mini+micro-fix

This commit is contained in:
Untone 2024-10-01 20:18:27 +03:00
parent 21b3903062
commit abdc419aa8
7 changed files with 244 additions and 179 deletions

View File

@ -1,138 +0,0 @@
import { Editor } from '@tiptap/core'
import { Accessor, Show, createEffect, createSignal, on } from 'solid-js'
import { Portal } from 'solid-js/web'
import { createEditorTransaction } from 'solid-tiptap'
import { UploadModalContent } from '~/components/Upload/UploadModalContent/UploadModalContent'
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
import { Icon } from '~/components/_shared/Icon/Icon'
import { Modal } from '~/components/_shared/Modal/Modal'
import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui'
import { UploadedFile } from '~/types/upload'
import { InsertLinkForm } from './InsertLinkForm'
import { ToolbarControl as Control } from './ToolbarControl'
import styles from '../MiniEditor/MiniEditor.module.scss'
interface EditorToolbarProps {
editor: Accessor<Editor | undefined>
mode?: 'micro' | 'mini'
}
export const EditorToolbar = (props: EditorToolbarProps) => {
const { t } = useLocalize()
const { showModal } = useUI()
// show / hide for link input
const [showLinkInput, setShowLinkInput] = createSignal(false)
// focus on link input when it shows up
createEffect(on(showLinkInput, (x?: boolean) => x && props.editor()?.chain().focus().run()))
const selection = createEditorTransaction(props.editor, (instance) => instance?.state.selection)
// change visibility on selection if not in link input mode
const [showSimpleMenu, setShowSimpleMenu] = createSignal(false)
createEffect(
on([selection, showLinkInput], ([s, l]) => props.mode === 'micro' && !l && setShowSimpleMenu(!s?.empty))
)
const [storedSelection, setStoredSelection] = createSignal<Editor['state']['selection']>()
const recoverSelection = () => {
if (!storedSelection()?.empty) {
createEditorTransaction(props.editor, (instance?: Editor) => {
const r = selection()
if (instance && r) {
instance.state.selection.from === r.from
instance.state.selection.to === r.to
}
})
}
}
const storeSelection = () => {
const selection = props.editor()?.state.selection
if (!selection?.empty) {
setStoredSelection(selection)
}
}
const toggleShowLink = () => {
if (showLinkInput()) {
props.editor()?.chain().focus().run()
recoverSelection()
} else {
storeSelection()
}
setShowLinkInput(!showLinkInput())
}
return (
<div style={{ 'background-color': 'white', display: 'inline-flex' }}>
<Show
when={((props.mode === 'micro' && showSimpleMenu()) || props.mode !== 'micro') && props.editor()}
keyed
>
{(instance) => (
<div class={styles.controls}>
<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={toggleShowLink}
title={t('Add url')}
isActive={showLinkInput}
>
<Icon name="editor-link" />
</Control>
<Show when={props.mode !== 'micro'}>
<Control
key="blockquote"
editor={instance}
onChange={() => instance.chain().focus().toggleBlockquote().run()}
title={t('Add blockquote')}
>
<Icon name="editor-quote" />
</Control>
<Control
key="image"
editor={instance}
onChange={() => showModal('simplifiedEditorUploadImage')}
title={t('Add image')}
>
<Icon name="editor-image-dd-full" />
</Control>
</Show>
</div>
<Show when={showLinkInput()}>
<InsertLinkForm editor={instance} onClose={toggleShowLink} />
</Show>
<Portal>
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<UploadModalContent
onClose={(image) => renderUploadedImage(instance as Editor, image as UploadedFile)}
/>
</Modal>
</Portal>
</div>
)}
</Show>
</div>
)
}

View File

@ -5,8 +5,11 @@ import { validateUrl } from '~/utils/validate'
import { InlineForm } from '../../_shared/InlineForm' import { InlineForm } from '../../_shared/InlineForm'
type Props = { type Props = {
class?: string
editor: Editor editor: Editor
onClose: () => void onClose: () => void
onSubmit?: (value: string) => void
onRemove?: () => void
} }
export const checkUrl = (url: string) => { export const checkUrl = (url: string) => {
@ -38,7 +41,7 @@ export const InsertLinkForm = (props: Props) => {
const handleClearLinkForm = () => { const handleClearLinkForm = () => {
if (currentUrl()) { if (currentUrl()) {
props.editor?.chain().focus().unsetLink().run() props.onRemove?.()
} }
} }
@ -48,11 +51,12 @@ export const InsertLinkForm = (props: Props) => {
.focus() .focus()
.setLink({ href: checkUrl(value) }) .setLink({ href: checkUrl(value) })
.run() .run()
props.onSubmit?.(value)
props.onClose() props.onClose()
} }
return ( return (
<div> <div class={props.class}>
<InlineForm <InlineForm
placeholder={t('Enter URL address')} placeholder={t('Enter URL address')}
initialValue={currentUrl() ?? ''} initialValue={currentUrl() ?? ''}

View File

@ -6,7 +6,7 @@ import { Popover } from '~/components/_shared/Popover'
import styles from '../MiniEditor/MiniEditor.module.scss' import styles from '../MiniEditor/MiniEditor.module.scss'
interface ControlProps { interface ControlProps {
editor: Editor editor: Editor | undefined
title: string title: string
key: string key: string
onChange: () => void onChange: () => void
@ -27,7 +27,7 @@ export const ToolbarControl = (props: ControlProps): JSX.Element => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={clsx(styles.actionButton, { [styles.active]: props.editor.isActive(props.key) })} class={clsx(styles.actionButton, { [styles.active]: props.editor?.isActive?.(props.key) })}
onClick={handleClick} onClick={handleClick}
> >
{props.children} {props.children}

View File

@ -1,22 +1,31 @@
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import BubbleMenu from '@tiptap/extension-bubble-menu'
import clsx from 'clsx' import clsx from 'clsx'
import { type JSX, createEffect, createSignal, on } from 'solid-js' import { type JSX, Show, createEffect, createSignal, on } from 'solid-js'
import { createTiptapEditor, useEditorHTML, useEditorIsFocused } from 'solid-tiptap' import { createEditorTransaction, createTiptapEditor, useEditorHTML, useEditorIsFocused } from 'solid-tiptap'
import { minimal } from '~/lib/editorExtensions' import { minimal } from '~/lib/editorExtensions'
import { EditorToolbar } from '../EditorToolbar/EditorToolbar' import { Editor } from '@tiptap/core'
import { Icon } from '~/components/_shared/Icon/Icon'
import { ToolbarControl as Control } from '../EditorToolbar/ToolbarControl'
import { InsertLinkForm } from '../EditorToolbar/InsertLinkForm'
import styles from '../MiniEditor/MiniEditor.module.scss' import styles from '../MiniEditor/MiniEditor.module.scss'
import { useLocalize } from '~/context/localize'
interface MicroEditorProps { interface MicroEditorProps {
content?: string content?: string
onChange?: (content: string) => void onChange?: (content: string) => void
onSubmit?: (content: string) => void onSubmit?: (content: string) => void
placeholder?: string placeholder?: string
bordered?: boolean
} }
export const MicroEditor = (props: MicroEditorProps): JSX.Element => { export const MicroEditor = (props: MicroEditorProps): JSX.Element => {
const { t } = useLocalize()
const [showLinkForm, setShowLinkForm] = createSignal(false)
const [isActive, setIsActive] = createSignal(false)
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>() const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const editor = createTiptapEditor(() => ({ const editor = createTiptapEditor(() => ({
element: editorElement()!, element: editorElement()!,
extensions: [ extensions: [
@ -28,20 +37,99 @@ export const MicroEditor = (props: MicroEditorProps): JSX.Element => {
class: styles.simplifiedEditorField class: styles.simplifiedEditorField
} }
}, },
content: props.content || '' content: props.content || '',
autofocus: 'end'
})) }))
const isFocused = useEditorIsFocused(editor) const selection = createEditorTransaction(editor, (e?: Editor) => e?.state.selection)
const html = useEditorHTML(editor) const html = useEditorHTML(editor)
const toggleLinkForm = () => {
setShowLinkForm(!showLinkForm())
// Если форма закрывается, возвращаем фокус редактору
!showLinkForm() && editor()?.commands.focus()
}
const setLink = (url: string) => {
url && editor()?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
!url && editor()?.chain().focus().extendMarkRange('link').unsetLink().run()
setShowLinkForm(false)
}
const removeLink = () => {
editor()?.chain().focus().unsetLink().run()
setShowLinkForm(false)
}
const handleLinkButtonClick = () => {
if (editor()?.isActive('link')) {
const previousUrl = editor()?.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
url && setLink(url)
} else {
toggleLinkForm()
}
}
createEffect(on(html, (c?: string) => c && props.onChange?.(c))) createEffect(on(html, (c?: string) => c && props.onChange?.(c)))
createEffect(() => {
const updateActive = () => {
setIsActive(Boolean(selection()) || editor()?.isActive('link') || showLinkForm())
}
updateActive()
editor()?.on('focus', updateActive)
editor()?.on('blur', updateActive)
return () => {
editor()?.off('focus', updateActive)
editor()?.off('blur', updateActive)
}
})
return ( return (
<div class={clsx(styles.MiniEditor, styles.bordered, { [styles.isFocused]: isFocused() })}> <div class={clsx(
<div> styles.MiniEditor, {
<EditorToolbar editor={editor} mode={'micro'} /> [styles.bordered]: props.bordered,
<div id="micro-editor" ref={setEditorElement} style={styles.minimal} /> [styles.isFocused]: isActive() && selection() && Boolean(!selection()?.empty)
})}>
<div class={styles.controls}>
<div class={styles.actions}>
<Control
key="bold"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleBold().run()}
title={t('Bold')}
>
<Icon name="editor-bold" />
</Control>
<Control
key="italic"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleItalic().run()}
title={t('Italic')}
>
<Icon name="editor-italic" />
</Control>
<Control
key="link"
editor={editor()}
onChange={handleLinkButtonClick}
title={t('Add url')}
isActive={(e: Editor) => Boolean(e?.isActive('link'))}
>
<Icon name="editor-link" />
</Control>
<InsertLinkForm
class={clsx([styles.linkInput, { [styles.linkInputactive]: showLinkForm() }])}
editor={editor() as Editor}
onClose={toggleLinkForm}
onSubmit={setLink}
onRemove={removeLink}
/>
</div> </div>
</div> </div>
<div id="micro-editor" ref={setEditorElement} style={styles.minimal} />
</div>
) )
} }

View File

@ -45,6 +45,14 @@
} }
} }
.bubbleMenu {
display: flex;
background-color: white;
padding: 5px;
border-radius: 5px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
}
.controls { .controls {
margin-top: auto; margin-top: auto;
display: flex; display: flex;
@ -89,6 +97,15 @@
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
}
.linkInput {
opacity: 0;
transition: opacity ease-in-out 0.3s;
&.linkInputactive {
opacity: 1;
}
} }
} }

View File

@ -2,13 +2,22 @@ import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import clsx from 'clsx' import clsx from 'clsx'
import { type JSX, Show, createEffect, createSignal, on } from 'solid-js' import { type JSX, Show, createEffect, createSignal, on } from 'solid-js'
import { createEditorTransaction, createTiptapEditor, useEditorHTML } from 'solid-tiptap' import { createEditorTransaction, createTiptapEditor, useEditorHTML, useEditorIsEmpty } from 'solid-tiptap'
import { Button } from '~/components/_shared/Button' import { Button } from '~/components/_shared/Button'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { base } from '~/lib/editorExtensions' import { base } from '~/lib/editorExtensions'
import { EditorToolbar } from '../EditorToolbar/EditorToolbar' import { ToolbarControl as Control } from '../EditorToolbar/ToolbarControl'
import styles from './MiniEditor.module.scss' import styles from './MiniEditor.module.scss'
import { Editor } from '@tiptap/core'
import { InsertLinkForm } from '../EditorToolbar/InsertLinkForm'
import { Icon } from '~/components/_shared/Icon/Icon'
import { useUI } from '~/context/ui'
import { Modal } from '~/components/_shared/Modal'
import { UploadModalContent } from '~/components/Upload/UploadModalContent'
import { Portal } from 'solid-js/web'
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
import { UploadedFile } from '~/types/upload'
interface MiniEditorProps { interface MiniEditorProps {
content?: string content?: string
@ -21,9 +30,10 @@ interface MiniEditorProps {
export default function MiniEditor(props: MiniEditorProps): JSX.Element { export default function MiniEditor(props: MiniEditorProps): JSX.Element {
const { t } = useLocalize() const { t } = useLocalize()
const { showModal } = useUI()
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>() const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const [counter, setCounter] = createSignal(0) const [counter, setCounter] = createSignal(0)
const [showLinkForm, setShowLinkForm] = createSignal(false)
const editor = createTiptapEditor(() => ({ const editor = createTiptapEditor(() => ({
element: editorElement()!, element: editorElement()!,
extensions: [ extensions: [
@ -36,12 +46,39 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
class: styles.simplifiedEditorField class: styles.simplifiedEditorField
} }
}, },
content: props.content || '' content: props.content || '',
autofocus: 'end'
})) }))
const isFocused = createEditorTransaction(editor, (instance) => instance?.isFocused)
const isEmpty = createEditorTransaction(editor, (instance) => instance?.isEmpty)
const html = useEditorHTML(editor) const html = useEditorHTML(editor)
const isEmpty = useEditorIsEmpty(editor)
const toggleLinkForm = () => {
setShowLinkForm(!showLinkForm())
// Если форма закрывается, возвращаем фокус редактору
!showLinkForm() && editor()?.commands.focus()
}
const setLink = (url: string) => {
url && editor()?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
!url && editor()?.chain().focus().extendMarkRange('link').unsetLink().run()
setShowLinkForm(false)
}
const removeLink = () => {
editor()?.chain().focus().unsetLink().run()
setShowLinkForm(false)
}
const handleLinkButtonClick = () => {
if (editor()?.isActive('link')) {
const previousUrl = editor()?.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
url && setLink(url)
} else {
toggleLinkForm()
}
}
createEffect(on(html, (c?: string) => c && props.onChange?.(c))) createEffect(on(html, (c?: string) => c && props.onChange?.(c)))
@ -58,11 +95,69 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
} }
return ( return (
<div class={clsx(styles.MiniEditor, styles.bordered, { [styles.isFocused]: isFocused() })}> <div class={clsx(styles.MiniEditor, styles.isFocused)}>
<div> <div class={clsx(styles.controls, styles.isFocused)}>
<div id="mini-editor" ref={setEditorElement} /> <div class={clsx(styles.actions, styles.active)}>
<Control
key="bold"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleBold().run()}
title={t('Bold')}
>
<Icon name="editor-bold" />
</Control>
<Control
key="italic"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleItalic().run()}
title={t('Italic')}
>
<Icon name="editor-italic" />
</Control>
<Control
key="link"
editor={editor()}
onChange={handleLinkButtonClick}
title={t('Add url')}
isActive={(e: Editor) => Boolean(e?.isActive('link'))}
>
<Icon name="editor-link" />
</Control>
<Control
key="blockquote"
editor={editor()}
onChange={() => editor()?.chain().focus().toggleBlockquote().run()}
title={t('Add blockquote')}
>
<Icon name="editor-quote" />
</Control>
<Control
key="image"
editor={editor()}
onChange={() => showModal('simplifiedEditorUploadImage')}
title={t('Add image')}
>
<Icon name="editor-image-dd-full" />
</Control>
<InsertLinkForm
class={clsx([styles.linkInput, { [styles.linkInputactive]: showLinkForm() }])}
editor={editor() as Editor}
onClose={toggleLinkForm}
onSubmit={setLink}
onRemove={removeLink}
/>
</div>
</div>
<EditorToolbar editor={editor} mode={'mini'} /> <Portal>
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<UploadModalContent
onClose={(image) => renderUploadedImage(editor() as Editor, image as UploadedFile)}
/>
</Modal>
</Portal>
<div id="mini-editor" ref={setEditorElement} style={styles.minimal} />
<div class={styles.buttons}> <div class={styles.buttons}>
<Button <Button
@ -80,6 +175,5 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
</small> </small>
</Show> </Show>
</div> </div>
</div>
) )
} }

View File

@ -339,7 +339,7 @@ export const ProfileSettings = () => {
/> />
<h4>{t('About')}</h4> <h4>{t('About')}</h4>
<MicroEditor content={about() || ''} onChange={setAbout} placeholder={t('About')} /> <MicroEditor content={about() || ''} onChange={setAbout} placeholder={t('About')} bordered={true} />
<div class={clsx(styles.multipleControls, 'pretty-form__item')}> <div class={clsx(styles.multipleControls, 'pretty-form__item')}>
<div class={styles.multipleControlsHeader}> <div class={styles.multipleControlsHeader}>
<h4>{t('Social networks')}</h4> <h4>{t('Social networks')}</h4>