microeditor-wip

This commit is contained in:
Untone 2024-09-27 16:46:43 +03:00
parent 0c61445293
commit 962140e755
6 changed files with 275 additions and 62 deletions

View File

@ -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<string | undefined>()
let inputRef: HTMLInputElement | undefined
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
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 (
<div class={styles.InlineForm}>
<div class={styles.form}>
<input
ref={(el) => (inputRef = el)}
ref={setInputRef}
type="text"
value={props.initialValue ?? ''}
value={formValue()}
placeholder={props.placeholder}
onKeyDown={handleKeyDown}
onInput={handleFormInput}
onFocus={props.onFocus}
/>
<Popover content={t('Add link')}>
{(triggerRef: (el: HTMLElement) => void) => (

View File

@ -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 (
<div>
<InlineForm
@ -49,6 +62,7 @@ export const InsertLinkForm = (props: Props) => {
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
onSubmit={handleLinkFormSubmit}
onClose={props.onClose}
onFocus={props.onFocus}
/>
</div>
)

View File

@ -0,0 +1,51 @@
import { Meta, StoryObj } from 'storybook-solidjs'
import { MicroEditor } from './MicroEditor'
const meta: Meta<typeof MicroEditor> = {
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<typeof MicroEditor>
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)
}
}

View File

@ -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 (
<Popover content={props.title}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: props.editor.isActive(props.key) })}
onClick={handleClick}
>
{props.children}
</button>
)}
</Popover>
)
}
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<HTMLDivElement>()
const [showLinkInput, setShowLinkInput] = createSignal(false)
const [showSimpleMenu, setShowSimpleMenu] = createSignal(false)
const [toolbarElement, setToolbarElement] = createSignal<HTMLElement>()
const [selectionRange, setSelectionRange] = createSignal<Range | null>(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 (
<div
class={clsx(styles.SimplifiedEditor, styles.bordered, {
[styles.isFocused]: isEmpty() || isFocused()
})}
>
<div>
<Show when={editor()} keyed>
{(instance) => (
<Show when={showSimpleMenu()}>
<div
style={{
display: 'inline-flex',
background: 'var(--editor-bubble-menu-background)',
border: '1px solid black'
}}
ref={setToolbarElement}
>
<div class={styles.controls}>
<Show
when={!showLinkInput()}
fallback={<InsertLinkForm editor={instance}
onClose={() => {
setShowLinkInput(false)
if (selectionRange()) {
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(selectionRange()!)
}
}}
onFocus={handleLinkInputFocus} />}
>
<div class={styles.actions}>
<Control
key="bold"
editor={instance}
onChange={() => instance.chain().focus().toggleBold().run()}
title={t('Bold')}
>
<Icon name="editor-bold" />
</Control>
<Control
key="italic"
editor={instance}
onChange={() => instance.chain().focus().toggleItalic().run()}
title={t('Italic')}
>
<Icon name="editor-italic" />
</Control>
<Control
key="link"
editor={instance}
onChange={() => setShowLinkInput(!showLinkInput())}
title={t('Add url')}
isActive={showLinkInput}
>
<Icon name="editor-link" />
</Control>
</div>
</Show>
</div>
</div>
</Show>
)}
</Show>
<div id="micro-editor" ref={setEditorElement} style={styles.minimal} />
</div>
</div>
)
}
export default MicroEditor

View File

@ -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<ExpoLayoutType | '', string> = {
'audio': 'Audio',
'video': 'Video',
'image': 'Artworks',
'literature': 'Literature',
audio: 'Audio',
video: 'Video',
image: 'Artworks',
literature: 'Literature',
'': 'All'
}

View File

@ -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.')
}
}
*/