feat: right-click context-menu
This commit is contained in:
1 parent
9483d598fc
commit
8cdd487172
9 files changed
+434
-158
No files matched your search
+2
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user