Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 50s
CI / Semgrep Security Scan (pull_request) Successful in 29s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Addresses the clean-agent review of PR #717: - C1: the hidden pencil was opacity-0 only, which still hit-tests; its 44px box overhangs adjacent text, so a click in the gap between two mentions could land on the invisible button and spuriously open the dropdown (AC-8 hole). Add pointer-events-none while hidden, re-enabled with the opacity reveal on hover/focus. - C2/N1: editor.setEditable() emits "update", not a ProseMirror transaction, so the NodeView's 'transaction' listener missed a mid-session disable flip (stale aria-disabled/tabindex; the comment was wrong). Listen on 'update' instead — which also skips selection-only changes, so it fires far less often. - N2: track the node across update() so the pencil opens with the live displayName (hardening; relink only swaps personId today). Tests: structural guard that the hidden pencil is pointer-events-none + reveals, and a mid-session disable-flip test (fixture gains an onReady setDisabled hook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
7.0 KiB
TypeScript
168 lines
7.0 KiB
TypeScript
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);
|
|
}
|
|
};
|
|
};
|
|
}
|