import type { Editor } from '@tiptap/core'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { m } from '$lib/paraglide/messages.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; /** * Opens the re-edit dropdown for a saved @mention. The host editor supplies * this; the NodeView calls it when the pencil is activated. * * @param getRect anchor for the dropdown — the mention token's on-screen rect * @param displayName the stored display text, used to pre-fill the search input * @param pos the mention node's document position, captured at open time * so the eventual re-link targets exactly this node */ export type RelinkRequest = ( getRect: () => DOMRect | null, displayName: string, pos: number ) => void; type NodeViewArgs = { node: ProseMirrorNode; editor: Editor; getPos: () => number | undefined; }; // Static developer markup — no user data reaches the DOM here, so building the // glyph element-by-element (never innerHTML) keeps the "user text is textContent // only" rule honest across the whole NodeView. function buildPencilIcon(): SVGSVGElement { const svg = document.createElementNS(SVG_NS, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '2'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('class', 'h-4 w-4'); const tip = document.createElementNS(SVG_NS, 'path'); tip.setAttribute('d', 'M12 20h9'); tip.setAttribute('stroke-linecap', 'round'); const body = document.createElementNS(SVG_NS, 'path'); body.setAttribute('d', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z'); body.setAttribute('stroke-linejoin', 'round'); svg.append(tip, body); return svg; } /** * Tiptap NodeView for a person @mention. Renders the `@displayName` token text * (as `textContent` — the name is user-influenced, never `innerHTML`) plus a * `contenteditable="false"` pencil button that opens the re-edit dropdown (#628). * * The pencil sits in a fixed-width slot revealed on whole-token hover and * keyboard focus — an instant opacity swap (respects prefers-reduced-motion), * so following words never shift and the caret model stays stable. */ export function createMentionNodeView(requestRelink: RelinkRequest) { return ({ node, editor, getPos }: NodeViewArgs) => { // Tracks the node's current attrs across update()s so the pencil always // opens with the live displayName (relink only swaps personId today, but // this keeps openRelink honest if displayName ever becomes mutable). let currentNode = node; const dom = document.createElement('span'); dom.className = 'mention-token group/mention'; dom.setAttribute('data-type', 'mention'); dom.setAttribute('data-person-id', node.attrs.personId ?? ''); dom.setAttribute('data-display-name', node.attrs.displayName ?? ''); const text = document.createElement('span'); text.className = 'underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'; text.textContent = `@${node.attrs.displayName}`; dom.appendChild(text); // Fixed-width slot — always present so revealing the pencil never reflows // the following text (NFR no-reflow). Only opacity changes on reveal. const slot = document.createElement('span'); slot.className = 'mention-edit-slot ml-0.5 inline-flex w-4 shrink-0 items-center justify-center align-middle'; const button = document.createElement('button'); button.type = 'button'; button.contentEditable = 'false'; button.tabIndex = 0; button.setAttribute('aria-label', m.person_mention_edit_label()); // Visible glyph stays ~16px; the 44px tap target comes from padding pulled // back with negative margin so the larger hit box does not reflow the line. // While hidden the button must be pointer-events-none — opacity-0 alone // still hit-tests, and the 44px box overhangs the adjacent text, so a click // in the gap between two mentions would otherwise land on this invisible // button and spuriously open the dropdown (AC-8). Pointer events are // re-enabled together with the opacity reveal on hover/focus. button.className = [ 'mention-edit-btn', '-mx-3 -my-2 inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-2', 'pointer-events-none opacity-0 transition-none', 'group-hover/mention:pointer-events-auto group-hover/mention:opacity-100', 'group-focus-within/mention:pointer-events-auto group-focus-within/mention:opacity-100', 'focus:pointer-events-auto focus:opacity-100', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy', 'disabled:cursor-not-allowed' ].join(' '); button.appendChild(buildPencilIcon()); slot.appendChild(button); dom.appendChild(slot); const openRelink = (event: Event) => { event.preventDefault(); event.stopPropagation(); // AC-7: inert when the editor is disabled — independent of the wrapper's // pointer-events-none, on both the pointer and the keyboard path. if (!editor.isEditable) return; const pos = getPos(); if (pos === undefined) return; requestRelink(() => text.getBoundingClientRect(), currentNode.attrs.displayName ?? '', pos); }; // Prevent mousedown from moving the editor selection before the click // fires — keeps the captured pos valid. click + Enter/Space activate. button.addEventListener('mousedown', (event) => event.preventDefault()); button.addEventListener('click', openRelink); button.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') openRelink(event); }); const syncEditable = () => { const editable = editor.isEditable; button.disabled = !editable; if (editable) { button.removeAttribute('aria-disabled'); button.tabIndex = 0; } else { button.setAttribute('aria-disabled', 'true'); button.tabIndex = -1; } }; syncEditable(); // editor.setEditable() emits "update" (not a ProseMirror transaction), so // we listen on "update" to re-sync the pencil's inert state when `disabled` // flips mid-session. "update" also skips selection-only changes, so this // fires far less often than a "transaction" listener would. editor.on('update', syncEditable); return { dom, // The mention is an atom leaf — its DOM is fully owned here, so PM must // ignore our mutations (token textContent / disabled attr) rather than // trying to read them back into the document. ignoreMutation: () => true, // Pencil events are ours; everything else (caret placement on the token) // flows to ProseMirror. stopEvent: (event: Event) => button.contains(event.target as Node), update(updatedNode: ProseMirrorNode) { if (updatedNode.type.name !== 'mention') return false; currentNode = updatedNode; text.textContent = `@${updatedNode.attrs.displayName}`; dom.setAttribute('data-person-id', updatedNode.attrs.personId ?? ''); dom.setAttribute('data-display-name', updatedNode.attrs.displayName ?? ''); return true; }, destroy() { editor.off('update', syncEditable); } }; }; }