bubble-menu-used
Some checks failed
deploy / test (push) Failing after 6m42s
deploy / Update templates on Mailgun (push) Has been skipped

This commit is contained in:
Untone 2024-10-01 21:11:07 +03:00
parent 67e8c80d9a
commit ae1a93469b
7 changed files with 148 additions and 93 deletions

View File

@ -5,7 +5,7 @@ import { createEditorTransaction } from 'solid-tiptap'
import { Icon } from '~/components/_shared/Icon'
import { Popover } from '~/components/_shared/Popover'
import { useLocalize } from '~/context/localize'
import { InsertLinkForm } from '../EditorToolbar/InsertLinkForm'
import { InsertLinkForm } from '../Toolbar/InsertLinkForm'
import styles from './TextBubbleMenu.module.scss'

View File

@ -0,0 +1,29 @@
.MicroBubbleMenu {
display: flex;
background-color: white;
padding: 5px;
border-radius: 5px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 5%), 0 10px 20px rgb(0 0 0 / 10%);
.bubbleMenuButton {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
&:hover,
&.bubbleMenuButtonActive {
opacity: 1;
}
}
.noWrap {
white-space: nowrap;
}
}

View File

@ -0,0 +1,100 @@
import type { Editor } from '@tiptap/core'
import { clsx } from 'clsx'
import { Show, createEffect, createSignal } from 'solid-js'
import { createEditorTransaction } from 'solid-tiptap'
import { Icon } from '~/components/_shared/Icon'
import { Popover } from '~/components/_shared/Popover'
import { useLocalize } from '~/context/localize'
import { InsertLinkForm } from '../Toolbar/InsertLinkForm'
import styles from './MicroBubbleMenu.module.scss'
type MicroBubbleMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
hidden: boolean
}
export const MicroBubbleMenu = (props: MicroBubbleMenuProps) => {
const { t } = useLocalize()
const isActive = (name: string, attributes?: Record<string, string | number>) =>
createEditorTransaction(
() => props.editor,
(editor) => editor?.isActive(name, attributes)
)
const [linkEditorOpen, setLinkEditorOpen] = createSignal(false)
createEffect(() => props.hidden && setLinkEditorOpen(false))
const isBold = isActive('bold')
const isItalic = isActive('italic')
const isLink = isActive('link')
const handleOpenLinkForm = () => {
const { from, to } = props.editor.state.selection
props.editor?.chain().focus().setTextSelection({ from, to }).run()
setLinkEditorOpen(true)
}
const handleCloseLinkForm = () => {
setLinkEditorOpen(false)
// Снимаем выделение, устанавливая курсор в конец текущего выделения
const { to } = props.editor.state.selection
props.editor?.chain().focus().setTextSelection(to).run()
}
return (
<div ref={props.ref} class={styles.MicroBubbleMenu}>
<Show
when={!linkEditorOpen()}
fallback={<InsertLinkForm editor={props.editor} onClose={handleCloseLinkForm} />}
>
<Popover content={t('Bold')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBold()
})}
onClick={() => props.editor?.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
)}
</Popover>
<Popover content={t('Italic')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isItalic()
})}
onClick={() => props.editor?.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
)}
</Popover>
<Popover content={<div class={styles.noWrap}>{t('Add url')}</div>}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
onClick={handleOpenLinkForm}
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink()
})}
>
<Icon name="editor-link" />
</button>
)}
</Popover>
</Show>
</div>
)
}
export default MicroBubbleMenu

View File

@ -1,15 +1,11 @@
import { Editor } from '@tiptap/core'
import BubbleMenu from '@tiptap/extension-bubble-menu'
import Placeholder from '@tiptap/extension-placeholder'
import clsx from 'clsx'
import { type JSX, createEffect, createSignal, on } from 'solid-js'
import { createEditorTransaction, createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import { minimal } from '~/lib/editorExtensions'
import { MicroBubbleMenu } from './MicroBubbleMenu'
import { Icon } from '~/components/_shared/Icon/Icon'
import { InsertLinkForm } from '../EditorToolbar/InsertLinkForm'
import { ToolbarControl as Control } from '../EditorToolbar/ToolbarControl'
import { useLocalize } from '~/context/localize'
import styles from '../MiniEditor/MiniEditor.module.scss'
interface MicroEditorProps {
@ -21,15 +17,18 @@ interface MicroEditorProps {
}
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 [bubbleMenuElement, setBubbleMenuElement] = createSignal<HTMLDivElement>()
const editor = createTiptapEditor(() => ({
element: editorElement()!,
extensions: [
...minimal,
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder })
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }),
BubbleMenu.configure({
element: bubbleMenuElement()!,
shouldShow: ({ state: { selection }, editor }) => !selection.empty || editor.isActive('link')
})
],
editorProps: {
attributes: {
@ -40,94 +39,21 @@ export const MicroEditor = (props: MicroEditorProps): JSX.Element => {
autofocus: 'end'
}))
const selection = createEditorTransaction(editor, (e?: Editor) => e?.state.selection)
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(() => {
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 (
<div
class={clsx(styles.MiniEditor, {
[styles.bordered]: props.bordered,
[styles.isFocused]: isActive() && selection() && Boolean(!selection()?.empty)
[styles.bordered]: props.bordered
})}
>
<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}
<MicroBubbleMenu
editor={editor()!}
ref={setBubbleMenuElement}
hidden={!!editor()?.state.selection.empty}
/>
</div>
</div>
<div id="micro-editor" ref={setEditorElement} style={styles.minimal} />
</div>
)

View File

@ -6,7 +6,7 @@ import { createTiptapEditor, useEditorHTML, useEditorIsEmpty } from 'solid-tipta
import { Button } from '~/components/_shared/Button'
import { useLocalize } from '~/context/localize'
import { base } from '~/lib/editorExtensions'
import { ToolbarControl as Control } from '../EditorToolbar/ToolbarControl'
import { ToolbarControl as Control } from '../Toolbar/ToolbarControl'
import { Editor } from '@tiptap/core'
import { Portal } from 'solid-js/web'
@ -16,7 +16,7 @@ import { Icon } from '~/components/_shared/Icon/Icon'
import { Modal } from '~/components/_shared/Modal'
import { useUI } from '~/context/ui'
import { UploadedFile } from '~/types/upload'
import { InsertLinkForm } from '../EditorToolbar/InsertLinkForm'
import { InsertLinkForm } from '../Toolbar/InsertLinkForm'
import styles from './MiniEditor.module.scss'
interface MiniEditorProps {