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 @@ }); -