feat(17-02): richeditor field on TipTap with the untouched rule
- RichEditorField: Reka toolbar, link popover with the http/https/mailto/tel and relative URL policy, Edit HTML view, lossy note, skeleton while the lazily imported TipTap chunk loads, HTML view fallback when it fails - untouched values are submitted byte-identical; after an edit the value is the editor HTML (empty paragraph -> ""); a value from outside (the save's sanitized answer) reloads the editor - read-only renders through a client display allowlist, never as stored - TipTap 3.31.4 (core, pm, starter-kit) pinned exact after the package check - richeditor.* phrase keys (en, pl), smoke specs, forms docs
This commit is contained in:
424
admin/src/components/form/fields/RichEditorField.vue
Normal file
424
admin/src/components/form/fields/RichEditorField.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch, type Component, type ComponentPublicInstance } from 'vue'
|
||||
import { Bold, Code, Heading3, Info, Italic, Link, List, ListOrdered, Quote, Redo2, Underline, Undo2, Unlink } from '@lucide/vue'
|
||||
import { ToolbarButton, ToolbarRoot, ToolbarSeparator } from 'reka-ui'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { controlClass, type FieldControlProps } from '../control'
|
||||
import { displayHtml } from '../richeditor/html'
|
||||
import { isLossy } from '../richeditor/lossy'
|
||||
import LinkPopover from '../richeditor/LinkPopover.vue'
|
||||
import type { Editor } from '../richeditor/editor'
|
||||
|
||||
// type: richeditor (D-10, UI-SPEC RichEditorField): an HTML WYSIWYG on
|
||||
// TipTap, which loads as its own chunk (a skeleton meanwhile; when the chunk
|
||||
// fails, the HTML view with richeditor.load_failed). Value contract:
|
||||
// - untouched rule: nothing is emitted until the administrator edits, so a
|
||||
// loaded value is saved byte-identical and never re-serialized by TipTap;
|
||||
// - after an edit in the editor the value is its HTML, "" for an empty
|
||||
// paragraph; the HTML view emits the textarea text as typed;
|
||||
// - a new value from outside (a save's answer, the sanitized HTML) replaces
|
||||
// the document and makes the field untouched again.
|
||||
// Read-only (preview) shows the value through displayHtml, never as stored.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
type EditorModule = typeof import('../richeditor/editor')
|
||||
type Status = 'loading' | 'ready' | 'failed'
|
||||
|
||||
const readOnly = computed(() => !!props.field.readOnly)
|
||||
const stored = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const display = computed(() => (readOnly.value ? displayHtml(stored.value) : ''))
|
||||
|
||||
const status = ref<Status>('loading')
|
||||
const source = ref(false)
|
||||
const sourceText = ref('')
|
||||
const lossy = ref(isLossy(stored.value))
|
||||
const mount = ref<HTMLElement | null>(null)
|
||||
const linkButton = ref<ComponentPublicInstance | null>(null)
|
||||
const editor = shallowRef<Editor | null>(null)
|
||||
// Bumped on every editor transaction, so the toolbar state recomputes.
|
||||
const tick = ref(0)
|
||||
const linkOpen = ref(false)
|
||||
const linkHref = ref('')
|
||||
|
||||
let module: EditorModule | null = null
|
||||
let disposed = false
|
||||
// The last value this field emitted: its echo through modelValue is not an
|
||||
// outside change.
|
||||
let lastEmitted: string | null = null
|
||||
// The HTML view's text when it opened: leaving it unchanged re-parses nothing.
|
||||
let sourceOpenedWith = ''
|
||||
|
||||
const labelId = computed(() => `${props.controlId}-label`)
|
||||
const containerClass = computed(() => [
|
||||
controlClass(props.invalid).replace(' px-3.5', ''),
|
||||
'overflow-hidden focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]',
|
||||
])
|
||||
|
||||
function areaAttributes(): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
class: 'summer-markdown min-h-[160px] max-h-[480px] overflow-y-auto px-3.5 py-2.5 outline-none',
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-labelledby': labelId.value,
|
||||
'data-richeditor-area': '',
|
||||
}
|
||||
if (!source.value) {
|
||||
out.id = props.controlId
|
||||
}
|
||||
if (props.describedBy) {
|
||||
out['aria-describedby'] = props.describedBy
|
||||
}
|
||||
if (props.invalid) {
|
||||
out['aria-invalid'] = 'true'
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function setValue(value: string): void {
|
||||
lastEmitted = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function onEditorChange(): void {
|
||||
if (editor.value && module) {
|
||||
setValue(module.editorValue(editor.value))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
module = await import('../richeditor/editor')
|
||||
} catch {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
status.value = 'failed'
|
||||
sourceText.value = stored.value
|
||||
sourceOpenedWith = stored.value
|
||||
source.value = true
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
status.value = 'ready'
|
||||
await nextTick()
|
||||
if (disposed || !mount.value) {
|
||||
return
|
||||
}
|
||||
editor.value = module.createRichEditor({
|
||||
element: mount.value,
|
||||
content: stored.value,
|
||||
attributes: areaAttributes(),
|
||||
onChange: onEditorChange,
|
||||
onTransaction: () => {
|
||||
tick.value++
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
editor.value?.destroy()
|
||||
editor.value = null
|
||||
})
|
||||
|
||||
// A value from outside (load, the save's answer): show it, untouched again.
|
||||
watch(stored, (value) => {
|
||||
if (value === lastEmitted) {
|
||||
return
|
||||
}
|
||||
lastEmitted = null
|
||||
lossy.value = isLossy(value)
|
||||
if (source.value) {
|
||||
sourceText.value = value
|
||||
sourceOpenedWith = value
|
||||
}
|
||||
if (editor.value && module) {
|
||||
module.loadContent(editor.value, value)
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.invalid, () => props.describedBy, source], () => {
|
||||
editor.value?.setOptions({ editorProps: { attributes: areaAttributes() } })
|
||||
})
|
||||
|
||||
interface Tool {
|
||||
key: string
|
||||
icon: Component
|
||||
/** Full phrase key: the SPA key check reads literals only. */
|
||||
label: string
|
||||
run: (editor: Editor) => void
|
||||
active?: (editor: Editor) => boolean
|
||||
enabled?: (editor: Editor) => boolean
|
||||
}
|
||||
|
||||
const groups: Tool[][] = [
|
||||
[
|
||||
{ key: 'bold', icon: Bold, label: 'backend::lang.richeditor.bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
||||
{ key: 'italic', icon: Italic, label: 'backend::lang.richeditor.italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
|
||||
{
|
||||
key: 'underline',
|
||||
icon: Underline,
|
||||
label: 'backend::lang.richeditor.underline',
|
||||
run: (e) => e.chain().focus().toggleUnderline().run(),
|
||||
active: (e) => e.isActive('underline'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'heading',
|
||||
icon: Heading3,
|
||||
label: 'backend::lang.richeditor.heading',
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
active: (e) => e.isActive('heading', { level: 3 }),
|
||||
},
|
||||
{
|
||||
key: 'bullet_list',
|
||||
icon: List,
|
||||
label: 'backend::lang.richeditor.bullet_list',
|
||||
run: (e) => e.chain().focus().toggleBulletList().run(),
|
||||
active: (e) => e.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
key: 'ordered_list',
|
||||
icon: ListOrdered,
|
||||
label: 'backend::lang.richeditor.ordered_list',
|
||||
run: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||
active: (e) => e.isActive('orderedList'),
|
||||
},
|
||||
{ key: 'quote', icon: Quote, label: 'backend::lang.richeditor.quote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
|
||||
],
|
||||
]
|
||||
|
||||
const history: Tool[] = [
|
||||
{ key: 'undo', icon: Undo2, label: 'backend::lang.richeditor.undo', run: (e) => e.chain().focus().undo().run(), enabled: (e) => e.can().undo() },
|
||||
{ key: 'redo', icon: Redo2, label: 'backend::lang.richeditor.redo', run: (e) => e.chain().focus().redo().run(), enabled: (e) => e.can().redo() },
|
||||
]
|
||||
|
||||
/** The toolbar's view of the editor; recomputed after every transaction. */
|
||||
const view = computed(() => {
|
||||
void tick.value
|
||||
const current = editor.value
|
||||
if (!current || source.value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
editor: current,
|
||||
inLink: current.isActive('link'),
|
||||
hasSelection: !current.state.selection.empty,
|
||||
}
|
||||
})
|
||||
|
||||
function pressed(tool: Tool): 'true' | 'false' | undefined {
|
||||
if (!tool.active) {
|
||||
return undefined
|
||||
}
|
||||
return view.value && tool.active(view.value.editor) ? 'true' : 'false'
|
||||
}
|
||||
|
||||
function disabled(tool: Tool): boolean {
|
||||
const state = view.value
|
||||
return !state || (tool.enabled !== undefined && !tool.enabled(state.editor))
|
||||
}
|
||||
|
||||
function run(tool: Tool): void {
|
||||
if (view.value && !disabled(tool)) {
|
||||
tool.run(view.value.editor)
|
||||
}
|
||||
}
|
||||
|
||||
const linkEnabled = computed(() => !!view.value && (view.value.hasSelection || view.value.inLink))
|
||||
|
||||
function openLink(): void {
|
||||
if (!view.value || !linkEnabled.value) {
|
||||
return
|
||||
}
|
||||
const href = view.value.editor.getAttributes('link').href
|
||||
linkHref.value = typeof href === 'string' ? href : ''
|
||||
linkOpen.value = true
|
||||
}
|
||||
|
||||
function applyLink(url: string): void {
|
||||
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
linkOpen.value = false
|
||||
}
|
||||
|
||||
function removeLink(): void {
|
||||
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
linkOpen.value = false
|
||||
}
|
||||
|
||||
function onLinkClosed(changed: boolean): void {
|
||||
linkOpen.value = false
|
||||
if (changed) {
|
||||
editor.value?.commands.focus()
|
||||
return
|
||||
}
|
||||
const element = linkButton.value?.$el as HTMLElement | undefined
|
||||
element?.focus()
|
||||
}
|
||||
|
||||
function toggleSource(): void {
|
||||
if (!source.value) {
|
||||
linkOpen.value = false
|
||||
sourceText.value = stored.value
|
||||
sourceOpenedWith = stored.value
|
||||
source.value = true
|
||||
return
|
||||
}
|
||||
// Re-parse only when the text changed; the value stays that text until
|
||||
// the next edit in the editor.
|
||||
if (editor.value && module && sourceText.value !== sourceOpenedWith) {
|
||||
module.loadContent(editor.value, sourceText.value)
|
||||
}
|
||||
source.value = false
|
||||
}
|
||||
|
||||
function onSourceInput(event: Event): void {
|
||||
sourceText.value = (event.target as HTMLTextAreaElement).value
|
||||
setValue(sourceText.value)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'k' && view.value) {
|
||||
event.preventDefault()
|
||||
openLink()
|
||||
}
|
||||
}
|
||||
|
||||
const toolClass =
|
||||
'inline-flex size-8 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text disabled:cursor-not-allowed disabled:opacity-40'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="readOnly"
|
||||
:id="controlId"
|
||||
data-richeditor-readonly
|
||||
class="rounded-control border border-border bg-subtle px-3.5 py-2.5"
|
||||
>
|
||||
<div v-if="display !== ''" class="summer-markdown" v-html="display" />
|
||||
<span v-else class="text-muted">{{ t('backend::lang.list.empty_value') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'loading'" data-richeditor-loading aria-busy="true" class="h-[200px] rounded-control bg-skel" />
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<p v-if="status === 'failed'" data-richeditor-load-failed class="text-[13px] text-muted">
|
||||
{{ t('backend::lang.richeditor.load_failed') }}
|
||||
</p>
|
||||
<div :data-richeditor="field.name" :class="containerClass" @keydown="onKeydown">
|
||||
<ToolbarRoot
|
||||
v-if="status === 'ready'"
|
||||
:aria-label="t('backend::lang.richeditor.toolbar')"
|
||||
:aria-controls="controlId"
|
||||
class="flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1"
|
||||
>
|
||||
<template v-for="(group, index) in groups" :key="index">
|
||||
<ToolbarSeparator v-if="index > 0" class="mx-1 h-5 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
v-for="tool in group"
|
||||
:key="tool.key"
|
||||
:data-tool="tool.key"
|
||||
:aria-label="t(tool.label)"
|
||||
:title="t(tool.label)"
|
||||
:aria-pressed="pressed(tool)"
|
||||
:disabled="disabled(tool)"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="run(tool)"
|
||||
>
|
||||
<component :is="tool.icon" :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</template>
|
||||
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
|
||||
<LinkPopover
|
||||
:open="linkOpen"
|
||||
:href="linkHref"
|
||||
:in-link="!!view?.inLink"
|
||||
:id-prefix="controlId"
|
||||
@update:open="(open) => { if (!open) linkOpen = false }"
|
||||
@apply="applyLink"
|
||||
@remove="removeLink"
|
||||
@closed="onLinkClosed"
|
||||
>
|
||||
<ToolbarButton
|
||||
ref="linkButton"
|
||||
data-tool="link"
|
||||
:aria-label="t('backend::lang.richeditor.link')"
|
||||
:title="t('backend::lang.richeditor.link')"
|
||||
:aria-pressed="view?.inLink ? 'true' : 'false'"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="linkOpen ? 'true' : 'false'"
|
||||
:disabled="!linkEnabled"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="openLink"
|
||||
>
|
||||
<Link :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</LinkPopover>
|
||||
<ToolbarButton
|
||||
data-tool="unlink"
|
||||
:aria-label="t('backend::lang.richeditor.unlink')"
|
||||
:title="t('backend::lang.richeditor.unlink')"
|
||||
:disabled="!view?.inLink"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="removeLink"
|
||||
>
|
||||
<Unlink :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
v-for="tool in history"
|
||||
:key="tool.key"
|
||||
:data-tool="tool.key"
|
||||
:aria-label="t(tool.label)"
|
||||
:title="t(tool.label)"
|
||||
:disabled="disabled(tool)"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="run(tool)"
|
||||
>
|
||||
<component :is="tool.icon" :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
data-tool="source"
|
||||
:aria-pressed="source ? 'true' : 'false'"
|
||||
class="ml-auto inline-flex h-8 items-center gap-1.5 rounded-pager px-2 text-[13px] text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text"
|
||||
@mousedown.prevent
|
||||
@click="toggleSource"
|
||||
>
|
||||
<Code :size="16" aria-hidden="true" />
|
||||
{{ t('backend::lang.richeditor.source') }}
|
||||
</ToolbarButton>
|
||||
</ToolbarRoot>
|
||||
<p
|
||||
v-if="lossy && status === 'ready'"
|
||||
role="note"
|
||||
data-richeditor-lossy
|
||||
class="flex items-start gap-2 border-b border-border bg-surface px-3.5 py-2.5 text-[13px] text-muted"
|
||||
>
|
||||
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.richeditor.lossy_note') }}</span>
|
||||
</p>
|
||||
<div v-show="!source" ref="mount" />
|
||||
<textarea
|
||||
v-if="source"
|
||||
:id="controlId"
|
||||
data-richeditor-source
|
||||
:name="field.name"
|
||||
:value="sourceText"
|
||||
spellcheck="false"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="block min-h-[160px] w-full resize-y bg-surface px-3.5 py-2.5 font-mono text-[13px] text-text outline-none"
|
||||
@input="onSourceInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user