- 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
134 lines
4.1 KiB
Vue
134 lines
4.1 KiB
Vue
<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>
|