microeditor-wip
This commit is contained in:
parent
0c61445293
commit
962140e755
|
@ -1,5 +1,5 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createSignal, onMount } from 'solid-js'
|
import { createEffect, createSignal, onMount } from 'solid-js'
|
||||||
|
|
||||||
import { Icon } from '~/components/_shared/Icon'
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
import { Popover } from '~/components/_shared/Popover'
|
import { Popover } from '~/components/_shared/Popover'
|
||||||
|
@ -15,20 +15,24 @@ type Props = {
|
||||||
initialValue?: string
|
initialValue?: string
|
||||||
showInput?: boolean
|
showInput?: boolean
|
||||||
placeholder: string
|
placeholder: string
|
||||||
|
onFocus?: (event: FocusEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InlineForm = (props: Props) => {
|
export const InlineForm = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [formValue, setFormValue] = createSignal(props.initialValue || '')
|
const [formValue, setFormValue] = createSignal(props.initialValue || '')
|
||||||
const [formValueError, setFormValueError] = createSignal<string | undefined>()
|
const [formValueError, setFormValueError] = createSignal<string | undefined>()
|
||||||
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
let inputRef: HTMLInputElement | undefined
|
|
||||||
const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => {
|
const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => {
|
||||||
const value = (e.currentTarget || e.target).value
|
const value = (e.currentTarget || e.target).value
|
||||||
setFormValueError()
|
setFormValueError()
|
||||||
setFormValue(value)
|
setFormValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setFormValue(props.initialValue || '')
|
||||||
|
})
|
||||||
|
|
||||||
const handleSaveButtonClick = async () => {
|
const handleSaveButtonClick = async () => {
|
||||||
if (props.validate) {
|
if (props.validate) {
|
||||||
const errorMessage = await props.validate(formValue())
|
const errorMessage = await props.validate(formValue())
|
||||||
|
@ -56,23 +60,23 @@ export const InlineForm = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
props.initialValue ? props.onClear?.() : props.onClose()
|
props.initialValue && props.onClear?.()
|
||||||
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => inputRef()?.focus())
|
||||||
inputRef?.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.InlineForm}>
|
<div class={styles.InlineForm}>
|
||||||
<div class={styles.form}>
|
<div class={styles.form}>
|
||||||
<input
|
<input
|
||||||
ref={(el) => (inputRef = el)}
|
ref={setInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={props.initialValue ?? ''}
|
value={formValue()}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onInput={handleFormInput}
|
onInput={handleFormInput}
|
||||||
|
onFocus={props.onFocus}
|
||||||
/>
|
/>
|
||||||
<Popover content={t('Add link')}>
|
<Popover content={t('Add link')}>
|
||||||
{(triggerRef: (el: HTMLElement) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Editor } from '@tiptap/core'
|
import { Editor } from '@tiptap/core'
|
||||||
import { createEditorTransaction } from 'solid-tiptap'
|
import { createEffect, createSignal, onCleanup } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { validateUrl } from '~/utils/validate'
|
import { validateUrl } from '~/utils/validate'
|
||||||
|
@ -8,6 +8,7 @@ import { InlineForm } from '../InlineForm'
|
||||||
type Props = {
|
type Props = {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onFocus: (event: FocusEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkUrl = (url: string) => {
|
export const checkUrl = (url: string) => {
|
||||||
|
@ -21,12 +22,22 @@ export const checkUrl = (url: string) => {
|
||||||
|
|
||||||
export const InsertLinkForm = (props: Props) => {
|
export const InsertLinkForm = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const currentUrl = createEditorTransaction(
|
const [currentUrl, setCurrentUrl] = createSignal('')
|
||||||
() => props.editor,
|
|
||||||
(ed) => {
|
createEffect(() => {
|
||||||
return ed?.getAttributes('link').href || ''
|
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 = () => {
|
const handleClearLinkForm = () => {
|
||||||
if (currentUrl()) {
|
if (currentUrl()) {
|
||||||
props.editor?.chain().focus().unsetLink().run()
|
props.editor?.chain().focus().unsetLink().run()
|
||||||
|
@ -39,7 +50,9 @@ export const InsertLinkForm = (props: Props) => {
|
||||||
.focus()
|
.focus()
|
||||||
.setLink({ href: checkUrl(value) })
|
.setLink({ href: checkUrl(value) })
|
||||||
.run()
|
.run()
|
||||||
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InlineForm
|
<InlineForm
|
||||||
|
@ -49,6 +62,7 @@ export const InsertLinkForm = (props: Props) => {
|
||||||
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
|
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
|
||||||
onSubmit={handleLinkFormSubmit}
|
onSubmit={handleLinkFormSubmit}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
onFocus={props.onFocus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
51
src/components/Editor/MicroEditor/MicroEditor.stories.tsx
Normal file
51
src/components/Editor/MicroEditor/MicroEditor.stories.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
173
src/components/Editor/MicroEditor/MicroEditor.tsx
Normal file
173
src/components/Editor/MicroEditor/MicroEditor.tsx
Normal 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
|
|
@ -18,10 +18,10 @@ export const PRERENDERED_ARTICLES_COUNT = 5
|
||||||
export const SHOUTS_PER_PAGE = 20
|
export const SHOUTS_PER_PAGE = 20
|
||||||
export const EXPO_LAYOUTS = ['audio', 'literature', 'video', 'image'] as ExpoLayoutType[]
|
export const EXPO_LAYOUTS = ['audio', 'literature', 'video', 'image'] as ExpoLayoutType[]
|
||||||
export const EXPO_TITLES: Record<ExpoLayoutType | '', string> = {
|
export const EXPO_TITLES: Record<ExpoLayoutType | '', string> = {
|
||||||
'audio': 'Audio',
|
audio: 'Audio',
|
||||||
'video': 'Video',
|
video: 'Video',
|
||||||
'image': 'Artworks',
|
image: 'Artworks',
|
||||||
'literature': 'Literature',
|
literature: 'Literature',
|
||||||
'': 'All'
|
'': 'All'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { EditorOptions } from '@tiptap/core'
|
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 Dropcursor from '@tiptap/extension-dropcursor'
|
||||||
import Focus from '@tiptap/extension-focus'
|
import Focus from '@tiptap/extension-focus'
|
||||||
import Gapcursor from '@tiptap/extension-gapcursor'
|
import Gapcursor from '@tiptap/extension-gapcursor'
|
||||||
|
@ -6,7 +8,10 @@ import HardBreak from '@tiptap/extension-hard-break'
|
||||||
import Highlight from '@tiptap/extension-highlight'
|
import Highlight from '@tiptap/extension-highlight'
|
||||||
import HorizontalRule from '@tiptap/extension-horizontal-rule'
|
import HorizontalRule from '@tiptap/extension-horizontal-rule'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
|
import Italic from '@tiptap/extension-italic'
|
||||||
import Link from '@tiptap/extension-link'
|
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 Underline from '@tiptap/extension-underline'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import ArticleNode from '~/components/Editor/extensions/Article'
|
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
|
// Extend the Figure extension to include Figcaption
|
||||||
export const ImageFigure = Figure.extend({
|
export const ImageFigure = Figure.extend({
|
||||||
name: 'capturedImage',
|
name: 'capturedImage',
|
||||||
|
@ -71,46 +85,3 @@ export const extended: EditorOptions['extensions'] = [
|
||||||
HardBreak,
|
HardBreak,
|
||||||
ArticleNode
|
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.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user