From ffe0ede8354adeda6a4347396030de6b00ef89d5 Mon Sep 17 00:00:00 2001 From: Untone Date: Fri, 11 Oct 2024 21:34:57 +0300 Subject: [PATCH] bubble-conditions-wip --- src/components/Editor/Editor.module.scss | 519 ++++++++++--------- src/components/Editor/Editor.tsx | 442 +++++----------- src/components/Editor/MiniEditor.module.scss | 2 +- src/components/Upload/renderUploadedImage.ts | 48 +- src/lib/handleImageUpload.ts | 58 +++ 5 files changed, 474 insertions(+), 595 deletions(-) diff --git a/src/components/Editor/Editor.module.scss b/src/components/Editor/Editor.module.scss index 83f5ad10..a09b9d7b 100644 --- a/src/components/Editor/Editor.module.scss +++ b/src/components/Editor/Editor.module.scss @@ -1,313 +1,314 @@ -.articleEditor { +.ProseMirror { font-size: 1.6rem; outline: none; min-height: 300px; +} - p.is-editor-empty:first-child::before { - content: attr(data-placeholder); +.ProseMirror p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + opacity: 0.3; +} + +// Keeping the cursor active when moving outside the editable area + +/* Give a remote user a caret */ +.collaboration-cursor__caret { + border-left: 1px solid #0d0d0d; + border-right: 1px solid #0d0d0d; + margin-left: -1px; + margin-right: -1px; + pointer-events: none; + position: relative; + word-break: normal; +} + +/* Render the username above the caret */ +.collaboration-cursor__label { + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + font-size: 12px; + font-style: normal; + font-weight: 600; + left: -1px; + line-height: normal; + padding: 0.1rem 0.3rem; + position: absolute; + top: -1.4em; + user-select: none; + white-space: nowrap; +} + +.embed-wrapper { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: #f1f1f1; + margin: 4rem 0; + + iframe { + border: none; + overflow: hidden; + } +} + +.horizontalRule { + border-top: 2px solid #000; +} + +mark.highlight { + box-decoration-break: clone; + padding: 0.2em 0; +} + +// custom atibutes fro TipTap Nodes + +@include media-breakpoint-up(sm) { + [data-float] { + max-width: 50%; + } + + [data-float='left'] { float: left; - height: 0; - pointer-events: none; - opacity: 0.3; + max-width: 35%; + margin: 1rem 2.2em 0 0; + clear: left; } - /* Give a remote user a caret */ - .collaboration-cursor__caret { - border-left: 1px solid #0d0d0d; - border-right: 1px solid #0d0d0d; - margin-left: -1px; - margin-right: -1px; - pointer-events: none; - position: relative; - word-break: normal; + [data-float='right'] { + float: right; + margin: 1rem 0 1rem 2.2em; + max-width: 35%; + clear: right; } + [data-float='half-left'] { + float: left; + margin: 1rem 1rem 0; + clear: left; - /* Render the username above the caret */ - .collaboration-cursor__label { - border-radius: 3px 3px 3px 0; - color: #0d0d0d; - font-size: 12px; - font-style: normal; - font-weight: 600; - left: -1px; - line-height: normal; - padding: 0.1rem 0.3rem; - position: absolute; - top: -1.4em; - user-select: none; - white-space: nowrap; - } - - .embed-wrapper { - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - background: #f1f1f1; - margin: 4rem 0; - - iframe { - border: none; - overflow: hidden; - } - } - - .horizontalRule { - border-top: 2px solid #000; - } - - mark.highlight { - box-decoration-break: clone; - padding: 0.2em 0; - } - - // custom atibutes fro TipTap Nodes - - @include media-breakpoint-up(sm) { - [data-float] { + @include media-breakpoint-up(md) { max-width: 50%; - } - - [data-float='left'] { - float: left; - max-width: 35%; - margin: 1rem 2.2em 0 0; - clear: left; - } - - [data-float='right'] { - float: right; - margin: 1rem 0 1rem 2.2em; - max-width: 35%; - clear: right; - } - - [data-float='half-left'] { - float: left; - margin: 1rem 1rem 0; - clear: left; - - @include media-breakpoint-up(md) { - max-width: 50%; - min-width: 30%; - } - } - - [data-float='half-right'] { - float: right; - margin: 1rem 0; - clear: right; - - @include media-breakpoint-up(md) { - max-width: 50%; - min-width: 30%; - } + min-width: 30%; } } - blockquote, .blockquote { - p:last-child { - margin-bottom: 0; + [data-float='half-right'] { + float: right; + margin: 1rem 0; + clear: right; + + @include media-breakpoint-up(md) { + max-width: 50%; + min-width: 30%; + } + } +} + +.ProseMirror blockquote { + p:last-child { + margin-bottom: 0; + } + + &[data-type='quote'] { + font-size: 1.4rem; + border: solid #000; + border-width: 0 0 0 2px; + margin: 1.6rem 0; + padding: 0 0 0 1.5em; + + &[data-float='left'] { + padding-left: 0; + padding-right: 0; + margin-right: 1.6rem; + border-width: 0 2px 0 0; + clear: left; } - &[data-type='quote'] { - font-size: 1.4rem; - border: solid #000; + &[data-float='right'] { + margin-left: 1.6rem; border-width: 0 0 0 2px; - margin: 1.6rem 0; - padding: 0 0 0 1.5em; + clear: right; + } + } + &[data-type='punchline'] { + border: solid #000; + border-width: 2px 0; + font-size: 3.2rem; + font-weight: 700; + line-height: 1.2; + margin: 1em 0; + padding: 2.4rem 0; + + &[data-float='left'], + &[data-float='right'] { + font-size: 2.2rem; + line-height: 1.4; + } + + @include media-breakpoint-up(sm) { &[data-float='left'] { - padding-left: 0; - padding-right: 0; - margin-right: 1.6rem; - border-width: 0 2px 0 0; + margin-right: 1.5em; clear: left; } &[data-float='right'] { - margin-left: 1.6rem; - border-width: 0 0 0 2px; + margin-left: 1.5em; clear: right; } } + } +} - &[data-type='punchline'] { - border: solid #000; - border-width: 2px 0; - font-size: 3.2rem; - font-weight: 700; - line-height: 1.2; - margin: 1em 0; - padding: 2.4rem 0; +.ProseMirror article[data-type='incut'] { + background: #f1f2f3; + font-size: 1.4rem; + margin: 1em -1rem; + padding: 2em 2rem; + transition: background 0.3s ease-in-out; - &[data-float='left'], - &[data-float='right'] { - font-size: 2.2rem; - line-height: 1.4; - } - - @include media-breakpoint-up(sm) { - &[data-float='left'] { - margin-right: 1.5em; - clear: left; - } - - &[data-float='right'] { - margin-left: 1.5em; - clear: right; - } - } - } + @include media-breakpoint-up(sm) { + margin-left: -2rem; + margin-right: -2rem; } - article[data-type='incut'] { - background: #f1f2f3; - font-size: 1.4rem; - margin: 1em -1rem; - padding: 2em 2rem; - transition: background 0.3s ease-in-out; + @include media-breakpoint-up(lg) { + margin-right: -6%; + padding-left: 3em; + padding-right: 3em; + } + + @include media-breakpoint-up(lg) { + margin-left: -3em; + margin-right: -3em; + } + + &[data-float] img { + float: none; + max-width: unset; + width: 100% !important; + margin: 0; + } + + &[data-float='left'], + &[data-float='half-left'] { + margin-left: -1rem; + clear: left; @include media-breakpoint-up(sm) { margin-left: -2rem; + margin-right: 2rem; + } + + @include media-breakpoint-up(lg) { + margin-left: -6%; + } + + @include media-breakpoint-up(xl) { + margin-left: -12.5%; + } + } + + &[data-float='right'], + &[data-float='half-right'] { + margin-right: -1rem; + clear: right; + + @include media-breakpoint-up(sm) { + margin-left: 2rem; margin-right: -2rem; } @include media-breakpoint-up(lg) { margin-right: -6%; - padding-left: 3em; - padding-right: 3em; } - @include media-breakpoint-up(lg) { - margin-left: -3em; - margin-right: -3em; - } - - &[data-float] img { - float: none; - max-width: unset; - width: 100% !important; - margin: 0; - } - - &[data-float='left'], - &[data-float='half-left'] { - margin-left: -1rem; - clear: left; - - @include media-breakpoint-up(sm) { - margin-left: -2rem; - margin-right: 2rem; - } - - @include media-breakpoint-up(lg) { - margin-left: -6%; - } - - @include media-breakpoint-up(xl) { - margin-left: -12.5%; - } - } - - &[data-float='right'], - &[data-float='half-right'] { - margin-right: -1rem; - clear: right; - - @include media-breakpoint-up(sm) { - margin-left: 2rem; - margin-right: -2rem; - } - - @include media-breakpoint-up(lg) { - margin-right: -6%; - } - - @include media-breakpoint-up(xl) { - margin-right: -12.5%; - } - } - - *:last-child { - margin-bottom: 0; - } - - &[data-bg='black'] { - background: #000; - color: #fff; - } - - &[data-bg='yellow'] { - background: #f6e3a1; - } - - &[data-bg='pink'] { - background: #f1b5bc; - } - - &[data-bg='green'] { - background: #eafff2; - } - - &[data-bg='white'] { - background: #fff; - box-shadow: 0 0 0 1px #000; + @include media-breakpoint-up(xl) { + margin-right: -12.5%; } } - figure[data-type='figure'] { - width: 100% !important; - - .iframe-wrapper { - position: relative; - overflow: hidden; - width: 100%; - height: auto; - - iframe { - display: block; - width: 100%; - } - } + *:last-child { + margin-bottom: 0; } - /* stylelint-disable-next-line selector-type-no-unknown */ - footnote, .footnote { - display: inline-flex; + &[data-bg='black'] { + background: #000; + color: #fff; + } + + &[data-bg='yellow'] { + background: #f6e3a1; + } + + &[data-bg='pink'] { + background: #f1b5bc; + } + + &[data-bg='green'] { + background: #eafff2; + } + + &[data-bg='white'] { + background: #fff; + box-shadow: 0 0 0 1px #000; + } +} + +.ProseMirror-hideselection figure[data-type='figure'] { + & > figcaption { + --selection-color: rgb(0 0 0 / 60%); + } +} + +figure[data-type='figure'] { + width: 100% !important; + + .iframe-wrapper { position: relative; - cursor: pointer; - width: 0.8rem; - height: 1em; + overflow: hidden; + width: 100%; + height: auto; - &::before { - content: ''; - position: absolute; - width: 10px; - height: 10px; - border-radius: 50%; - top: -2px; - border: unset; - background-size: 10px; - background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgY2xhc3M9ImJpIGJpLWluZm8tY2lyY2xlIiBmaWxsPSJjdXJyZW50Q29sb3IiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik04IDE1QTcgNyAwIDEgMSA4IDFhNyA3IDAgMCAxIDAgMTR6bTAgMUE4IDggMCAxIDAgOCAwYTggOCAwIDAgMCAwIDE2eiIvPjxwYXRoIGQ9Im04LjkzIDYuNTg4LTIuMjkuMjg3LS4wODIuMzguNDUuMDgzYy4yOTQuMDcuMzUyLjE3Ni4yODguNDY5bC0uNzM4IDMuNDY4Yy0uMTk0Ljg5Ny4xMDUgMS4zMTkuODA4IDEuMzE5LjU0NSAwIDEuMTc4LS4yNTIgMS40NjUtLjU5OGwuMDg4LS40MTZjLS4yLjE3Ni0uNDkyLjI0Ni0uNjg2LjI0Ni0uMjc1IDAtLjM3NS0uMTkzLS4zMDQtLjUzM0w4LjkzIDYuNTg4ek05IDQuNWExIDEgMCAxIDEtMiAwIDEgMSAwIDAgMSAyIDB6Ii8+PC9zdmc+'); - } - - &:hover { - background-color: unset; + iframe { + display: block; + width: 100%; } } +} - .highlight-fake-selection { - background: var(--selection-background); - color: var(--selection-color); - border: solid var(--selection-background); - border-width: 0; +/* stylelint-disable-next-line selector-type-no-unknown */ +footnote { + display: inline-flex; + position: relative; + cursor: pointer; + width: 0.8rem; + height: 1em; + + &::before { + content: ''; + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + top: -2px; + border: unset; + background-size: 10px; + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjxzdmcgY2xhc3M9ImJpIGJpLWluZm8tY2lyY2xlIiBmaWxsPSJjdXJyZW50Q29sb3IiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik04IDE1QTcgNyAwIDEgMSA4IDFhNyA3IDAgMCAxIDAgMTR6bTAgMUE4IDggMCAxIDAgOCAwYTggOCAwIDAgMCAwIDE2eiIvPjxwYXRoIGQ9Im04LjkzIDYuNTg4LTIuMjkuMjg3LS4wODIuMzguNDUuMDgzYy4yOTQuMDcuMzUyLjE3Ni4yODguNDY5bC0uNzM4IDMuNDY4Yy0uMTk0Ljg5Ny4xMDUgMS4zMTkuODA4IDEuMzE5LjU0NSAwIDEuMTc4LS4yNTIgMS40NjUtLjU5OGwuMDg4LS40MTZjLS4yLjE3Ni0uNDkyLjI0Ni0uNjg2LjI0Ni0uMjc1IDAtLjM3NS0uMTkzLS4zMDQtLjUzM0w4LjkzIDYuNTg4ek05IDQuNWExIDEgMCAxIDEtMiAwIDEgMSAwIDAgMSAyIDB6Ii8+PC9zdmc+'); } - &.ProseMirror-hideselection figure[data-type='figure'] { - &>figcaption { - --selection-color: rgb(0 0 0 / 60%); - } + &:hover { + background-color: unset; } +} + +.highlight-fake-selection { + background: var(--selection-background); + color: var(--selection-color); + border: solid var(--selection-background); + border-width: 0; } \ No newline at end of file diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index 6b035a8b..c0bb6a1b 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -1,337 +1,167 @@ -import { HocuspocusProvider } from '@hocuspocus/provider' -import { UploadFile } from '@solid-primitives/upload' -import { Editor, EditorOptions } from '@tiptap/core' +import { Editor, isTextSelection } from '@tiptap/core' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { CharacterCount } from '@tiptap/extension-character-count' -import { Collaboration } from '@tiptap/extension-collaboration' -import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor' import { FloatingMenu } from '@tiptap/extension-floating-menu' +import { Link } from '@tiptap/extension-link' import { Placeholder } from '@tiptap/extension-placeholder' -import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' -import { isServer } from 'solid-js/web' -import { createEditorTransaction, createTiptapEditor } from 'solid-tiptap' -import uniqolor from 'uniqolor' -import { Doc } from 'yjs' -import { useEditorContext } from '~/context/editor' -import { useLocalize } from '~/context/localize' -import { useSession } from '~/context/session' +import { createEffect, createSignal, onCleanup } from 'solid-js' +import { createTiptapEditor } from 'solid-tiptap' import { useSnackbar } from '~/context/ui' -import { Author } from '~/graphql/schema/core.gen' import { base, custom, extended } from '~/lib/editorExtensions' -import { handleImageUpload } from '~/lib/handleImageUpload' -import { allowedImageTypes, renderUploadedImage } from '../Upload/renderUploadedImage' +import { handleClipboardPaste } from '~/lib/handleImageUpload' +import { useEditorContext } from '../../context/editor' +import { useLocalize } from '../../context/localize' +import { useSession } from '../../context/session' import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu' import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu' import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu' import { FullBubbleMenu } from './Toolbar/FullBubbleMenu' import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu' +import { ArticleNode } from './extensions/Article' +import { TrailingNode } from './extensions/TrailingNode' -import styles from './Editor.module.scss' +import './Editor.module.scss' -export type EditorComponentProps = { +type Props = { shoutId: number initialContent?: string onChange: (text: string) => void } -const yDocs: Record = {} -const providers: Record = {} - -export const EditorComponent = (props: EditorComponentProps) => { +export const EditorComponent = (props: Props) => { const { t } = useLocalize() - const { session, requireAuthentication } = useSession() - const author = createMemo(() => session()?.user?.app_data?.profile as Author) - const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) + const { session } = useSession() const { showSnackbar } = useSnackbar() - const { countWords, setEditing, isCollabMode } = useEditorContext() - const [editorOptions, setEditorOptions] = createSignal>({}) + const { countWords, setEditing } = useEditorContext() + const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) + const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) const [editorElRef, setEditorElRef] = createSignal() const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal() const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal() const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal() const [floatingMenuRef, setFloatingMenuRef] = createSignal() - const [fullBubbleMenuRef, setFullBubbleMenuRef] = createSignal() - const [editor, setEditor] = createSignal(null) - const [menusInitialized, setMenusInitialized] = createSignal(false) - const [shouldShowFullBubbleMenu, setShouldShowFullBubbleMenu] = createSignal(false) + const [textBubbleMenuRef, setFullBubbleMenuRef] = createSignal() + + const editor = createTiptapEditor(() => ({ + element: editorElRef()!, + editorProps: { + attributes: { + class: 'articleEditor' + }, + transformPastedHTML(html) { + return html.replaceAll(//g, '') + }, + handlePaste: () => { + showSnackbar({ body: t('Uploading image') }) + handleClipboardPaste(editor(), session()?.access_token || '').then(() => false) + return false + } + }, + extensions: [ + ...base, + ...custom, + ...extended, + Placeholder.configure({ + placeholder: t('Add a link or click plus to embed media') + }), + CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689 + BubbleMenu.configure({ + pluginKey: 'textBubbleMenu', + element: textBubbleMenuRef()!, + shouldShow: ({ editor: e, view, state: { doc, selection } , from, to }) => { + const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection) + if (isEmptyTextBlock) { + e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run() + } + setIsCommonMarkup(e.isActive('figcaption')) + const result = + (view.hasFocus() && + !selection.empty && + !isEmptyTextBlock && + !e.isActive('image') && + !e.isActive('figure')) || + e.isActive('footnote') || + (e.isActive('figcaption') && !selection.empty) + setShouldShowTextBubbleMenu(result) + return result + }, + tippyOptions: { + sticky: true + } + }), + BubbleMenu.configure({ + pluginKey: 'blockquoteBubbleMenu', + element: blockquoteBubbleMenuRef()!, + shouldShow: ({ editor: e, state }) => { + const { selection } = state + const { empty } = selection + return empty && e.isActive('blockquote') + }, + tippyOptions: { + offset: [0, 0], + placement: 'top', + getReferenceClientRect: (): DOMRect => { + const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null + if (selectedElement) { + return selectedElement.getBoundingClientRect() + } + return new DOMRect() + } + } + }), + BubbleMenu.configure({ + pluginKey: 'incutBubbleMenu', + element: incutBubbleMenuRef()!, + shouldShow: ({ editor: e, state }) => { + const { selection } = state + const { empty } = selection + return empty && e.isActive('article') + }, + tippyOptions: { + offset: [0, -16], + placement: 'top', + getReferenceClientRect: (): DOMRect => { + const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null + if (selectedElement) { + return selectedElement.getBoundingClientRect() + } + return new DOMRect() + } + } + }), + BubbleMenu.configure({ + pluginKey: 'imageBubbleMenu', + element: figureBubbleMenuRef()!, + shouldShow: ({ editor: e, view }) => { + return view.hasFocus() && e.isActive('image') + } + }), + FloatingMenu.configure({ + tippyOptions: { + placement: 'left' + }, + element: floatingMenuRef()! + }), + TrailingNode, + ArticleNode + ], + enablePasteRules: [Link], + content: props.initialContent || null, + onTransaction: ({ editor: e, transaction }) => { + if (transaction.docChanged) { + const html = e.getHTML() + html && props.onChange(html) + const wordCount: number = e.storage.characterCount.words() + const charsCount: number = e.storage.characterCount.characters() + wordCount && countWords({ words: wordCount, characters: charsCount }) + } + } + })) // store tiptap editor in context provider's signal to use it in Panel createEffect(() => setEditing(editor() || undefined)) - /** - * Создает экземпляр редактора с заданными опциями - * @param opts Опции редактора - */ - const createEditorInstance = (opts?: Partial) => { - if (!opts?.element) { - console.error('Editor options or element is missing') - return - } - console.log('stage 2: create editor instance without menus', opts) - - const old = editor() || { options: {} as EditorOptions } - const uniqueExtensions = [ - ...new Map( - [...(old?.options?.extensions || []), ...(opts?.extensions || [])].map((ext) => [ext.name, ext]) - ).values() - ] - - const fresh = createTiptapEditor(() => ({ - ...old?.options, - ...opts, - element: opts.element as HTMLElement, - extensions: uniqueExtensions - })) - if (old instanceof Editor) old?.destroy() - setEditor(fresh() || null) - } - - const handleClipboardPaste = async () => { - try { - const clipboardItems: ClipboardItems = await navigator.clipboard.read() - - if (clipboardItems.length === 0) return - const [clipboardItem] = clipboardItems - const { types } = clipboardItem - const imageType: string | undefined = types.find((type) => allowedImageTypes.has(type)) - - if (!imageType) return - const blob = await clipboardItem.getType(imageType) - const extension = imageType.split('/')[1] - const file = new File([blob], `clipboardImage.${extension}`) - - const uplFile: UploadFile = { - source: blob.toString(), - name: file.name, - size: file.size, - file - } - - showSnackbar({ body: t('Uploading image') }) - const image: { url: string; originalFilename?: string } = await handleImageUpload( - uplFile, - session()?.access_token || '' - ) - renderUploadedImage(editor() as Editor, image) - } catch (error) { - console.error('[Paste Image Error]:', error) - } - return false - } - - // stage 0: update editor options - const setupEditor = () => { - console.log('stage 0: update editor options') - const options: Partial = { - element: editorElRef()!, - editorProps: { - attributes: { class: styles.articleEditor }, - transformPastedHTML: (c: string) => c.replaceAll(//g, ''), - handlePaste: () => { - handleClipboardPaste().then((_) => 0) - } - }, - extensions: [ - ...base, - ...custom, - ...extended, - Placeholder.configure({ - placeholder: t('Add a link or click plus to embed media') - }), - CharacterCount.configure() - ], - onTransaction({ transaction, editor }) { - if (transaction.docChanged) { - const html = editor.getHTML() - html && props.onChange(html) - const wordCount: number = editor.storage.characterCount.words() - const charsCount: number = editor.storage.characterCount.characters() - wordCount && countWords({ words: wordCount, characters: charsCount }) - } - }, - content: props.initialContent ?? null - } - console.log(options) - setEditorOptions(() => options) - return options - } - - // stage 1: create editor options when got author profile - createEffect( - on([editorOptions, author], ([opts, a]: [Partial | undefined, Author | undefined]) => { - if (isServer) return - console.log('stage 1: create editor options when got author profile', { opts, a }) - const noOptions = !opts || Object.keys(opts).length === 0 - noOptions && a && setTimeout(setupEditor, 1) - }) - ) - - const isFigcaptionActive = createEditorTransaction(editor as Accessor, (e) => - e?.isActive('figcaption') - ) - createEffect(() => setIsCommonMarkup(!!isFigcaptionActive())) - - const initializeMenus = () => { - if (menusInitialized() || !editor()) return - if ( - blockquoteBubbleMenuRef() && - figureBubbleMenuRef() && - incutBubbleMenuRef() && - floatingMenuRef() && - fullBubbleMenuRef() - ) { - console.log('stage 3: initialize menus when editor instance is ready') - const menus = [ - BubbleMenu.configure({ - element: fullBubbleMenuRef()!, - pluginKey: 'fullBubbleMenu', - shouldShow: ({ editor: e, state: { selection } }) => { - const { empty, from, to } = selection - const hasSelection = !empty && from !== to - const shouldShow = - e.view.hasFocus() && hasSelection && !e.isActive('image') && !e.isActive('figure') - setShouldShowFullBubbleMenu(shouldShow) - return shouldShow - }, - tippyOptions: { - duration: 200, - placement: 'top' - } - }), - BubbleMenu.configure({ - pluginKey: 'blockquoteBubbleMenu', - element: blockquoteBubbleMenuRef()!, - shouldShow: ({ editor: e, state: { selection } }) => - e.isFocused && !selection.empty && e.isActive('blockquote'), - tippyOptions: { - offset: [0, 0], - placement: 'top', - getReferenceClientRect: () => { - const selectedElement = editor()?.view.dom.querySelector('.has-focus') - return selectedElement?.getBoundingClientRect() || new DOMRect() - } - } - }), - BubbleMenu.configure({ - pluginKey: 'figureBubbleMenu', - element: figureBubbleMenuRef()!, - shouldShow: ({ editor: e, view }) => view.hasFocus() && e.isActive('figure') - }), - BubbleMenu.configure({ - pluginKey: 'incutBubbleMenu', - element: incutBubbleMenuRef()!, - shouldShow: ({ editor: e, state: { selection } }) => - e.isFocused && !selection.empty && e.isActive('figcaption'), - tippyOptions: { - offset: [0, -16], - placement: 'top', - getReferenceClientRect: () => { - const selectedElement = editor()?.view.dom.querySelector('.has-focus') - return selectedElement?.getBoundingClientRect() || new DOMRect() - } - } - }), - FloatingMenu.configure({ - element: floatingMenuRef()!, - pluginKey: 'floatingMenu', - shouldShow: ({ editor: e, state: { selection } }) => { - const { $anchor, empty } = selection - const isRootDepth = $anchor.depth === 1 - if (!(isRootDepth && empty)) return false - return !(e.isActive('codeBlock') || e.isActive('heading')) - }, - tippyOptions: { - placement: 'left' - } - }) - ] - setEditorOptions((prev) => ({ ...prev, extensions: [...(prev.extensions || []), ...menus] })) - setMenusInitialized(true) - } else { - console.error('Some menu references are missing') - } - } - - const initializeCollaboration = () => { - if (!editor()) { - console.error('Editor is not initialized') - return - } - - setEditorOptions((prev: Partial) => { - const extensions = [...(prev.extensions || [])] - - try { - if (!isCollabMode()) { - // Remove collaboration extensions and return - const filteredExtensions = extensions.filter( - (ext) => ext.name !== 'collaboration' && ext.name !== 'collaborationCursor' - ) - return { ...prev, extensions: filteredExtensions } - } - - const docName = `shout-${props.shoutId}` - const token = session()?.access_token || '' - const profile = author() - - if (!(token && profile)) { - throw new Error('Missing authentication data') - } - - if (!yDocs[docName]) { - yDocs[docName] = new Doc() - } - - if (!providers[docName]) { - providers[docName] = new HocuspocusProvider({ - url: 'wss://hocuspocus.discours.io', - name: docName, - document: yDocs[docName], - token - }) - console.log(`HocuspocusProvider установлен для ${docName}`) - } - extensions.push( - Collaboration.configure({ document: yDocs[docName] }), - CollaborationCursor.configure({ - provider: providers[docName], - user: { name: profile.name, color: uniqolor(profile.slug).color } - }) - ) - } catch (error) { - console.error('Error initializing collaboration:', error) - showSnackbar({ body: t('Failed to initialize collaboration') }) - } - console.log('collab extensions added:', extensions) - return { ...prev, extensions } - }) - } - - const handleFocus = (event: FocusEvent) => { - console.log('handling focus event', event) - if (editor()?.isActive('figcaption')) { - editor()?.commands.focus() - console.log('active figcaption detected, focusing editor') - } - } - - onMount(() => { - console.log('Editor component mounted') - editorElRef()?.addEventListener('focus', handleFocus) - requireAuthentication(() => { - setTimeout(() => { - const opts = setupEditor() - createEditorInstance(opts) - initializeMenus() - }, 120) - }, 'edit') - }) - - // collab mode on/off - createEffect(on(isCollabMode, (x) => !x && initializeCollaboration(), { defer: true })) - onCleanup(() => { - editorElRef()?.removeEventListener('focus', handleFocus) editor()?.destroy() }) @@ -345,9 +175,9 @@ export const EditorComponent = (props: EditorComponentProps) => { } + editor={editor} ref={setFullBubbleMenuRef} - shouldShow={shouldShowFullBubbleMenu} + shouldShow={shouldShowTextBubbleMenu} isCommonMarkup={isCommonMarkup()} /> diff --git a/src/components/Editor/MiniEditor.module.scss b/src/components/Editor/MiniEditor.module.scss index f5487703..c57af7cc 100644 --- a/src/components/Editor/MiniEditor.module.scss +++ b/src/components/Editor/MiniEditor.module.scss @@ -31,7 +31,7 @@ outline: none; } - .blockQuote { + .blockquote { font-weight: 500; color: var(--black-300); border-left: 2px solid var(--black-100); diff --git a/src/components/Upload/renderUploadedImage.ts b/src/components/Upload/renderUploadedImage.ts index c7aee077..83baec31 100644 --- a/src/components/Upload/renderUploadedImage.ts +++ b/src/components/Upload/renderUploadedImage.ts @@ -1,33 +1,23 @@ import { Editor } from '@tiptap/core' export const renderUploadedImage = (editor: Editor, image: { url: string; originalFilename?: string }) => { - editor - .chain() - .focus() - .insertContent({ - type: 'figure', - attrs: { 'data-type': 'image' }, - content: [ - { - type: 'image', - attrs: { src: image.url } - }, - { - type: 'figcaption', - content: [{ type: 'text', text: image.originalFilename }] - } - ] - }) - .run() + image?.url && + editor + .chain() + .focus() + .insertContent({ + type: 'figure', + attrs: { 'data-type': 'image' }, + content: [ + { + type: 'image', + attrs: { src: image.url } + }, + { + type: 'figcaption', + content: [{ type: 'text', text: image.originalFilename }] + } + ] + }) + .run() } - -export const allowedImageTypes = new Set([ - 'image/bmp', - 'image/gif', - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/tiff', - 'image/webp', - 'image/x-icon' -]) diff --git a/src/lib/handleImageUpload.ts b/src/lib/handleImageUpload.ts index 5eef3717..2e9e7c1b 100644 --- a/src/lib/handleImageUpload.ts +++ b/src/lib/handleImageUpload.ts @@ -1,6 +1,18 @@ import { UploadFile } from '@solid-primitives/upload' +import { Editor } from '@tiptap/core' import { thumborUrl } from '../config' +export const allowedImageTypes = new Set([ + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/tiff', + 'image/webp', + 'image/x-icon' +]) + export const handleImageUpload = async (uploadFile: UploadFile, token: string) => { const formData = new FormData() formData.append('media', uploadFile.file, uploadFile.name) @@ -38,3 +50,49 @@ export const handleImageUpload = async (uploadFile: UploadFile, token: string) = url } } + +export const handleClipboardPaste = async (editor?: Editor, token = '') => { + try { + const clipboardItems: ClipboardItems = await navigator.clipboard.read() + + if (clipboardItems.length === 0) return + const [clipboardItem] = clipboardItems + const { types } = clipboardItem + const imageType = types.find((type) => allowedImageTypes.has(type)) + + if (!imageType) return + const blob = await clipboardItem.getType(imageType) + const extension = imageType.split('/')[1] + const file = new File([blob], `clipboardImage.${extension}`) + + const uplFile = { + source: blob.toString(), + name: file.name, + size: file.size, + file + } + + const result = await handleImageUpload(uplFile, token) + + editor + ?.chain() + .focus() + .insertContent({ + type: 'figure', + attrs: { 'data-type': 'image' }, + content: [ + { + type: 'image', + attrs: { src: result.url } + }, + { + type: 'figcaption', + content: [{ type: 'text', text: result.originalFilename }] + } + ] + }) + .run() + } catch (error) { + console.error('[Paste Image Error]:', error) + } +}