From 8cdd48717248b2fbc2299fc6631be547ef1a3f40 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 2 Aug 2026 23:06:18 +0200 Subject: [PATCH] feat: right-click context-menu --- CHANGELOG.md | 3 +- src/App.svelte | 16 +- src/lib/components/ContextMenu.svelte | 163 +++++++++++++++++++++ src/lib/components/Editor.svelte | 94 ++++++------ src/lib/components/SpellContextMenu.svelte | 114 -------------- src/lib/editor/contextMenuItems.ts | 104 +++++++++++++ src/lib/i18n/locales/de.ts | 11 ++ src/lib/i18n/locales/en.ts | 11 ++ tests/unit/context-menu.test.ts | 76 ++++++++++ 9 files changed, 434 insertions(+), 158 deletions(-) create mode 100644 src/lib/components/ContextMenu.svelte delete mode 100644 src/lib/components/SpellContextMenu.svelte create mode 100644 src/lib/editor/contextMenuItems.ts create mode 100644 tests/unit/context-menu.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2f8b5..4d97b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/src/App.svelte b/src/App.svelte index b1f1780..f4d6143 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,5 @@ + + + + diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index d0f7da1..bd5cf74 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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 @@ }); -
+
@@ -841,15 +854,12 @@ strokeWidthPt={textBoxUi.strokeWidthPt} /> {/if} - {#if spellMenu.visible} - (ctxMenu = null)} /> {/if} {#if linkTip} diff --git a/src/lib/components/SpellContextMenu.svelte b/src/lib/components/SpellContextMenu.svelte deleted file mode 100644 index 6b74dd7..0000000 --- a/src/lib/components/SpellContextMenu.svelte +++ /dev/null @@ -1,114 +0,0 @@ - - - - - diff --git a/src/lib/editor/contextMenuItems.ts b/src/lib/editor/contextMenuItems.ts new file mode 100644 index 0000000..5b3c6ae --- /dev/null +++ b/src/lib/editor/contextMenuItems.ts @@ -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; +} diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 55022f6..a9b1647 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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.', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index a0273f6..6d942f8 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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.', diff --git a/tests/unit/context-menu.test.ts b/tests/unit/context-menu.test.ts new file mode 100644 index 0000000..7521a2a --- /dev/null +++ b/tests/unit/context-menu.test.ts @@ -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 => e.kind === 'item').map((e) => e.label); + +const item = (entries: MenuEntry[], label: string) => + entries.find((e): e is Extract => 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(); + }); +});