Files
summercms/admin/src/components/form/richeditor/LinkPopover.vue
Jakub Zych cb2987c146 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
2026-10-12 15:56:59 +02:00

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>