feat: right-click context-menu

This commit is contained in:
Steffen Becker committed 2026-08-02 23:06:18 +02:00
1 parent 9483d598fc
commit 8cdd487172
9 files changed
+434 -158

No files matched your search

+2 -1
View File
@@ -70,7 +70,8 @@
- Word/LibreOffice keyboard shortcuts throughout: Ctrl+L/E/R/J alignment, Ctrl+Alt+1–5 headings and Ctrl+Shift+N default style, Ctrl+1/2/5 line spacing, Ctrl+Shift+P / Ctrl+Shift+B super/subscript, Ctrl+Shift+. / Ctrl+Shift+, grow/shrink font, Ctrl+Space clear formatting, Ctrl+Shift+Space non-breaking space, Ctrl+Shift+- soft hyphen, Alt+Shift+D / Alt+Shift+T date & time field, Ctrl+O open, F3 / Shift+F3 find next/previous, Ctrl+F10 formatting marks — alongside the existing Ctrl+B/I/U, Ctrl+M, Ctrl+K, Ctrl+Enter, Ctrl+F/H, Ctrl+S/P and Tab/Shift+Tab. All bindings live in one table, so tooltips stay in sync and they can be remapped later
- Undo / Redo with a labelled history dropdown
- Search & Replace (Ctrl+F / Ctrl+H): live match highlighting, match count, next / previous, match-case and whole-word options, replace current / replace all
- Spell check in English and German with squiggles and a suggestions context menu (add / ignore word); selectable document language. Powered by Hunspell (WASM), so German compound words (Fußgänger, Krankenversicherung …) are recognised; dictionaries are lazy-loaded per language on demand
- Right-click context menu for text (Word-style): cut / copy / paste / paste without formatting, link insert-edit-remove, clear formatting — with the spelling suggestions merged in on top. Shift+right-click keeps the browser's own menu; images, text boxes and header/footer stay with their existing UI
- Spell check in English and German with squiggles and suggestions in the context menu (add / ignore word); selectable document language. Powered by Hunspell (WASM), so German compound words (Fußgänger, Krankenversicherung …) are recognised; dictionaries are lazy-loaded per language on demand
- Word / character count statistics (whole document and selection)
**UI & theming**
+15 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick as domUpdated } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { Editor } from '@tiptap/core';
import { EditorState } from '@tiptap/pm/state';
@@ -36,6 +36,7 @@
import { t } from './lib/i18n/i18n.svelte';
import { withShortcut } from './lib/i18n/shortcut';
import { DEFAULT_SHORTCUTS, matchesEvent, shortcutHint } from './lib/editor/shortcuts';
import { OPEN_LINK_DIALOG_EVENT } from './lib/editor/extensions/link';
import { localizeImportMessage } from './lib/i18n/importMessages';
import { unavailableFonts } from './lib/utils/fontDetect';
import { registerEmbeddedFonts, clearEmbeddedFonts } from './lib/fonts/embeddedFonts';
@@ -222,6 +223,19 @@
saveToolbarExpanded(toolbarExpanded);
}
// The link dialog lives in ToolbarExpanded, which isn't mounted while the secondary
// toolbar is collapsed — Ctrl+K and the context menu's link entry would go nowhere.
// Expand it (not persisted) and re-fire once the dialog's own listener exists.
$effect(() => {
const open = () => {
if (toolbarExpanded) return;
toolbarExpanded = true;
domUpdated().then(() => window.dispatchEvent(new CustomEvent(OPEN_LINK_DIALOG_EVENT)));
};
window.addEventListener(OPEN_LINK_DIALOG_EVENT, open);
return () => window.removeEventListener(OPEN_LINK_DIALOG_EVENT, open);
});
// Horizontal toolbar scrolling: when too narrow for all buttons, the toolbar stack
// is translated left via a custom scrollbar (a native one auto-hides on macOS), so
// the document stays centered — the page itself never scrolls horizontally.
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import type { MenuEntry } from '../editor/contextMenuItems';
let {
top,
left,
items,
onClose,
}: {
top: number;
left: number;
items: MenuEntry[];
onClose: () => void;
} = $props();
let menuEl: HTMLDivElement | undefined = $state();
// Flips set after measuring: the menu opens up/left when it would leave the viewport.
let flipX = $state(false);
let flipY = $state(false);
function activate(entry: Extract<MenuEntry, { kind: 'item' }>) {
if (entry.disabled) return;
onClose();
entry.run();
}
// Dismiss on outside click or Escape (standard context-menu behaviour).
$effect(() => {
const onPointerDown = (e: PointerEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey, true);
};
});
// Keep the panel inside the window; a right-click near an edge otherwise opens a menu
// that runs off-screen. Measured from the layout box (offset*), not
// getBoundingClientRect, so the flip transform below can't feed back into the next
// measurement.
$effect(() => {
void [top, left, items];
if (!menuEl) return;
const parent = menuEl.offsetParent as HTMLElement | null;
const pr = parent?.getBoundingClientRect();
const x = (pr?.left ?? 0) + menuEl.offsetLeft - (parent?.scrollLeft ?? 0);
const y = (pr?.top ?? 0) + menuEl.offsetTop - (parent?.scrollTop ?? 0);
flipX = x + menuEl.offsetWidth > window.innerWidth - 4;
flipY = y + menuEl.offsetHeight > window.innerHeight - 4;
menuEl.focus({ preventScroll: true });
});
// Roving focus with the arrow keys, as in a native menu.
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const buttons = Array.from(menuEl?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') ?? []);
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
const next = e.key === 'ArrowDown' ? i + 1 : i - 1;
buttons[(next + buttons.length) % buttons.length]?.focus();
}
</script>
<div
bind:this={menuEl}
class="cm-menu"
class:flip-x={flipX}
class:flip-y={flipY}
style="top: {top}px; left: {left}px;"
role="menu"
tabindex="-1"
onkeydown={onKeydown}
>
{#each items as entry}
{#if entry.kind === 'sep'}
<span class="cm-sep"></span>
{:else}
<button
class="cm-item"
class:strong={entry.strong}
role="menuitem"
disabled={entry.disabled}
onclick={() => activate(entry)}
>
<span class="cm-label">{entry.label}</span>
{#if entry.hint}<span class="cm-hint">{entry.hint}</span>{/if}
</button>
{/if}
{/each}
</div>
<style>
/* Sibling of the zoomed .paper, so the menu renders constant-size regardless of zoom. */
.cm-menu {
position: absolute;
z-index: 200;
min-width: 13rem;
padding: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
font-family: var(--font-sans);
font-size: 0.8125rem;
}
.cm-menu.flip-x { transform: translateX(-100%); }
.cm-menu.flip-y { transform: translateY(-100%); }
.cm-menu.flip-x.flip-y { transform: translate(-100%, -100%); }
.cm-item {
display: flex;
align-items: center;
gap: 1.5rem;
width: 100%;
padding: 4px 10px;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
text-align: left;
white-space: nowrap;
}
.cm-item:hover:not(:disabled),
.cm-item:focus-visible {
background: var(--color-btn-hover);
outline: none;
}
/* Spelling suggestions, as in the old spell menu. */
.cm-item.strong { font-weight: 600; }
.cm-item:disabled {
color: var(--color-text-muted, #888);
cursor: default;
}
.cm-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-hint {
color: var(--color-text-muted, #888);
font-size: 0.75rem;
}
.cm-sep {
display: block;
height: 1px;
margin: 4px 2px;
background: var(--color-border);
}
</style>
+52 -42
View File
@@ -3,8 +3,8 @@
import { Editor } from '@tiptap/core';
import { Slice, Fragment } from 'prosemirror-model';
import type { Node as PmNode, MarkType } from 'prosemirror-model';
import type { EditorView } from '@tiptap/pm/view';
import { extensions } from '../editor/extensions';
import { buildContextMenu, type MenuEntry, type SpellSection } from '../editor/contextMenuItems';
import { spellErrorAt } from '../editor/extensions/spellCheck';
import { spellController } from '../spell/controller';
import TableToolbar from './TableToolbar.svelte';
@@ -13,8 +13,8 @@
import TextBoxToolbar from './TextBoxToolbar.svelte';
import type { WrapMode } from '../editor/extensions/image';
import { findTextBox, type ShapeKind } from '../editor/extensions/textBox';
import { NodeSelection } from '@tiptap/pm/state';
import SpellContextMenu from './SpellContextMenu.svelte';
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
import ContextMenu from './ContextMenu.svelte';
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
@@ -189,56 +189,70 @@
return (Number.isFinite(ph) ? ph : 1123) + 20;
}
// --- Spelling suggestion menu (right-click on a red-squiggled word) ---
let spellMenu = $state<{
visible: boolean; top: number; left: number;
from: number; to: number; word: string; suggestions: string[];
}>({ visible: false, top: 0, left: 0, from: 0, to: 0, word: '', suggestions: [] });
// --- Right-click context menu (Word's text menu, spelling suggestions merged in) ---
let ctxMenu = $state<{ top: number; left: number; items: MenuEntry[] } | null>(null);
// The misspelled range the open menu's suggestions belong to.
let spellTarget: { from: number; to: number; word: string } | null = null;
function openSpellMenu(view: EditorView, event: MouseEvent): boolean {
if (!spellController.isEnabled() || !editorContainer) return false;
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!coords) return false;
const range = spellErrorAt(view.state, coords.pos);
if (!range) return false; // not on a misspelling → let the browser menu show
function openContextMenu(event: MouseEvent) {
const ed = editor;
// Shift+right-click yields to the browser menu, whose Paste needs no clipboard
// permission (Firefox does this for page handlers by itself). Header/footer keeps
// the browser menu too — the schema has none of the entries below.
if (!ed || event.shiftKey || hfActive || !editorContainer) return;
const target = event.target as HTMLElement | null;
if (!target || !ed.view.dom.contains(target)) return;
if (target.closest('.image-node, .textbox-node')) return; // own floating toolbars
event.preventDefault();
const word = view.state.doc.textBetween(range.from, range.to);
const view = ed.view;
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
const sel = view.state.selection;
// Word keeps a selection the click lands inside, otherwise it moves the caret.
if (coords && (sel.empty || coords.pos < sel.from || coords.pos > sel.to)) {
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(coords.pos))));
}
spellTarget = null;
let spell: SpellSection | undefined;
const range = coords && spellController.isEnabled() ? spellErrorAt(view.state, coords.pos) : null;
if (range) {
const word = view.state.doc.textBetween(range.from, range.to);
spellTarget = { ...range, word };
spell = {
suggestions: spellController.suggest(word).slice(0, 6),
onReplace: replaceSpellWord,
onAdd: addSpellWord,
onIgnore: ignoreSpellWord,
};
}
const cRect = editorContainer.getBoundingClientRect();
spellMenu = {
visible: true,
ctxMenu = {
top: event.clientY - cRect.top + editorContainer.scrollTop,
left: event.clientX - cRect.left + editorContainer.scrollLeft,
from: range.from, to: range.to, word,
suggestions: spellController.suggest(word).slice(0, 6),
items: buildContextMenu(ed, { spell }),
};
return true;
}
function closeSpellMenu() {
if (spellMenu.visible) spellMenu = { ...spellMenu, visible: false };
}
// Replace the misspelled range, preserving the word's marks (font/bold/etc.).
function replaceSpellWord(replacement: string) {
const ed = editor;
if (!ed || !spellMenu.visible) return;
const { from, to } = spellMenu;
if (!ed || !spellTarget) return;
const { from, to } = spellTarget;
const { state } = ed.view;
const marks = state.doc.resolve(from).marksAcross(state.doc.resolve(to)) ?? state.doc.resolve(from).marks();
ed.view.dispatch(state.tr.replaceWith(from, to, state.schema.text(replacement, marks)).scrollIntoView());
ed.view.focus();
closeSpellMenu();
}
function addSpellWord() {
if (spellMenu.visible) spellController.addWord(spellMenu.word);
closeSpellMenu();
if (spellTarget) spellController.addWord(spellTarget.word);
editor?.view.focus();
}
function ignoreSpellWord() {
if (spellMenu.visible) spellController.ignoreWord(spellMenu.word);
closeSpellMenu();
if (spellTarget) spellController.ignoreWord(spellTarget.word);
editor?.view.focus();
}
@@ -667,7 +681,6 @@
return true;
},
handleDOMEvents: {
contextmenu: (view, event) => openSpellMenu(view, event),
drop: (view, event) => {
const e = event as DragEvent;
const files = imageFilesFrom(e.dataTransfer);
@@ -760,7 +773,7 @@
});
</script>
<div class="editor" bind:this={editorContainer} onwheel={onWheel}>
<div class="editor" bind:this={editorContainer} onwheel={onWheel} oncontextmenu={openContextMenu} role="none">
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
@@ -841,15 +854,12 @@
strokeWidthPt={textBoxUi.strokeWidthPt}
/>
{/if}
{#if spellMenu.visible}
<SpellContextMenu
top={spellMenu.top}
left={spellMenu.left}
suggestions={spellMenu.suggestions}
onReplace={replaceSpellWord}
onAdd={addSpellWord}
onIgnore={ignoreSpellWord}
onClose={closeSpellMenu}
{#if ctxMenu}
<ContextMenu
top={ctxMenu.top}
left={ctxMenu.left}
items={ctxMenu.items}
onClose={() => (ctxMenu = null)}
/>
{/if}
{#if linkTip}
-114
View File
@@ -1,114 +0,0 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
let {
top,
left,
suggestions,
onReplace,
onAdd,
onIgnore,
onClose,
}: {
top: number;
left: number;
suggestions: string[];
onReplace: (word: string) => void;
onAdd: () => void;
onIgnore: () => void;
onClose: () => void;
} = $props();
let menuEl: HTMLDivElement | undefined = $state();
// Dismiss on outside click or Escape (standard context-menu behaviour).
$effect(() => {
const onPointerDown = (e: PointerEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey, true);
};
});
</script>
<div
bind:this={menuEl}
class="spell-menu"
style="top: {top}px; left: {left}px;"
role="menu"
tabindex="-1"
aria-label={t().spell.suggestions}
>
<div class="menu-scroll">
{#if suggestions.length}
{#each suggestions as s}
<button class="sm-item sm-suggest" role="menuitem" onclick={() => onReplace(s)}>{s}</button>
{/each}
{:else}
<span class="sm-empty">{t().spell.noSuggestions}</span>
{/if}
<span class="sm-sep"></span>
<button class="sm-item" role="menuitem" onclick={onAdd}>{t().spell.addToDictionary}</button>
<button class="sm-item" role="menuitem" onclick={onIgnore}>{t().spell.ignoreAll}</button>
</div>
</div>
<style>
.spell-menu {
position: absolute;
z-index: 200;
min-width: 11rem;
max-height: 18rem;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
font-family: var(--font-sans);
font-size: 0.8125rem;
/* Sibling of the zoomed .paper, so it renders constant-size regardless of zoom. */
}
.sm-item {
display: block;
width: 100%;
text-align: left;
padding: 4px 10px;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
transition: background 0.12s;
}
.sm-item:hover {
background: var(--color-btn-hover);
}
.sm-suggest {
font-weight: 600;
}
.sm-empty {
padding: 4px 10px;
color: var(--color-text-muted, #888);
font-style: italic;
}
.sm-sep {
height: 1px;
margin: 4px 2px;
background: var(--color-border);
}
</style>
+104
View File
@@ -0,0 +1,104 @@
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';
// The right-click menu's contents, Word's 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: 'sep' },
{
kind: 'item',
label: m.clearFormatting,
hint: shortcutHint('clearFormatting'),
run: () => editor.commands.clearDirectFormatting(),
},
);
return entries;
}
+11
View File
@@ -275,6 +275,17 @@ const de: Messages = {
addToDictionary: 'Zum Wörterbuch hinzufügen',
ignoreAll: 'Alle ignorieren',
},
contextMenu: {
cut: 'Ausschneiden',
copy: 'Kopieren',
paste: 'Einfügen',
pasteWithoutFormatting: 'Ohne Formatierung einfügen',
pasteBlocked: 'Dieser Browser erlaubt das Einfügen nur über die Tastatur. Drücke Strg+V oder halte beim Rechtsklick die Umschalttaste, um das Browser-Menü zu nutzen.',
insertLink: 'Link…',
editLink: 'Link bearbeiten…',
removeLink: 'Link entfernen',
clearFormatting: 'Direkte Formatierung löschen',
},
about: {
label: 'Über EdenText',
tagline: 'Ein privater Editor für OpenDocument-Dateien — läuft vollständig im Browser.',
+11
View File
@@ -279,6 +279,17 @@ const en = {
addToDictionary: 'Add to Dictionary',
ignoreAll: 'Ignore All',
},
contextMenu: {
cut: 'Cut',
copy: 'Copy',
paste: 'Paste',
pasteWithoutFormatting: 'Paste without formatting',
pasteBlocked: 'This browser only allows pasting from the keyboard. Press Ctrl+V, or hold Shift while right-clicking to use the browser menu.',
insertLink: 'Link…',
editLink: 'Edit Link…',
removeLink: 'Remove Link',
clearFormatting: 'Clear Formatting',
},
about: {
label: 'About EdenText',
tagline: 'A private editor for OpenDocument files — runs entirely in your browser.',
+76
View File
@@ -0,0 +1,76 @@
// The right-click menu's contents (Word's text menu). Drives a real body editor so the
// entries are built against a live selection, exactly as Editor.svelte does.
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import { extensions } from '../../src/lib/editor/extensions';
import { buildContextMenu, type MenuEntry } from '../../src/lib/editor/contextMenuItems';
type N = any;
function makeEditor(content: N) {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({ element: el, extensions, content });
}
const para = (text: string, marks?: N[]): N => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text, ...(marks ? { marks } : {}) }] }],
});
const labels = (entries: MenuEntry[]) =>
entries.filter((e): e is Extract<MenuEntry, { kind: 'item' }> => e.kind === 'item').map((e) => e.label);
const item = (entries: MenuEntry[], label: string) =>
entries.find((e): e is Extract<MenuEntry, { kind: 'item' }> => e.kind === 'item' && e.label === label);
describe('context menu items', () => {
it('disables cut/copy without a selection and enables them with one', () => {
const editor = makeEditor(para('Hello world'));
editor.commands.setTextSelection(3);
expect(item(buildContextMenu(editor), 'Cut')!.disabled).toBe(true);
expect(item(buildContextMenu(editor), 'Copy')!.disabled).toBe(true);
editor.commands.setTextSelection({ from: 1, to: 6 });
expect(item(buildContextMenu(editor), 'Cut')!.disabled).toBe(false);
expect(item(buildContextMenu(editor), 'Copy')!.disabled).toBe(false);
editor.destroy();
});
it('shows spelling suggestions in the first section, above the clipboard', () => {
const editor = makeEditor(para('Helo'));
const plain = buildContextMenu(editor);
expect(labels(plain)).not.toContain('Hello');
const withSpell = buildContextMenu(editor, {
spell: { suggestions: ['Hello', 'Halo'], onReplace: () => {}, onAdd: () => {}, onIgnore: () => {} },
});
const names = labels(withSpell);
expect(names.slice(0, 2)).toEqual(['Hello', 'Halo']);
expect(names.indexOf('Ignore All')).toBeLessThan(names.indexOf('Cut'));
editor.destroy();
});
it('offers edit/remove only when the cursor sits in a link', () => {
const editor = makeEditor(para('Hello'));
expect(labels(buildContextMenu(editor))).toContain('Link…');
expect(labels(buildContextMenu(editor))).not.toContain('Remove Link');
const linked = makeEditor(para('Hello', [{ type: 'link', attrs: { href: 'https://example.com' } }]));
linked.commands.setTextSelection(3);
const names = labels(buildContextMenu(linked));
expect(names).toContain('Edit Link…');
expect(names).toContain('Remove Link');
editor.destroy();
linked.destroy();
});
it('clears direct formatting through its entry', () => {
const editor = makeEditor(para('Hello'));
editor.commands.setTextSelection({ from: 1, to: 6 });
editor.commands.setFontSize('20pt');
item(buildContextMenu(editor), 'Clear Formatting')!.run();
expect(JSON.stringify(editor.getJSON())).not.toContain('20pt');
editor.destroy();
});
});