Files
edentext/src/lib/editor/contextMenuItems.ts
T
Steffen Becker 78e35cef2c feat: a reference finds the text it points at, and follows it
Bookmarks are a mark, not a point: both formats store a range, and only a
range lets a REF recompute its text. A cross-reference caches its display
like a Word field and resolves live on each pagination settle — the
bookmark's text, or the page it renders on — keeping the cached value when
the target is gone. Ctrl+click jumps to it, internal #name links too.

ODF splices its sentinels into the run text so cells and custom paragraphs
need no separate emitter; DOCX needs no post-pass at all.
2026-08-09 13:56:22 +00:00

120 lines
4.2 KiB
TypeScript

import type { Editor } from '@tiptap/core';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
import { shortcutHint } from './shortcuts';
import { OPEN_LINK_DIALOG_EVENT } from './extensions/link';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames } from './extensions/bookmark';
import { OPEN_CROSS_REF_DIALOG_EVENT } from './extensions/crossReference';
// The right-click menu's contents, the usual text menu mapped onto this editor. Pure
// data + closures — ContextMenu.svelte only renders it.
export type MenuEntry =
| { kind: 'sep' }
| { kind: 'item'; label: string; hint?: string; disabled?: boolean; strong?: boolean; run: () => void };
export interface SpellSection {
suggestions: string[];
onReplace: (word: string) => void;
onAdd: () => void;
onIgnore: () => void;
}
// Cut/copy go through execCommand so ProseMirror's own copy handler builds the
// clipboard payload (HTML + its slice metadata) — formatting survives.
// ponytail: deprecated but the only route to that payload; revisit if a browser drops it.
function clipboardCommand(editor: Editor, command: 'cut' | 'copy') {
editor.view.focus();
document.execCommand(command);
}
async function readClipboard(editor: Editor, plainOnly: boolean) {
const view = editor.view;
view.focus();
try {
if (plainOnly) {
view.pasteText(await navigator.clipboard.readText());
return;
}
for (const item of await navigator.clipboard.read()) {
if (item.types.includes('text/html')) {
view.pasteHTML(await (await item.getType('text/html')).text());
return;
}
if (item.types.includes('text/plain')) {
view.pasteText(await (await item.getType('text/plain')).text());
return;
}
}
} catch {
// Firefox/Safari gate clipboard reads behind their own paste affordance.
alert(t().contextMenu.pasteBlocked);
}
}
export function buildContextMenu(editor: Editor, opts: { spell?: SpellSection } = {}): MenuEntry[] {
const m = t().contextMenu;
const entries: MenuEntry[] = [];
if (opts.spell) {
const { suggestions, onReplace, onAdd, onIgnore } = opts.spell;
if (suggestions.length) {
for (const s of suggestions) entries.push({ kind: 'item', label: s, strong: true, run: () => onReplace(s) });
} else {
entries.push({ kind: 'item', label: t().spell.noSuggestions, disabled: true, run: () => {} });
}
entries.push({ kind: 'sep' });
entries.push({ kind: 'item', label: t().spell.ignoreAll, run: onIgnore });
entries.push({ kind: 'item', label: t().spell.addToDictionary, run: onAdd });
entries.push({ kind: 'sep' });
}
const hasSelection = !editor.state.selection.empty;
entries.push(
{ kind: 'item', label: m.cut, hint: withShortcut('Ctrl+X'), disabled: !hasSelection, run: () => clipboardCommand(editor, 'cut') },
{ kind: 'item', label: m.copy, hint: withShortcut('Ctrl+C'), disabled: !hasSelection, run: () => clipboardCommand(editor, 'copy') },
{ kind: 'item', label: m.paste, hint: withShortcut('Ctrl+V'), run: () => void readClipboard(editor, false) },
{ kind: 'item', label: m.pasteWithoutFormatting, hint: withShortcut('Ctrl+Shift+V'), run: () => void readClipboard(editor, true) },
{ kind: 'sep' },
);
const onLink = editor.isActive('link');
entries.push({
kind: 'item',
label: onLink ? m.editLink : m.insertLink,
hint: shortcutHint('link'),
run: () => window.dispatchEvent(new CustomEvent(OPEN_LINK_DIALOG_EVENT)),
});
if (onLink) {
entries.push({
kind: 'item',
label: m.removeLink,
run: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(),
});
}
entries.push({
kind: 'item',
label: m.insertBookmark,
disabled: !hasSelection,
run: () => window.dispatchEvent(new CustomEvent(OPEN_BOOKMARK_DIALOG_EVENT)),
});
entries.push({
kind: 'item',
label: m.insertCrossRef,
disabled: !bookmarkNames(editor.state.doc).length,
run: () => window.dispatchEvent(new CustomEvent(OPEN_CROSS_REF_DIALOG_EVENT)),
});
entries.push(
{ kind: 'sep' },
{
kind: 'item',
label: m.clearFormatting,
hint: shortcutHint('clearFormatting'),
run: () => editor.commands.clearDirectFormatting(),
},
);
return entries;
}