From 962140e755012af80237dd772fb56497683070f7 Mon Sep 17 00:00:00 2001 From: Untone Date: Fri, 27 Sep 2024 16:46:43 +0300 Subject: [PATCH] microeditor-wip --- .../Editor/InlineForm/InlineForm.tsx | 22 ++- .../Editor/InsertLinkForm/InsertLinkForm.tsx | 26 ++- .../MicroEditor/MicroEditor.stories.tsx | 51 ++++++ .../Editor/MicroEditor/MicroEditor.tsx | 173 ++++++++++++++++++ src/context/feed.tsx | 8 +- src/lib/editorExtensions.ts | 57 ++---- 6 files changed, 275 insertions(+), 62 deletions(-) create mode 100644 src/components/Editor/MicroEditor/MicroEditor.stories.tsx create mode 100644 src/components/Editor/MicroEditor/MicroEditor.tsx diff --git a/src/components/Editor/InlineForm/InlineForm.tsx b/src/components/Editor/InlineForm/InlineForm.tsx index 2eaf983f..4325832f 100644 --- a/src/components/Editor/InlineForm/InlineForm.tsx +++ b/src/components/Editor/InlineForm/InlineForm.tsx @@ -1,5 +1,5 @@ import { clsx } from 'clsx' -import { createSignal, onMount } from 'solid-js' +import { createEffect, createSignal, onMount } from 'solid-js' import { Icon } from '~/components/_shared/Icon' import { Popover } from '~/components/_shared/Popover' @@ -15,20 +15,24 @@ type Props = { initialValue?: string showInput?: boolean placeholder: string + onFocus?: (event: FocusEvent) => void } export const InlineForm = (props: Props) => { const { t } = useLocalize() const [formValue, setFormValue] = createSignal(props.initialValue || '') const [formValueError, setFormValueError] = createSignal() - - let inputRef: HTMLInputElement | undefined + const [inputRef, setInputRef] = createSignal() const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { const value = (e.currentTarget || e.target).value setFormValueError() setFormValue(value) } + createEffect(() => { + setFormValue(props.initialValue || '') + }) + const handleSaveButtonClick = async () => { if (props.validate) { const errorMessage = await props.validate(formValue()) @@ -56,23 +60,23 @@ export const InlineForm = (props: Props) => { } const handleClear = () => { - props.initialValue ? props.onClear?.() : props.onClose() + props.initialValue && props.onClear?.() + props.onClose() } - onMount(() => { - inputRef?.focus() - }) + onMount(() => inputRef()?.focus()) return (
(inputRef = el)} + ref={setInputRef} type="text" - value={props.initialValue ?? ''} + value={formValue()} placeholder={props.placeholder} onKeyDown={handleKeyDown} onInput={handleFormInput} + onFocus={props.onFocus} /> {(triggerRef: (el: HTMLElement) => void) => ( diff --git a/src/components/Editor/InsertLinkForm/InsertLinkForm.tsx b/src/components/Editor/InsertLinkForm/InsertLinkForm.tsx index 379c3f5d..601ad0ec 100644 --- a/src/components/Editor/InsertLinkForm/InsertLinkForm.tsx +++ b/src/components/Editor/InsertLinkForm/InsertLinkForm.tsx @@ -1,5 +1,5 @@ import { Editor } from '@tiptap/core' -import { createEditorTransaction } from 'solid-tiptap' +import { createEffect, createSignal, onCleanup } from 'solid-js' import { useLocalize } from '~/context/localize' import { validateUrl } from '~/utils/validate' @@ -8,6 +8,7 @@ import { InlineForm } from '../InlineForm' type Props = { editor: Editor onClose: () => void + onFocus: (event: FocusEvent) => void } export const checkUrl = (url: string) => { @@ -21,12 +22,22 @@ export const checkUrl = (url: string) => { export const InsertLinkForm = (props: Props) => { const { t } = useLocalize() - const currentUrl = createEditorTransaction( - () => props.editor, - (ed) => { - return ed?.getAttributes('link').href || '' + const [currentUrl, setCurrentUrl] = createSignal('') + + createEffect(() => { + const url = props.editor.getAttributes('link').href + setCurrentUrl(url || '') + }) + + createEffect(() => { + const updateListener = () => { + const url = props.editor.getAttributes('link').href + setCurrentUrl(url || '') } - ) + props.editor.on('update', updateListener) + onCleanup(() => props.editor.off('update', updateListener)) + }) + const handleClearLinkForm = () => { if (currentUrl()) { props.editor?.chain().focus().unsetLink().run() @@ -39,7 +50,9 @@ export const InsertLinkForm = (props: Props) => { .focus() .setLink({ href: checkUrl(value) }) .run() + props.onClose() } + return (
{ validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))} onSubmit={handleLinkFormSubmit} onClose={props.onClose} + onFocus={props.onFocus} />
) diff --git a/src/components/Editor/MicroEditor/MicroEditor.stories.tsx b/src/components/Editor/MicroEditor/MicroEditor.stories.tsx new file mode 100644 index 00000000..da31bd3f --- /dev/null +++ b/src/components/Editor/MicroEditor/MicroEditor.stories.tsx @@ -0,0 +1,51 @@ +import { Meta, StoryObj } from 'storybook-solidjs' +import { MicroEditor } from './MicroEditor' + +const meta: Meta = { + title: 'Components/MicroEditor', + component: MicroEditor, + argTypes: { + content: { + control: 'text', + description: 'Initial content for the editor', + defaultValue: '' + }, + placeholder: { + control: 'text', + description: 'Placeholder text when the editor is empty', + defaultValue: 'Start typing here...' + }, + onChange: { + action: 'changed', + description: 'Callback when the content changes' + } + } +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + content: '', + placeholder: 'Start typing here...', + onChange: (content: string) => console.log('Content changed:', content) + } +} + +export const WithInitialContent: Story = { + args: { + content: 'This is some initial content.', + placeholder: 'Start typing here...', + onChange: (content: string) => console.log('Content changed:', content) + } +} + +export const WithCustomPlaceholder: Story = { + args: { + content: '', + placeholder: 'Type your text here...', + onChange: (content: string) => console.log('Content changed:', content) + } +} diff --git a/src/components/Editor/MicroEditor/MicroEditor.tsx b/src/components/Editor/MicroEditor/MicroEditor.tsx new file mode 100644 index 00000000..e16fcf33 --- /dev/null +++ b/src/components/Editor/MicroEditor/MicroEditor.tsx @@ -0,0 +1,173 @@ +import type { Editor } from '@tiptap/core' +import Placeholder from '@tiptap/extension-placeholder' +import clsx from 'clsx' +import { type JSX, Show, createEffect, createReaction, createSignal, on, onCleanup } from 'solid-js' +import { + createEditorTransaction, + createTiptapEditor, + useEditorHTML, + useEditorIsEmpty, + useEditorIsFocused +} from 'solid-tiptap' +import { Icon } from '~/components/_shared/Icon/Icon' +import { Popover } from '~/components/_shared/Popover/Popover' +import { useLocalize } from '~/context/localize' +import { minimal } from '~/lib/editorExtensions' +import { InsertLinkForm } from '../InsertLinkForm/InsertLinkForm' + +import styles from '../SimplifiedEditor.module.scss' + +interface ControlProps { + editor: Editor + title: string + key: string + onChange: () => void + isActive?: (editor: Editor) => boolean + children: JSX.Element +} + +function Control(props: ControlProps): JSX.Element { + const handleClick = (ev?: MouseEvent) => { + ev?.preventDefault() + ev?.stopPropagation() + props.onChange?.() + } + + return ( + + {(triggerRef: (el: HTMLElement) => void) => ( + + )} + + ) +} + +interface MicroEditorProps { + content?: string + onChange?: (content: string) => void + placeholder?: string +} + +const prevent = (e: Event) => e.preventDefault() + +export const MicroEditor = (props: MicroEditorProps): JSX.Element => { + const { t } = useLocalize() + const [editorElement, setEditorElement] = createSignal() + const [showLinkInput, setShowLinkInput] = createSignal(false) + const [showSimpleMenu, setShowSimpleMenu] = createSignal(false) + const [toolbarElement, setToolbarElement] = createSignal() + const [selectionRange, setSelectionRange] = createSignal(null) + + const handleLinkInputFocus = (event: FocusEvent) => { + event.preventDefault() + const selection = window.getSelection() + if (selection?.rangeCount) { + setSelectionRange(selection.getRangeAt(0)) + } + } + + const editor = createTiptapEditor(() => ({ + element: editorElement()!, + extensions: [ + ...minimal, + Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }) + ], + editorProps: { + attributes: { + class: styles.simplifiedEditorField + } + }, + content: props.content || '' + })) + + const isEmpty = useEditorIsEmpty(editor) + const isFocused = useEditorIsFocused(editor) + const isTextSelection = createEditorTransaction(editor, (instance) => !instance?.state.selection.empty) + const html = useEditorHTML(editor) + + createEffect(on([isTextSelection, showLinkInput],([selected, linkEditing]) => !linkEditing && setShowSimpleMenu(selected))) + createEffect(on(html, (c?: string) => c && props.onChange?.(c))) + createEffect(on(showLinkInput, (x?: boolean) => x && editor()?.chain().focus().run())) + createReaction(on(toolbarElement, (t?: HTMLElement) => t?.addEventListener('mousedown', prevent))) + onCleanup(() => toolbarElement()?.removeEventListener('mousedown', prevent)) + + return ( +
+
+ + {(instance) => ( + +
+
+ { + setShowLinkInput(false) + if (selectionRange()) { + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(selectionRange()!) + } + }} + onFocus={handleLinkInputFocus} />} + > +
+ instance.chain().focus().toggleBold().run()} + title={t('Bold')} + > + + + instance.chain().focus().toggleItalic().run()} + title={t('Italic')} + > + + + setShowLinkInput(!showLinkInput())} + title={t('Add url')} + isActive={showLinkInput} + > + + +
+
+
+
+
+ )} +
+ +
+
+
+ ) +} + +export default MicroEditor diff --git a/src/context/feed.tsx b/src/context/feed.tsx index b6d22375..43508628 100644 --- a/src/context/feed.tsx +++ b/src/context/feed.tsx @@ -18,10 +18,10 @@ export const PRERENDERED_ARTICLES_COUNT = 5 export const SHOUTS_PER_PAGE = 20 export const EXPO_LAYOUTS = ['audio', 'literature', 'video', 'image'] as ExpoLayoutType[] export const EXPO_TITLES: Record = { - 'audio': 'Audio', - 'video': 'Video', - 'image': 'Artworks', - 'literature': 'Literature', + audio: 'Audio', + video: 'Video', + image: 'Artworks', + literature: 'Literature', '': 'All' } diff --git a/src/lib/editorExtensions.ts b/src/lib/editorExtensions.ts index b6603987..8e6553c5 100644 --- a/src/lib/editorExtensions.ts +++ b/src/lib/editorExtensions.ts @@ -1,4 +1,6 @@ import { EditorOptions } from '@tiptap/core' +import Bold from '@tiptap/extension-bold' +import { Document as DocExt } from '@tiptap/extension-document' import Dropcursor from '@tiptap/extension-dropcursor' import Focus from '@tiptap/extension-focus' import Gapcursor from '@tiptap/extension-gapcursor' @@ -6,7 +8,10 @@ import HardBreak from '@tiptap/extension-hard-break' import Highlight from '@tiptap/extension-highlight' import HorizontalRule from '@tiptap/extension-horizontal-rule' import Image from '@tiptap/extension-image' +import Italic from '@tiptap/extension-italic' import Link from '@tiptap/extension-link' +import Paragraph from '@tiptap/extension-paragraph' +import { Text } from '@tiptap/extension-text' import Underline from '@tiptap/extension-underline' import StarterKit from '@tiptap/starter-kit' import ArticleNode from '~/components/Editor/extensions/Article' @@ -42,6 +47,15 @@ export const base: EditorOptions['extensions'] = [ }) ] +export const minimal: EditorOptions['extensions'] = [ + DocExt, + Text, + Paragraph, + Bold, + Italic, + Link.configure({ autolink: true, openOnClick: false }) +] + // Extend the Figure extension to include Figcaption export const ImageFigure = Figure.extend({ name: 'capturedImage', @@ -71,46 +85,3 @@ export const extended: EditorOptions['extensions'] = [ HardBreak, ArticleNode ] - -/* - content: '', - autofocus: false, - editable: false, - element: undefined, - injectCSS: false, - injectNonce: undefined, - editorProps: {} as EditorProps, - parseOptions: {} as EditorOptions['parseOptions'], - enableInputRules: false, - enablePasteRules: false, - enableCoreExtensions: false, - enableContentCheck: false, - onBeforeCreate: (_props: EditorEvents['beforeCreate']): void => { - throw new Error('Function not implemented.') - }, - onCreate: (_props: EditorEvents['create']): void => { - throw new Error('Function not implemented.') - }, - onContentError: (_props: EditorEvents['contentError']): void => { - throw new Error('Function not implemented.') - }, - onUpdate: (_props: EditorEvents['update']): void => { - throw new Error('Function not implemented.') - }, - onSelectionUpdate: (_props: EditorEvents['selectionUpdate']): void => { - throw new Error('Function not implemented.') - }, - onTransaction: (_props: EditorEvents['transaction']): void => { - throw new Error('Function not implemented.') - }, - onFocus: (_props: EditorEvents['focus']): void => { - throw new Error('Function not implemented.') - }, - onBlur: (_props: EditorEvents['blur']): void => { - throw new Error('Function not implemented.') - }, - onDestroy: (_props: EditorEvents['destroy']): void => { - throw new Error('Function not implemented.') - } -} -*/