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:
133
admin/src/components/form/richeditor/LinkPopover.vue
Normal file
133
admin/src/components/form/richeditor/LinkPopover.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot } from 'reka-ui'
|
||||
import { t } from '../../../app/i18n'
|
||||
import Button from '../../ui/Button.vue'
|
||||
import { controlClass } from '../control'
|
||||
import { isAllowedLink } from './html'
|
||||
|
||||
// The richeditor link popover (UI-SPEC RichEditorField, "Link popover"),
|
||||
// anchored on the toolbar's Link button (the default slot). Enter applies,
|
||||
// Esc closes; on close the field decides where focus goes (`closed` tells it
|
||||
// whether a link was applied or removed). A URL outside the client policy
|
||||
// (http, https, mailto, tel or relative) shows richeditor.link_invalid and
|
||||
// is not applied; the server sanitizer is still the gate.
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** The current link's href, or "" when the selection is not in a link. */
|
||||
href: string
|
||||
/** Whether the selection is inside a link: offers "Remove link". */
|
||||
inLink: boolean
|
||||
/** Prefix of the element ids. */
|
||||
idPrefix: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
apply: [url: string]
|
||||
remove: []
|
||||
/** Esc or a click outside asks to close. */
|
||||
'update:open': [open: boolean]
|
||||
/** The popover has closed; `changed` is true after apply or remove. */
|
||||
closed: [changed: boolean]
|
||||
}>()
|
||||
|
||||
const url = ref('')
|
||||
const invalid = ref(false)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
let changed = false
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
url.value = props.href
|
||||
invalid.value = false
|
||||
changed = false
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
},
|
||||
)
|
||||
|
||||
function apply(): void {
|
||||
const value = url.value.trim()
|
||||
if (!isAllowedLink(value)) {
|
||||
invalid.value = true
|
||||
input.value?.focus()
|
||||
return
|
||||
}
|
||||
changed = true
|
||||
emit('apply', value)
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
changed = true
|
||||
emit('remove')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
// The popover sits in a form: Enter applies the link, it never submits.
|
||||
event.preventDefault()
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean): void {
|
||||
emit('update:open', open)
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
// There is no trigger to return to: the field focuses the Link button or
|
||||
// the editor itself.
|
||||
event.preventDefault()
|
||||
emit('closed', changed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot :open="open" @update:open="onOpenChange">
|
||||
<PopoverAnchor as-child>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-richeditor-link
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
class="z-50 flex w-[320px] flex-col gap-2 rounded-inner border border-border bg-surface p-4 text-text shadow-menu"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
>
|
||||
<label :for="`${idPrefix}-link-url`" class="font-semibold">{{ t('backend::lang.richeditor.link_url') }}</label>
|
||||
<input
|
||||
:id="`${idPrefix}-link-url`"
|
||||
ref="input"
|
||||
v-model="url"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:class="controlClass(invalid)"
|
||||
class="h-input"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="invalid ? `${idPrefix}-link-error` : undefined"
|
||||
@input="invalid = false"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<p v-if="invalid" :id="`${idPrefix}-link-error`" class="flex items-center gap-1.5 text-[13px] text-danger">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.richeditor.link_invalid') }}</span>
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button v-if="inLink" variant="ghost" size="sm" data-action="richeditor-unlink" @click="remove">
|
||||
{{ t('backend::lang.richeditor.unlink') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" data-action="richeditor-link-apply" @click="apply">
|
||||
{{ t('backend::lang.richeditor.link_apply') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
Reference in New Issue
Block a user