diff --git a/package.json b/package.json index 855f9637..3d71ac47 100644 --- a/package.json +++ b/package.json @@ -144,12 +144,7 @@ "engines": { "node": ">= 20" }, - "trustedDependencies": [ - "@biomejs/biome", - "@swc/core", - "esbuild", - "protobufjs" - ], + "trustedDependencies": ["@biomejs/biome", "@swc/core", "esbuild", "protobufjs"], "dependencies": { "form-data": "^4.0.0", "idb": "^8.0.0", diff --git a/src/components/Editor/EditorToolbar.tsx b/src/components/Editor/EditorToolbar.tsx new file mode 100644 index 00000000..ec4c2f2b --- /dev/null +++ b/src/components/Editor/EditorToolbar.tsx @@ -0,0 +1,133 @@ +import clsx from 'clsx' +import { Show } from 'solid-js' +import { createEditorTransaction, useEditorHTML, useEditorIsEmpty } from 'solid-tiptap' +import { useEditorContext } from '~/context/editor' +import { useLocalize } from '~/context/localize' +import { useUI } from '~/context/ui' +import { Button } from '../_shared/Button' +import { Icon } from '../_shared/Icon' +import { Loading } from '../_shared/Loading' +import { Popover } from '../_shared/Popover' +import { SimplifiedEditorProps } from './SimplifiedEditor' + +import styles from './SimplifiedEditor.module.scss' + +export const ToolbarControls = ( + props: SimplifiedEditorProps & { setShouldShowLinkBubbleMenu: (x: boolean) => void } +) => { + const { t } = useLocalize() + const { showModal } = useUI() + const { editor } = useEditorContext() + const isActive = (name: string) => createEditorTransaction(editor, (ed) => ed?.isActive(name)) + const isBold = isActive('bold') + const isItalic = isActive('italic') + const isLink = isActive('link') + const isBlockquote = isActive('blockquote') + const isEmpty = useEditorIsEmpty(editor) + const html = useEditorHTML(editor) + + const handleClear = () => { + props.onCancel?.() + editor()?.commands.clearContent(true) + } + + const handleShowLinkBubble = () => { + editor()?.chain().focus().run() + props.setShouldShowLinkBubbleMenu(true) + } + + return ( + + {/* Only show controls if 'hideToolbar' is false */} +
+
+ {/* Bold button */} + + {(triggerRef: (el: HTMLElement) => void) => ( + + )} + + {/* Italic button */} + + {(triggerRef) => ( + + )} + + {/* Link button */} + + {(triggerRef) => ( + + )} + + {/* Blockquote button (optional) */} + + + {(triggerRef) => ( + + )} + + + {/* Image button (optional) */} + + + {(triggerRef) => ( + + )} + + +
+ {/* Cancel and submit buttons */} + +
+ +
+
+
+
+ ) +} diff --git a/src/components/Editor/MiniEditor/MiniEditor.tsx b/src/components/Editor/MiniEditor/MiniEditor.tsx index 0fb95c17..c866901f 100644 --- a/src/components/Editor/MiniEditor/MiniEditor.tsx +++ b/src/components/Editor/MiniEditor/MiniEditor.tsx @@ -11,12 +11,11 @@ import { useEditorIsFocused } from 'solid-tiptap' import { Toolbar } from 'terracotta' - import { Icon } from '~/components/_shared/Icon/Icon' import { Popover } from '~/components/_shared/Popover/Popover' import { useLocalize } from '~/context/localize' import { useUI } from '~/context/ui' -import { base, custom } from '~/lib/editorOptions' +import { base } from '~/lib/editorOptions' import { InsertLinkForm } from '../InsertLinkForm/InsertLinkForm' import styles from '../SimplifiedEditor.module.scss' @@ -72,7 +71,6 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element { element: editorElement()!, extensions: [ ...base, - ...custom, Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }), CharacterCount.configure({ limit: props.limit }) ], diff --git a/src/components/Editor/SimplifiedEditor.tsx b/src/components/Editor/SimplifiedEditor.tsx index de656fb7..2f689753 100644 --- a/src/components/Editor/SimplifiedEditor.tsx +++ b/src/components/Editor/SimplifiedEditor.tsx @@ -1,40 +1,27 @@ -import { Editor } from '@tiptap/core' -import { Blockquote } from '@tiptap/extension-blockquote' +import { Editor, FocusPosition } from '@tiptap/core' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { CharacterCount } from '@tiptap/extension-character-count' -import { Image } from '@tiptap/extension-image' -import { Link } from '@tiptap/extension-link' import { Placeholder } from '@tiptap/extension-placeholder' import { clsx } from 'clsx' -import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js' +import { Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js' import { Portal } from 'solid-js/web' -import { - createEditorTransaction, - createTiptapEditor, - useEditorHTML, - useEditorIsEmpty, - useEditorIsFocused -} from 'solid-tiptap' -import { useLocalize } from '~/context/localize' +import { createEditorTransaction, useEditorHTML, useEditorIsEmpty, useEditorIsFocused } from 'solid-tiptap' +import { useEditorContext } from '~/context/editor' import { useUI } from '~/context/ui' -import { base } from '~/lib/editorOptions' +import { base, custom } from '~/lib/editorOptions' +import { useEscKeyDownHandler } from '~/lib/useEscKeyDownHandler' import { UploadedFile } from '~/types/upload' -import { Button } from '../_shared/Button' -import { Icon } from '../_shared/Icon' -import { Loading } from '../_shared/Loading' import { Modal } from '../_shared/Modal/Modal' -import { Popover } from '../_shared/Popover' import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' +import { ToolbarControls } from './EditorToolbar' import { LinkBubbleMenuModule } from './LinkBubbleMenu' import { TextBubbleMenu } from './TextBubbleMenu' import { UploadModalContent } from './UploadModalContent' -import { Figcaption } from './extensions/Figcaption' -import { Figure } from './extensions/Figure' import { renderUploadedImage } from './renderUploadedImage' import styles from './SimplifiedEditor.module.scss' -type Props = { +export type SimplifiedEditorProps = { placeholder: string initialContent?: string label?: string @@ -52,7 +39,7 @@ type Props = { resetToInitial?: boolean smallHeight?: boolean submitByCtrlEnter?: boolean - onlyBubbleControls?: boolean + hideToolbar?: boolean controlsAlwaysVisible?: boolean autoFocus?: boolean isCancelButtonVisible?: boolean @@ -60,100 +47,75 @@ type Props = { } const DEFAULT_MAX_LENGTH = 400 -const ImageFigure = Figure.extend({ name: 'capturedImage', content: 'figcaption image' }) -const SimplifiedEditor = (props: Props) => { - const { t } = useLocalize() - const { showModal, hideModal } = useUI() +const SimplifiedEditor = (props: SimplifiedEditorProps) => { + // local signals const [counter, setCounter] = createSignal(0) const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false) - const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false) - const [editorElement, setEditorElement] = createSignal() - const editor = createTiptapEditor(() => ({ - element: editorElement()!, - extensions: [ - ...base, - Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }), - CharacterCount.configure({ limit: props.noLimits ? undefined : props.maxLength }), - Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }), - Blockquote.configure({ HTMLAttributes: { class: styles.blockQuote } }), - BubbleMenu.configure({ - pluginKey: 'textBubbleMenu', - element: textBubbleMenuRef(), - shouldShow: ({ view, state }) => - Boolean(props.onlyBubbleControls && view.hasFocus() && !state.selection.empty) - }), - BubbleMenu.configure({ - pluginKey: 'linkBubbleMenu', - element: linkBubbleMenuRef(), - shouldShow: ({ state }) => !state.selection.empty && shouldShowLinkBubbleMenu(), - tippyOptions: { placement: 'bottom' } - }), - ImageFigure, - Image, - Figcaption - ], - editorProps: { - attributes: { - class: styles.simplifiedEditorField - } - }, - content: props.initialContent || '' - })) - + const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) + const [editorElement, setEditorElement] = createSignal() const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal() const [linkBubbleMenuRef, setLinkBubbleMenuRef] = createSignal() + + // contexts + const { hideModal } = useUI() + const { editor, createEditor } = useEditorContext() + + const initEditor = (element?: HTMLElement) => { + if (element instanceof HTMLElement && editor()?.options.element !== element) { + const opts = { + element, + extensions: [ + // common extensions + ...base, + ...custom, + + // setup from component props + Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }), + CharacterCount.configure({ limit: props.noLimits ? undefined : props.maxLength }), + + // bubble menu 1 + BubbleMenu.configure({ + pluginKey: 'bubble-menu', + element: textBubbleMenuRef(), + shouldShow: ({ view }) => view.hasFocus() && shouldShowTextBubbleMenu() + }), + + // bubble menu 2 + BubbleMenu.configure({ + pluginKey: 'bubble-link-input', + element: linkBubbleMenuRef(), + shouldShow: ({ state }) => !state.selection.empty && shouldShowLinkBubbleMenu(), + tippyOptions: { placement: 'bottom' } + }) + ], + editorProps: { + attributes: { class: styles.simplifiedEditorField } + }, + content: props.initialContent || '', + onCreate: () => console.info('[SimplifiedEditor] created'), + onContentError: console.error, + autofocus: (props.autoFocus && 'end') as FocusPosition | undefined, + editable: true, + enableCoreExtensions: true, + enableContentCheck: true, + injectNonce: undefined, // TODO: can be useful copyright/copyleft mark + parseOptions: undefined // see: https://prosemirror.net/docs/ref/#model.ParseOptions + } + + createEditor(opts) + } + } + + // editor observers const isEmpty = useEditorIsEmpty(editor) const isFocused = useEditorIsFocused(editor) - const isActive = (name: string) => createEditorTransaction(editor, (ed) => ed?.isActive(name)) + const selection = createEditorTransaction(editor, (ed) => ed?.state.selection) const html = useEditorHTML(editor) - const isBold = isActive('bold') - const isItalic = isActive('italic') - const isLink = isActive('link') - const isBlockquote = isActive('blockquote') - const renderImage = (image: UploadedFile) => { - renderUploadedImage(editor() as Editor, image) - hideModal() - } - - const handleClear = () => { - props.onCancel?.() - editor()?.commands.clearContent(true) - } - - createEffect(() => { - if (props.setClear) { - editor()?.commands.clearContent(true) - } - if (props.resetToInitial) { - editor()?.commands.clearContent(true) - if (props.initialContent) editor()?.commands.setContent(props.initialContent) - } - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if (isEmpty() || !isFocused()) { - return - } - - if (event.code === 'Escape' && editor()) { - handleHideLinkBubble() - } - - if (event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - props.onSubmit?.(html() || '') - handleClear() - } - - // if (event.code === 'KeyK' && (event.metaKey || event.ctrlKey) && !editor().state.selection.empty) { - // event.preventDefault() - // handleShowLinkBubble() - // - // } - } + /// EFFECTS /// + // Mount event listeners for handling key events and clean up on component unmount onMount(() => { window.addEventListener('keydown', handleKeyDown) onCleanup(() => { @@ -162,26 +124,44 @@ const SimplifiedEditor = (props: Props) => { }) }) - if (props.onChange) { - createEffect(() => { - props.onChange?.(html() || '') - }) + // watch changes + createEffect(on(editorElement, initEditor, { defer: true })) // element -> editorOptions -> set editor + createEffect( + on(selection, (s?: Editor['state']['selection']) => s && setShouldShowTextBubbleMenu(!s?.empty)) + ) + createEffect( + on( + () => props.setClear, + (x?: boolean) => x && editor()?.commands.clearContent(true) + ) + ) + createEffect( + on( + () => props.resetToInitial, + (x?: boolean) => x && editor()?.commands.setContent(props.initialContent || '') + ) + ) + createEffect(on([html, () => props.onChange], ([c, handler]) => c && handler && handler(c))) // onChange + createEffect(on(html, (c?: string) => c && setCounter(editor()?.storage.characterCount.characters()))) //counter + + /// HANDLERS /// + + const handleImageRender = (image?: UploadedFile) => { + image && renderUploadedImage(editor() as Editor, image) + hideModal() } - createEffect(() => { - if (html()) { - setCounter(editor()?.storage.characterCount.characters()) + const handleKeyDown = (event: KeyboardEvent) => { + if ( + isFocused() && + !isEmpty() && + event.code === 'Enter' && + props.submitByCtrlEnter && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + props.onSubmit?.(html() || '') } - }) - - const maxHeightStyle = { - overflow: 'auto', - 'max-height': `${props.maxHeight}px` - } - - const handleShowLinkBubble = () => { - editor()?.chain().focus().run() - setShouldShowLinkBubbleMenu(true) } const handleHideLinkBubble = () => { @@ -189,6 +169,8 @@ const SimplifiedEditor = (props: Props) => { setShouldShowLinkBubbleMenu(false) } + useEscKeyDownHandler(handleHideLinkBubble) + return (
{ [styles.labelVisible]: props.label && counter() > 0 })} > - -
{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}
-
+ {/* Display label when applicable */} 0}>
{props.label}
-
- -
-
- - {(triggerRef: (el: HTMLElement) => void) => ( - - )} - - - {(triggerRef) => ( - - )} - - - {(triggerRef) => ( - - )} - - - - {(triggerRef) => ( - - )} - - - - - {(triggerRef) => ( - - )} - - -
- -
- -
-
-
+ + + } + > + + + {/* Link bubble menu */} + + + + + {/* editor element */} +
+ + {/* Display character limit if maxLength is provided */} + +
{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}
+
+ + {/* Image upload modal (show/hide) */} - { - renderImage(value as UploadedFile) - }} - /> + - - - -
) } -export default SimplifiedEditor // "export default" need to use for asynchronous (lazy) imports in the comments tree +export default SimplifiedEditor // Export component for lazy loading diff --git a/src/components/Views/EditView/EditView.tsx b/src/components/Views/EditView/EditView.tsx index 6a6079ac..db2a4edd 100644 --- a/src/components/Views/EditView/EditView.tsx +++ b/src/components/Views/EditView/EditView.tsx @@ -371,7 +371,7 @@ export const EditView = (props: Props) => { { resetToInitial={true} noLimits={true} variant="bordered" - onlyBubbleControls={true} + hideToolbar={true} smallHeight={true} label={t('About')} initialContent={about() || ''} diff --git a/src/components/Views/PublishSettings/PublishSettings.tsx b/src/components/Views/PublishSettings/PublishSettings.tsx index 5de717c0..77db6d1b 100644 --- a/src/components/Views/PublishSettings/PublishSettings.tsx +++ b/src/components/Views/PublishSettings/PublishSettings.tsx @@ -226,7 +226,7 @@ export const PublishSettings = (props: Props) => { /> setFormErrors: SetStoreFunction> editor: Accessor - setEditor: (e: Editor) => void + createEditor: (opts?: Partial) => void } export const EditorContext = createContext({} as EditorContextType) @@ -270,6 +271,18 @@ export const EditorProvider = (props: { children: JSX.Element }) => { } } + const createEditor = (opts?: Partial) => { + if (!opts) return + const old = editor() as Editor + const fresh = createTiptapEditor(() => ({ + ...old.options, + ...opts, + element: opts.element as HTMLElement + })) + old?.destroy() + setEditor(fresh()) + } + const actions = { saveShout, saveDraft, @@ -283,7 +296,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => { setForm, setFormErrors, editor, - setEditor + createEditor } const value: EditorContextType = { diff --git a/src/intl/locales/en/translation.json b/src/intl/locales/en/translation.json index c713d8f1..7d372a12 100644 --- a/src/intl/locales/en/translation.json +++ b/src/intl/locales/en/translation.json @@ -5,6 +5,7 @@ "Invalid email": "Check if your email is correct", "Join our maillist": "To receive the best postings, just enter your email", "Join the global community of authors!": "Join the global community of authors from all over the world!", + "Registered since {date}": "Registered since {date}", "shout": "post", "some authors": "{count} {count, plural, one {author} other {authors}}", "some comments": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}", diff --git a/src/lib/editorOptions.ts b/src/lib/editorOptions.ts index 065de790..f7686d7f 100644 --- a/src/lib/editorOptions.ts +++ b/src/lib/editorOptions.ts @@ -13,12 +13,6 @@ import { Span } from '~/components/Editor/extensions/Span' import { ToggleTextWrap } from '~/components/Editor/extensions/ToggleTextWrap' import { TrailingNode } from '~/components/Editor/extensions/TrailingNode' -// Extend the Figure extension to include Figcaption -const ImageFigure = Figure.extend({ - name: 'capturedImage', - content: 'figcaption image' -}) - export const base: EditorOptions['extensions'] = [ StarterKit.configure({ heading: { @@ -32,10 +26,7 @@ export const base: EditorOptions['extensions'] = [ blockquote: undefined }), Underline, // не входит в StarterKit - Link.configure({ - autolink: true, - openOnClick: false - }), + Link.configure({ autolink: true, openOnClick: false }), Image, Highlight.configure({ multicolor: true, @@ -45,20 +36,28 @@ export const base: EditorOptions['extensions'] = [ }) ] +// Extend the Figure extension to include Figcaption +export const ImageFigure = Figure.extend({ + name: 'capturedImage', + content: 'figcaption image' +}) + export const custom: EditorOptions['extensions'] = [ ImageFigure, Figure, Figcaption, - Footnote, - CustomBlockquote, Iframe, - Span, ToggleTextWrap, + Span, TrailingNode - // Добавьте другие кастомные расширения здесь ] -export const collab: EditorOptions['extensions'] = [] +export const extended: EditorOptions['extensions'] = [ + Footnote, + CustomBlockquote + // TODO: Добавьте другие кастомные расширения здесь +] + /* content: '', autofocus: false, diff --git a/vite.config.ts b/vite.config.ts index abd7fa4f..59ba20ae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -// biome-ignore lint/correctness/noNodejsModules: +// biome-ignore lint/correctness/noNodejsModules: used during build import path from 'node:path' import { CSSOptions } from 'vite' import mkcert from 'vite-plugin-mkcert'