From b98cc550c34995b9085cc9585a74f60efd60ea19 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:20:40 +0200 Subject: [PATCH 01/26] fix: the suite finds a localStorage again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 26 defines its own `localStorage` global — undefined unless the process is given --localstorage-file — and it shadows the one jsdom installs. Every module that reads storage at import time threw before a test could run, so 37 of 65 files failed to collect. A setup file defines the property when it is missing. --- tests/setup.ts | 15 +++++++++++++++ vite.config.ts | 1 + 2 files changed, 16 insertions(+) create mode 100644 tests/setup.ts diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..51d385c --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,15 @@ +// Node 26 ships its own `localStorage` global, undefined unless the process gets +// --localstorage-file, and it shadows the one jsdom installs. Modules that read +// storage at import time then throw before a single test runs. +if (!globalThis.localStorage) { + const store = new Map(); + const shim: Storage = { + get length() { return store.size; }, + key: (i) => [...store.keys()][i] ?? null, + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => void store.set(k, String(v)), + removeItem: (k) => void store.delete(k), + clear: () => store.clear(), + }; + Object.defineProperty(globalThis, 'localStorage', { value: shim, configurable: true }); +} diff --git a/vite.config.ts b/vite.config.ts index 584d000..8dc70c7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ // jsdom supplies a global DOMParser, so the export/import specs need no setup. test: { environment: 'jsdom', + setupFiles: ['tests/setup.ts'], include: ['tests/**/*.test.ts'], globals: false, // A build+re-import leg runs ~10s when the whole suite competes for the CPU, From 2aeac7a574bc8f8e5cbacc7324d0e096c54c2eb9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:20:47 +0200 Subject: [PATCH 02/26] feat: a Word-style ribbon can take the toolbar's place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second chrome, chosen by odf-editor-chrome and switchable from either side so the two can be compared. The shell only — tab strip, File menu, quick-access save/undo/redo; the tab bodies follow. The classic island stays untouched. Word's palette lives in --w-* outside the theme blocks, and .ribbon remaps the app's own tokens onto it, so a reused picker adopts the ribbon's look without an edit of its own. --- src/App.svelte | 49 ++- src/lib/components/ribbon/Icon.svelte | 23 ++ src/lib/components/ribbon/Ribbon.svelte | 352 ++++++++++++++++++ src/lib/components/ribbon/RibbonButton.svelte | 172 +++++++++ src/lib/components/ribbon/RibbonGroup.svelte | 80 ++++ src/lib/components/ribbon/RibbonMenu.svelte | 50 +++ src/lib/components/ribbon/icons.ts | 44 +++ src/lib/components/ribbon/menu.svelte.ts | 34 ++ src/lib/components/ribbon/selection.ts | 20 + src/lib/i18n/locales/de.ts | 24 ++ src/lib/i18n/locales/en.ts | 24 ++ src/lib/storage/theme.ts | 13 + src/styles/global.css | 56 ++- 13 files changed, 936 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/ribbon/Icon.svelte create mode 100644 src/lib/components/ribbon/Ribbon.svelte create mode 100644 src/lib/components/ribbon/RibbonButton.svelte create mode 100644 src/lib/components/ribbon/RibbonGroup.svelte create mode 100644 src/lib/components/ribbon/RibbonMenu.svelte create mode 100644 src/lib/components/ribbon/icons.ts create mode 100644 src/lib/components/ribbon/menu.svelte.ts create mode 100644 src/lib/components/ribbon/selection.ts diff --git a/src/App.svelte b/src/App.svelte index 4bde3fd..8c37d48 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -6,6 +6,7 @@ import EditorComponent from './lib/components/Editor.svelte'; import Toolbar from './lib/components/Toolbar.svelte'; import ToolbarExpanded from './lib/components/ToolbarExpanded.svelte'; + import Ribbon from './lib/components/ribbon/Ribbon.svelte'; import FindReplaceBar from './lib/components/FindReplaceBar.svelte'; import { buildOdt, deriveFilename } from './lib/export/odt'; import { exportPdf, printPdf, printRaster } from './lib/export/pdf'; @@ -20,7 +21,7 @@ import { resetHistoryLog } from './lib/utils/historyLog.svelte'; import { countText, type TextStats } from './lib/utils/wordCount'; import { clampZoom, wheelZoomFactor, MIN_ZOOM, MAX_ZOOM } from './lib/utils/zoom'; - import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, type ThemeMode } from './lib/storage/theme'; + import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadChromeMode, saveChromeMode, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, type ThemeMode, type ChromeMode } from './lib/storage/theme'; import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins'; import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation'; import { loadTabInterval, saveTabInterval, applyTabIntervalVar, DEFAULT_TAB_INTERVAL_CM } from './lib/storage/tabInterval'; @@ -117,6 +118,8 @@ let themeMode: ThemeMode = $state(loadTheme()); let themeOpen = $state(false); let toolbarExpanded = $state(loadToolbarExpanded()); + // Which chrome mounts: the floating island or the ribbon. Both drive one editor. + let chromeMode: ChromeMode = $state(loadChromeMode()); let showFormattingMarks = $state(loadFormattingMarks()); let showRuler = $state(loadRuler()); let zoom = $state(clampZoom(parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10))); @@ -157,6 +160,10 @@ return n ? `${sanitizeNameForFile(n)}.docx` : deriveFilename(json).replace(/\.odt$/, '.docx'); } + $effect(() => { + saveChromeMode(chromeMode); + }); + $effect(() => { saveFormattingMarks(showFormattingMarks); }); @@ -260,7 +267,7 @@ // Expand it (not persisted) and re-fire once the dialog's own listener exists. $effect(() => { const open = () => { - if (toolbarExpanded) return; + if (chromeMode === 'ribbon' || toolbarExpanded) return; toolbarExpanded = true; domUpdated().then(() => window.dispatchEvent(new CustomEvent(OPEN_LINK_DIALOG_EVENT))); }; @@ -793,7 +800,9 @@ } -
+ +
{#snippet toolbarScrollbar()} {#if tbOverflow > 0}
{/if} {/snippet} + {#if chromeMode === 'ribbon'} +
+ (aboutOpen = true)} + /> +
+ {:else}
@@ -973,6 +1006,15 @@ {/if} {/each} +
{t().ribbon.chrome.title}
+
{/if}
@@ -1036,6 +1078,7 @@
+ {/if} + import { STROKED, FILLED, pinnedStroke, type IconName } from './icons'; + + let { name, size = 20 }: { name: IconName; size?: number } = $props(); + + let stroked = $derived((STROKED as Record)[name] ?? []); + let filled = $derived((FILLED as Record)[name] ?? []); + + + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte new file mode 100644 index 0000000..3ba3cb0 --- /dev/null +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -0,0 +1,352 @@ + + +
+
+
+ + {#if isMenuOpen('file')} + + + +
+ + +
+ + + +
+ + +
+ {/if} +
+ + + + + + + {#each TABS as id} + + {/each} + + + +
+ + e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} + onblur={() => (documentName = documentName.trim())} + /> + .odt +
+ +
+ + {#if isMenuOpen('appearance')} + + + +
+ + {#each (['light', 'dark', 'allBlack', 'auto'] as const) as m} + + {/each} +
+ {/if} +
+ + +
+ +
+ {#if tab === 'home'} + + {/if} +
+
+ + diff --git a/src/lib/components/ribbon/RibbonButton.svelte b/src/lib/components/ribbon/RibbonButton.svelte new file mode 100644 index 0000000..4726620 --- /dev/null +++ b/src/lib/components/ribbon/RibbonButton.svelte @@ -0,0 +1,172 @@ + + +{#snippet face()} + + {#if content}{@render content()} + {:else if icon}{/if} + {#if caret && !onCaret} + + {/if} + + {#if label && variant !== 'icon'}{label}{/if} +{/snippet} + +{#if caret && onCaret} + + + + +{:else} + +{/if} + + diff --git a/src/lib/components/ribbon/RibbonGroup.svelte b/src/lib/components/ribbon/RibbonGroup.svelte new file mode 100644 index 0000000..8fef165 --- /dev/null +++ b/src/lib/components/ribbon/RibbonGroup.svelte @@ -0,0 +1,80 @@ + + +
+
{@render children()}
+
+ {label} + {#if onLauncher} + + {/if} +
+
+ + diff --git a/src/lib/components/ribbon/RibbonMenu.svelte b/src/lib/components/ribbon/RibbonMenu.svelte new file mode 100644 index 0000000..1d3d369 --- /dev/null +++ b/src/lib/components/ribbon/RibbonMenu.svelte @@ -0,0 +1,50 @@ + + + + + diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts new file mode 100644 index 0000000..340cecb --- /dev/null +++ b/src/lib/components/ribbon/icons.ts @@ -0,0 +1,44 @@ +// Ribbon glyphs on a 16-unit canvas, as bare path data so one wrapper can +// own stroke, colour and size. STROKED paints outlines, FILLED solid shapes. + +export type IconName = keyof typeof STROKED | keyof typeof FILLED; + +const DOC_OUTLINE = [ + 'M9 1.75H4.5A1.25 1.25 0 0 0 3.25 3v10A1.25 1.25 0 0 0 4.5 14.25h7A1.25 1.25 0 0 0 12.75 13V5.5L9 1.75z', + 'M9 1.75V5.5h3.75', +]; + +export const STROKED = { + chevronDown: ['M3.5 6 8 10.5 12.5 6'], + chevronRight: ['M6 3.5 10.5 8 6 12.5'], + check: ['M3.5 8.5 6.5 11.5 12.5 5'], + doc: DOC_OUTLINE, + newDoc: [...DOC_OUTLINE, 'M8 8v3.5M6.25 9.75h3.5'], + folder: ['M1.75 12.5V4a1 1 0 0 1 1-1h3.2a1 1 0 0 1 .8.4l.7.95a1 1 0 0 0 .8.4h4.2a1 1 0 0 1 1 1v6.75a1 1 0 0 1-1 1H2.75a1 1 0 0 1-1-1z'], + save: [ + 'M2.75 2.5h7.65L13.5 5.6V12.75a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75V3.25a.75.75 0 0 1 .25-.75z', + 'M5 2.5v3h4.5v-3', + 'M4.75 8.75h6.5v4.75h-6.5z', + ], + print: [ + 'M4.5 6V2.25h7V6', + 'M4.5 12H3.25A1.25 1.25 0 0 1 2 10.75V7.25A1.25 1.25 0 0 1 3.25 6h9.5A1.25 1.25 0 0 1 14 7.25v3.5A1.25 1.25 0 0 1 12.75 12H11.5', + 'M4.5 10h7v4h-7z', + ], + export: ['M8 2v7', 'M5 6.25 8 9.25l3-3', 'M2.75 11.5v1.75h10.5V11.5'], + undo: ['M2.5 6.5h6.75a3.75 3.75 0 0 1 0 7.5H5.5', 'M5.25 3.5 2.5 6.5l2.75 3'], + redo: ['M13.5 6.5H6.75a3.75 3.75 0 0 0 0 7.5H10.5', 'M10.75 3.5 13.5 6.5l-2.75 3'], + info: ['M8 14.5A6.5 6.5 0 1 0 8 1.5a6.5 6.5 0 0 0 0 13z', 'M8 7.25v4'], + ribbon: ['M2 3.25h12v3.5H2z', 'M2 9.5h4v3.25H2z', 'M7.5 9.5h2.5v3.25H7.5z', 'M11.5 9.5H14v3.25h-2.5z'], +} as const; + +export const FILLED = { + infoDot: ['M8 4.4a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6z'], +} as const; + +// Constant painted stroke rather than proportional scaling: a 1-unit stroke would +// paint 1.75px at 28px and a hairline at 14px. Units = painted px × 16 / rendered px. +export function pinnedStroke(size: number): number { + const painted = size >= 20 ? 1.5 : size >= 13 ? 1.25 : 1.1; + return (painted * 16) / size; +} diff --git a/src/lib/components/ribbon/menu.svelte.ts b/src/lib/components/ribbon/menu.svelte.ts new file mode 100644 index 0000000..ed9d6ef --- /dev/null +++ b/src/lib/components/ribbon/menu.svelte.ts @@ -0,0 +1,34 @@ +// One open menu at a time, keyed by a string id. Opening any ribbon menu closes +// whichever was open, so no control has to know about its siblings. + +let current = $state(null); + +export function openMenu(): string | null { + return current; +} + +export function isMenuOpen(id: string): boolean { + return current === id; +} + +export function toggleMenu(id: string): void { + current = current === id ? null : id; +} + +export function showMenu(id: string): void { + current = id; +} + +export function closeMenu(): void { + current = null; +} + +// Closes the shared menu when a mousedown lands outside `node`. Attach to the +// element that wraps a trigger and its panel. +export function clickOutside(node: HTMLElement) { + function handler(e: MouseEvent) { + if (!node.contains(e.target as Node)) closeMenu(); + } + window.addEventListener('mousedown', handler); + return { destroy() { window.removeEventListener('mousedown', handler); } }; +} diff --git a/src/lib/components/ribbon/selection.ts b/src/lib/components/ribbon/selection.ts new file mode 100644 index 0000000..c6796b6 --- /dev/null +++ b/src/lib/components/ribbon/selection.ts @@ -0,0 +1,20 @@ +import type { Editor } from '@tiptap/core'; + +export type SavedRange = { from: number; to: number } | null; + +// A popover steals focus, which collapses the ProseMirror selection. Snapshot it +// when the popover opens, replay it before the command runs. +export function saveRange(editor: Editor | null): SavedRange { + if (!editor) return null; + const { from, to } = editor.state.selection; + return { from, to }; +} + +// Runs `fn` with the saved range restored; the chain it builds must be `.run()` by +// the caller, as usual. +export function withRange(editor: Editor | null, range: SavedRange, fn: () => void): void { + if (!editor) return; + if (range) editor.chain().focus().setTextSelection(range).run(); + else editor.chain().focus().run(); + fn(); +} diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 1c0fb87..ac55e6e 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -441,6 +441,30 @@ const de: Messages = { done: 'Fertig', doneTitle: 'Bearbeiten der Kopf-/Fußzeile beenden', }, + ribbon: { + tabs: { + file: 'Datei', + home: 'Start', + insert: 'Einfügen', + layout: 'Layout', + references: 'Verweise', + review: 'Überprüfen', + view: 'Ansicht', + tableDesign: 'Tabellenentwurf', + tableLayout: 'Tabellenlayout', + pictureFormat: 'Bildformat', + shapeFormat: 'Formformat', + }, + saveAs: 'Speichern unter…', + export: 'Exportieren', + chrome: { + title: 'Symbolleisten-Stil', + ribbon: 'Menüband', + ribbonHint: 'Mit Registerkarten, wie in Word', + classic: 'Klassisch', + classicHint: 'Die schwebende Symbolleiste', + }, + }, app: { documentName: 'Dokumentname', untitled: 'Unbenanntes Dokument', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 0649ad1..f587d80 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -445,6 +445,30 @@ const en = { done: 'Done', doneTitle: 'Finish editing the header/footer', }, + ribbon: { + tabs: { + file: 'File', + home: 'Home', + insert: 'Insert', + layout: 'Layout', + references: 'References', + review: 'Review', + view: 'View', + tableDesign: 'Table Design', + tableLayout: 'Table Layout', + pictureFormat: 'Picture Format', + shapeFormat: 'Shape Format', + }, + saveAs: 'Save as…', + export: 'Export', + chrome: { + title: 'Toolbar style', + ribbon: 'Ribbon', + ribbonHint: 'Tabbed, like Word', + classic: 'Classic', + classicHint: 'The floating toolbar', + }, + }, app: { documentName: 'Document name', untitled: 'Untitled document', diff --git a/src/lib/storage/theme.ts b/src/lib/storage/theme.ts index 074c0fd..878dd3a 100644 --- a/src/lib/storage/theme.ts +++ b/src/lib/storage/theme.ts @@ -1,7 +1,12 @@ export type ThemeMode = 'light' | 'dark' | 'allBlack' | 'auto'; +// Which chrome mounts above the document: the floating command island, or the +// Word-style ribbon. Both drive the same editor; only one is mounted at a time. +export type ChromeMode = 'classic' | 'ribbon'; + const THEME_KEY = 'odf-editor-theme'; const TOOLBAR_KEY = 'odf-editor-toolbar-expanded'; +const CHROME_KEY = 'odf-editor-chrome'; const FORMATTING_MARKS_KEY = 'odf-editor-formatting-marks'; const RULER_KEY = 'odf-editor-ruler'; @@ -23,6 +28,14 @@ export function saveToolbarExpanded(expanded: boolean): void { localStorage.setItem(TOOLBAR_KEY, String(expanded)); } +export function loadChromeMode(): ChromeMode { + return localStorage.getItem(CHROME_KEY) === 'ribbon' ? 'ribbon' : 'classic'; +} + +export function saveChromeMode(mode: ChromeMode): void { + localStorage.setItem(CHROME_KEY, mode); +} + export function loadFormattingMarks(): boolean { return localStorage.getItem(FORMATTING_MARKS_KEY) === 'true'; } diff --git a/src/styles/global.css b/src/styles/global.css index 91b51c3..d85a3ee 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -260,6 +260,27 @@ --brand-gradient: linear-gradient(90deg, var(--color-primary), var(--accent-warm)); } +/* The ribbon's palette: Word's own greys and accent, deliberately outside the + [data-theme] blocks — the theme switcher styles the canvas, not the ribbon. */ +:root { + --w-accent: #185abd; + --w-accent-dark: #124a9e; + --w-chrome: #ffffff; + --w-surface: #ffffff; + --w-border: #e3e6ea; + --w-border-strong: #d9d9d9; + --w-text: #242424; + --w-text-dim: #605e5c; + --w-text-tertiary: #909499; + --w-hover: #f3f2f1; + --w-pressed: #edebe9; + --w-active: #e2e0de; + --w-menu-shadow: 0 8px 24px rgb(0 0 0 / 18%); + --w-font: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; + /* Body height: three control rows plus the group-label row. A tuning knob. */ + --w-ribbon-h: 96px; +} + /* Signature micro-interactions, shared across all toolbar components. */ @keyframes menu-in { from { opacity: 0; transform: translateY(-5px); } @@ -271,10 +292,41 @@ .theme-dropdown, .ol-dropdown, .align-dropdown, .font-dropdown, .size-dropdown, .color-dropdown, .char-dropdown, .history-dropdown, .layout-dropdown, -.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown { +.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown, .ribbon-menu { animation: menu-in 0.14s ease; } +/* Rows inside a ribbon menu. Global because the panel (RibbonMenu.svelte) renders + its rows from a caller's snippet, which Svelte scopes to that caller. */ +.ribbon-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 12px; + border: none; + background: none; + border-radius: 4px; + color: var(--w-text); + font-family: var(--w-font); + font-size: 13px; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.ribbon-menu button:hover:not(:disabled) { background: var(--w-hover); } +.ribbon-menu button:disabled { color: var(--w-text-tertiary); cursor: default; } +.ribbon-menu button.selected { background: var(--w-active); } + +/* Both trail the row's label: the shortcut hint and the secondary description + Word puts at the right edge of a menu entry. */ +.ribbon-menu .menu-key, +.ribbon-menu .menu-sub { margin-left: auto; padding-left: 16px; color: var(--w-text-dim); } +.ribbon-menu .menu-key { font-size: 12px; } +.ribbon-menu .menu-sub { font-size: 11px; } +.ribbon-menu hr { border: none; border-top: 1px solid var(--w-border); margin: 4px 2px; } + /* Scrolling body of a popup menu. Only this inner box scrolls; the panel keeps the background and frame, so the elastic overscroll bounce can't drag the background off the border and show the page through the gap. */ @@ -289,7 +341,7 @@ .toolbar-region button:active:not(:disabled) { transform: none; } .theme-dropdown, .ol-dropdown, .align-dropdown, .font-dropdown, .size-dropdown, .color-dropdown, .char-dropdown, .history-dropdown, .layout-dropdown, - .lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown { animation: none; } + .lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown, .ribbon-menu { animation: none; } } [data-theme="light"] { From f7ee9546a4ed362901e56d04938d6c01e3527cb0 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:22:52 +0200 Subject: [PATCH 03/26] refactor: what the selection formats becomes a shared reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Font, size, mark colour and block attributes all answer the same question — the one value the selection agrees on, blank when it mixes. One `uniform` walk now serves all four, framework-free, so any chrome can read it inside its own reactive wrapper. The classic toolbar keeps its own copies for now: it is the baseline the ribbon is compared against, and it should adopt these only once that comparison ends. --- src/lib/utils/selectionFormat.ts | 85 +++++++++++++++++++++++++ tests/unit/selection-format.test.ts | 97 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/lib/utils/selectionFormat.ts create mode 100644 tests/unit/selection-format.test.ts diff --git a/src/lib/utils/selectionFormat.ts b/src/lib/utils/selectionFormat.ts new file mode 100644 index 0000000..7b721ff --- /dev/null +++ b/src/lib/utils/selectionFormat.ts @@ -0,0 +1,85 @@ +// What the selection formats uniformly. Each reader returns the shared value, +// '' when the selection mixes two, and null where "no value" is meaningful. +// Framework-free, so a toolbar reads them inside its own reactive wrapper. + +import type { EditorState } from '@tiptap/pm/state'; +import { blockFontSize, DEFAULT_FONT_SIZE, type SizedBlock } from './fontSize'; + +export const DEFAULT_EDITOR_FONT = 'Liberation Serif'; + +type MarkedNode = { + isText: boolean; + isInline: boolean; + isAtom: boolean; + attrs: Record; + marks: readonly { type: { name: string }; attrs: Record }[]; +}; + +// A node that carries text formatting: a text node, or an inline atom (a date +// field, say) bearing the mark. Selecting such an atom makes a NodeSelection, so +// without this its font, size and colour would all read as the default. +export function bearsMark(node: MarkedNode, markName: string): boolean { + return node.isText || (node.isInline && node.isAtom && node.marks.some((m) => m.type.name === markName)); +} + +// Walks the selection and returns the one value every node agrees on, '' if they +// disagree. `read` returns undefined for a node that carries no opinion. +function uniform(state: EditorState, read: (node: MarkedNode, parent: MarkedNode | null) => T | undefined): T | '' | undefined { + const { from, to } = state.selection; + let value: T | undefined; + let mixed = false; + state.doc.nodesBetween(from, to, (node, _pos, parent) => { + if (mixed) return; + const v = read(node as unknown as MarkedNode, parent as unknown as MarkedNode | null); + if (v === undefined) return; + if (value === undefined) value = v; + else if (value !== v) mixed = true; + }); + return mixed ? '' : value; +} + +function storedMarkAttr(state: EditorState, markName: string, attr: string): string | undefined { + const marks = state.storedMarks ?? state.selection.$head.marks(); + return marks.find((m) => m.type.name === markName)?.attrs[attr] as string | undefined; +} + +export function uniformFont(state: EditorState): string { + if (state.selection.empty) return storedMarkAttr(state, 'textStyle', 'fontFamily') ?? DEFAULT_EDITOR_FONT; + const v = uniform(state, (node) => + bearsMark(node, 'textStyle') + ? (node.marks.find((m) => m.type.name === 'textStyle')?.attrs.fontFamily ?? DEFAULT_EDITOR_FONT) + : undefined); + return v ?? DEFAULT_EDITOR_FONT; +} + +export function uniformFontSize(state: EditorState): string { + if (state.selection.empty) { + const head = state.selection.$head; + return storedMarkAttr(state, 'textStyle', 'fontSize') ?? blockFontSize(head.parent as unknown as SizedBlock); + } + const v = uniform(state, (node, parent) => { + if (!bearsMark(node, 'textStyle')) return undefined; + const explicit = node.marks.find((m) => m.type.name === 'textStyle')?.attrs.fontSize; + return explicit || blockFontSize(parent as unknown as SizedBlock); + }); + return v ?? DEFAULT_FONT_SIZE; +} + +// null = no colour anywhere. Font colour rides the textStyle mark (fontColor.ts), +// highlight its own multicolor mark. +export function uniformMarkColor(state: EditorState, markName: string): string | null { + if (state.selection.empty) return storedMarkAttr(state, markName, 'color') ?? null; + const v = uniform(state, (node) => + bearsMark(node, markName) + ? (node.marks.find((m) => m.type.name === markName)?.attrs.color ?? null) + : undefined); + return v === undefined ? null : v; +} + +// A block attribute every selected block agrees on (lineHeight, spaceBefore, +// backgroundColor …). `fallback` is what a block without the attribute reports. +export function uniformBlockAttr(state: EditorState, attr: string, fallback: T): T | '' { + if (state.selection.empty) return (state.selection.$head.parent.attrs[attr] ?? fallback) as T; + const v = uniform(state, (node) => (attr in node.attrs ? ((node.attrs[attr] ?? fallback) as T) : undefined)); + return v === undefined ? fallback : v; +} diff --git a/tests/unit/selection-format.test.ts b/tests/unit/selection-format.test.ts new file mode 100644 index 0000000..423ce6f --- /dev/null +++ b/tests/unit/selection-format.test.ts @@ -0,0 +1,97 @@ +// What a toolbar shows for the selection. Drives a real body editor, so the +// readers see the same state the ribbon does. +import { describe, it, expect } from 'vitest'; +import { Editor } from '@tiptap/core'; +import { extensions } from '../../src/lib/editor/extensions'; +import { + DEFAULT_EDITOR_FONT, uniformFont, uniformFontSize, uniformMarkColor, uniformBlockAttr, +} from '../../src/lib/utils/selectionFormat'; + +type N = any; + +function makeEditor(content: N) { + const el = document.createElement('div'); + document.body.appendChild(el); + return new Editor({ element: el, extensions, content }); +} + +const run = (text: string, attrs?: N): N => ({ + type: 'text', text, ...(attrs ? { marks: [{ type: 'textStyle', attrs }] } : {}), +}); + +const doc = (...paras: N[]): N => ({ type: 'doc', content: paras }); +const para = (attrs: N, ...content: N[]): N => ({ type: 'paragraph', attrs, content }); + +describe('uniformFont', () => { + it('falls back to the editor default where no run sets one', () => { + const editor = makeEditor(doc(para({}, run('plain')))); + editor.commands.selectAll(); + expect(uniformFont(editor.state)).toBe(DEFAULT_EDITOR_FONT); + editor.destroy(); + }); + + it('reports one font across a selection and blanks a mixed one', () => { + const editor = makeEditor(doc(para({}, run('aa', { fontFamily: 'Arial' }), run('bb', { fontFamily: 'Arial' })))); + editor.commands.selectAll(); + expect(uniformFont(editor.state)).toBe('Arial'); + + const mixed = makeEditor(doc(para({}, run('aa', { fontFamily: 'Arial' }), run('bb', { fontFamily: 'Georgia' })))); + mixed.commands.selectAll(); + expect(uniformFont(mixed.state)).toBe(''); + editor.destroy(); + mixed.destroy(); + }); +}); + +describe('uniformFontSize', () => { + it('prefers a run size over the block size', () => { + const editor = makeEditor(doc(para({ fontSize: '20pt' }, run('big', { fontSize: '9pt' })))); + editor.commands.selectAll(); + expect(uniformFontSize(editor.state)).toBe('9pt'); + editor.destroy(); + }); + + it('reads the block size for a run that carries none', () => { + const editor = makeEditor(doc(para({ fontSize: '20pt' }, run('plain')))); + editor.commands.selectAll(); + expect(uniformFontSize(editor.state)).toBe('20pt'); + editor.destroy(); + }); +}); + +describe('uniformMarkColor', () => { + it('is null without a colour, the colour with one, blank when mixed', () => { + const none = makeEditor(doc(para({}, run('plain')))); + none.commands.selectAll(); + expect(uniformMarkColor(none.state, 'textStyle')).toBeNull(); + + const one = makeEditor(doc(para({}, run('red', { color: '#ff0000' })))); + one.commands.selectAll(); + expect(uniformMarkColor(one.state, 'textStyle')).toBe('#ff0000'); + + const two = makeEditor(doc(para({}, run('r', { color: '#ff0000' }), run('b', { color: '#0000ff' })))); + two.commands.selectAll(); + expect(uniformMarkColor(two.state, 'textStyle')).toBe(''); + none.destroy(); one.destroy(); two.destroy(); + }); +}); + +describe('uniformBlockAttr', () => { + it('agrees across blocks and blanks when they differ', () => { + const same = makeEditor(doc(para({ lineHeight: '1.5' }, run('a')), para({ lineHeight: '1.5' }, run('b')))); + same.commands.selectAll(); + expect(uniformBlockAttr(same.state, 'lineHeight', '1')).toBe('1.5'); + + const differ = makeEditor(doc(para({ lineHeight: '1.5' }, run('a')), para({ lineHeight: '2' }, run('b')))); + differ.commands.selectAll(); + expect(uniformBlockAttr(differ.state, 'lineHeight', '1')).toBe(''); + same.destroy(); differ.destroy(); + }); + + it('reports the fallback for a block that never sets the attribute', () => { + const editor = makeEditor(doc(para({}, run('a')))); + editor.commands.selectAll(); + expect(uniformBlockAttr(editor.state, 'lineHeight', '1')).toBe('1'); + editor.destroy(); + }); +}); From ac93ba86514f941f97fcb912746a5bfa594280da Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:34:03 +0200 Subject: [PATCH 04/26] feat: the ribbon's Home tab carries every formatting control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clipboard, Font, Paragraph, a style gallery of live tiles, and Find/Replace — which had no button in either chrome before. So did change case, underline and strikethrough line styles: the engine has carried them all along. Two things the fixed-height band forced. Its own overflow clips a dropdown, so a menu leaves the flow and pins itself under its trigger. And with one shared open-menu id, each wrapper must name the menu it owns, or every sibling closes it on mousedown and the click on a row never lands. --- src/App.svelte | 3 + src/lib/components/ribbon/Ribbon.svelte | 27 +- src/lib/components/ribbon/RibbonMenu.svelte | 42 +- .../ribbon/controls/FontFamilyBox.svelte | 117 +++++ .../ribbon/controls/FontSizeBox.svelte | 98 +++++ .../ribbon/controls/StyleGallery.svelte | 198 +++++++++ src/lib/components/ribbon/fontList.svelte.ts | 58 +++ src/lib/components/ribbon/icons.ts | 28 ++ src/lib/components/ribbon/menu.svelte.ts | 38 +- src/lib/components/ribbon/tabs/HomeTab.svelte | 408 ++++++++++++++++++ src/lib/editor/contextMenuItems.ts | 4 +- src/lib/i18n/locales/de.ts | 23 + src/lib/i18n/locales/en.ts | 23 + src/styles/global.css | 109 ++++- 14 files changed, 1118 insertions(+), 58 deletions(-) create mode 100644 src/lib/components/ribbon/controls/FontFamilyBox.svelte create mode 100644 src/lib/components/ribbon/controls/FontSizeBox.svelte create mode 100644 src/lib/components/ribbon/controls/StyleGallery.svelte create mode 100644 src/lib/components/ribbon/fontList.svelte.ts create mode 100644 src/lib/components/ribbon/tabs/HomeTab.svelte diff --git a/src/App.svelte b/src/App.svelte index 8c37d48..f0e0d27 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -831,6 +831,9 @@ tick={activeTick} bind:chromeMode bind:documentName + bind:showFormattingMarks + onManageStyles={openStyleManager} + onFind={openFind} {namePlaceholder} {themeMode} onSelectTheme={selectTheme} diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 3ba3cb0..883577d 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -3,18 +3,23 @@ import Icon from './Icon.svelte'; import RibbonMenu from './RibbonMenu.svelte'; import HistoryButton from '../HistoryButton.svelte'; + import HomeTab from './tabs/HomeTab.svelte'; import UiLanguagePicker from '../UiLanguagePicker.svelte'; import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte'; import { t } from '../../i18n/i18n.svelte'; import { withShortcut } from '../../i18n/shortcut'; import { shortcutHint } from '../../editor/shortcuts'; import type { ChromeMode, ThemeMode } from '../../storage/theme'; + import type { StyleFamily } from '../../styles/styleSheet'; let { editor, tick, chromeMode = $bindable('ribbon'), documentName = $bindable(''), + showFormattingMarks = $bindable(false), + onManageStyles, + onFind, namePlaceholder = '', themeMode = 'auto', onSelectTheme, @@ -26,6 +31,9 @@ tick: number; chromeMode?: ChromeMode; documentName?: string; + showFormattingMarks?: boolean; + onManageStyles?: (family: StyleFamily) => void; + onFind?: (mode: 'find' | 'replace') => void; namePlaceholder?: string; themeMode?: ThemeMode; onSelectTheme?: (mode: ThemeMode) => void; @@ -58,7 +66,7 @@
-
+
-
+

- +
{t().appearance.title}
{#each (['light', 'dark', 'allBlack', 'auto'] as const) as m}
@@ -289,17 +297,6 @@ .appearance-wrap { position: relative; } - /* The heading style RibbonMenu applies to its own `heading` prop, for the second - section of a menu that has two. */ - .menu-heading { - padding: 4px 12px 6px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--w-text-tertiary); - } - /* The name field grows with its text: the sizer mirrors the value and lends the input its width. */ .doc-name { diff --git a/src/lib/components/ribbon/RibbonMenu.svelte b/src/lib/components/ribbon/RibbonMenu.svelte index 1d3d369..6d13542 100644 --- a/src/lib/components/ribbon/RibbonMenu.svelte +++ b/src/lib/components/ribbon/RibbonMenu.svelte @@ -1,8 +1,11 @@ - diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index a2e77b0..bddbbf4 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -57,6 +57,15 @@ export const STROKED = { find: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14'], replace: ['M2 4.5h6.5M2 4.5 4 2.5M2 4.5l2 2', 'M14 11.5H7.5M14 11.5l-2-2M14 11.5l-2 2'], + image: ['M2 3.25h12v9.5H2z', 'M2 10.5 5.75 7l3 2.75L11 7.5l3 3', 'M10.6 6.4a1.1 1.1 0 1 0 0-2.2 1.1 1.1 0 0 0 0 2.2z'], + textBox: ['M1.75 4h12.5v8H1.75z', 'M5 6.5h6M5 9h4'], + link: ['M6.6 9.4 9.4 6.6', 'M7.4 4.4 9 2.8a2.7 2.7 0 0 1 3.8 3.8l-1.6 1.6', 'M8.6 11.6 7 13.2a2.7 2.7 0 0 1-3.8-3.8l1.6-1.6'], + bookmark: ['M4 2.25h8v11.5L8 10.5l-4 3.25z'], + crossRef: ['M2.5 3.5h6M2.5 6.5h4M2.5 9.5h5', 'M8.5 12.5h5M11 10l2.5 2.5L11 15'], + header: ['M2 2.5h12v3H2z', 'M2 8h12M2 11h8'], + footer: ['M2 10.5h12v3H2z', 'M2 5h12M2 8h8'], + pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.75 10.5h2.5', 'M8 5.5v5'], + formula: ['M9 3.5h4', 'M9.5 8h3', 'M2.5 13.5c1.6 0 2-.9 2.4-2.4L7.2 3.4C7.5 2.3 8 2 8.8 2', 'M2.5 6.5h4'], pageBreak: ['M2 4.5h12M2 11.5h12', 'M8 6.5v3', 'M6.4 8.2 8 9.8l1.6-1.6'], } as const; diff --git a/src/lib/components/ribbon/tabs/InsertTab.svelte b/src/lib/components/ribbon/tabs/InsertTab.svelte new file mode 100644 index 0000000..6d9354d --- /dev/null +++ b/src/lib/components/ribbon/tabs/InsertTab.svelte @@ -0,0 +1,328 @@ + + + + editor?.chain().focus().insertPageBreak().run()} + /> + + +
+ + +
+ editor?.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run()} + onManageStyles={() => onManageTableStyles?.('table')} + /> + {t().ribbon.table} +
+
+ +
+ + + imageInput?.click()} /> + editor?.chain().focus().insertTextBox().run()} + /> + + +
+ + + + + +
+ + + onEditZone?.('header')} /> + onEditZone?.('footer')} /> +
+ toggleMenu('pageField')} onCaret={() => toggleMenu('pageField')} /> + {#if isMenuOpen('pageField')} + + {/if} +
+
+ +
+ + +
+ editor?.chain().focus().setTextSelection(range).insertDateTimeField(opts).run()} + /> + {t().ribbon.dateTime} +
+
+ +
+ + +
+ editor?.chain().focus().setTextSelection(range).insertContent(char).run()} + /> + {t().ribbon.symbol} +
+ +
+ + + + + + diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 03ed93d..d6c4031 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -463,6 +463,13 @@ const de: Messages = { clipboard: 'Zwischenablage', font: 'Schriftart', paragraph: 'Absatz', + pages: 'Seiten', + tables: 'Tabellen', + illustrations: 'Illustrationen', + links: 'Links', + headerFooter: 'Kopf- und Fußzeile', + text: 'Text', + symbols: 'Symbole', editing: 'Bearbeiten', }, case: { @@ -480,6 +487,19 @@ const de: Messages = { dashed: 'Gestrichelt', wavy: 'Wellenlinie', }, + pageBreak: 'Seitenumbruch', + picture: 'Bild', + textBox: 'Textfeld', + crossRef: 'Querverweis', + pageNumber: 'Seitenzahl', + equation: 'Formel', + header: 'Kopfzeile', + footer: 'Fußzeile', + link: 'Link', + bookmark: 'Textmarke', + table: 'Tabelle', + dateTime: 'Datum & Uhrzeit', + symbol: 'Symbol', chrome: { title: 'Symbolleisten-Stil', ribbon: 'Menüband', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index a6fe331..46d793a 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -467,6 +467,13 @@ const en = { clipboard: 'Clipboard', font: 'Font', paragraph: 'Paragraph', + pages: 'Pages', + tables: 'Tables', + illustrations: 'Illustrations', + links: 'Links', + headerFooter: 'Header & Footer', + text: 'Text', + symbols: 'Symbols', editing: 'Editing', }, case: { @@ -484,6 +491,19 @@ const en = { dashed: 'Dashed', wavy: 'Wavy', }, + pageBreak: 'Page break', + picture: 'Picture', + textBox: 'Text box', + crossRef: 'Cross-reference', + pageNumber: 'Page number', + equation: 'Equation', + header: 'Header', + footer: 'Footer', + link: 'Link', + bookmark: 'Bookmark', + table: 'Table', + dateTime: 'Date & time', + symbol: 'Symbol', chrome: { title: 'Toolbar style', ribbon: 'Ribbon', From 5600e8460579155891708e612f76899112aedad6 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:45:24 +0200 Subject: [PATCH 06/26] feat: the ribbon's Layout tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Margins, orientation, page size, columns and breaks as menus; indent and spacing as typed fields. Three of these had no control at all: a section break, and the absolute left and right indent the ruler could only drag. Each margin preset is labelled with the value it sets, so none of them hides a default — and Normal is LibreOffice's 2 cm, the one this editor already uses. --- src/App.svelte | 6 +- src/lib/components/ribbon/Ribbon.svelte | 17 +- src/lib/components/ribbon/icons.ts | 4 + .../components/ribbon/tabs/LayoutTab.svelte | 274 ++++++++++++++++++ src/lib/i18n/locales/de.ts | 15 + src/lib/i18n/locales/en.ts | 15 + 6 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 src/lib/components/ribbon/tabs/LayoutTab.svelte diff --git a/src/App.svelte b/src/App.svelte index cfeba9c..10535c1 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -832,9 +832,9 @@ bind:chromeMode bind:documentName bind:showFormattingMarks - {pageMargins} - {pageOrientation} - {pageFormat} + bind:pageMargins + bind:pageOrientation + bind:pageFormat {hfActive} onManageStyles={openStyleManager} onManageTableStyles={() => openStyleManager('table')} diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 5df2e92..c75029c 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -5,6 +5,7 @@ import HistoryButton from '../HistoryButton.svelte'; import HomeTab from './tabs/HomeTab.svelte'; import InsertTab from './tabs/InsertTab.svelte'; + import LayoutTab from './tabs/LayoutTab.svelte'; import UiLanguagePicker from '../UiLanguagePicker.svelte'; import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte'; import { t } from '../../i18n/i18n.svelte'; @@ -12,7 +13,7 @@ import { shortcutHint } from '../../editor/shortcuts'; import type { ChromeMode, ThemeMode } from '../../storage/theme'; import type { StyleFamily } from '../../styles/styleSheet'; - import type { PageMargins } from '../../storage/pageMargins'; + import { DEFAULT_MARGINS, type PageMargins } from '../../storage/pageMargins'; import type { Orientation } from '../../storage/pageOrientation'; import type { PageFormat } from '../../storage/pageFormat'; import type { HfZone } from '../../storage/headerFooter'; @@ -23,9 +24,9 @@ chromeMode = $bindable('ribbon'), documentName = $bindable(''), showFormattingMarks = $bindable(false), - pageMargins, - pageOrientation, - pageFormat, + pageMargins = $bindable(DEFAULT_MARGINS), + pageOrientation = $bindable('portrait'), + pageFormat = $bindable('A4'), hfActive = null, onManageStyles, onManageTableStyles, @@ -43,9 +44,9 @@ chromeMode?: ChromeMode; documentName?: string; showFormattingMarks?: boolean; - pageMargins: PageMargins; - pageOrientation: Orientation; - pageFormat: PageFormat; + pageMargins?: PageMargins; + pageOrientation?: Orientation; + pageFormat?: PageFormat; hfActive?: HfZone | null; onManageStyles?: (family: StyleFamily) => void; onManageTableStyles?: (family: StyleFamily) => void; @@ -204,6 +205,8 @@ {:else if tab === 'insert'} + {:else if tab === 'layout'} + {/if}
diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index bddbbf4..d930b91 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -57,6 +57,10 @@ export const STROKED = { find: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14'], replace: ['M2 4.5h6.5M2 4.5 4 2.5M2 4.5l2 2', 'M14 11.5H7.5M14 11.5l-2-2M14 11.5l-2 2'], + margins: ['M1.75 1.75h12.5v12.5H1.75z', 'M4.5 1.75v12.5M11.5 1.75v12.5', 'M1.75 4.5h12.5M1.75 11.5h12.5'], + orientation: ['M2.5 2h7v12h-7z', 'M11 6.5h3v6h-3z'], + pageSize: ['M2.5 1.75h11v12.5h-11z', 'M5 5h6M5 8h6M5 11h4'], + columns: ['M2 2.5h4.5v11H2zM9.5 2.5H14v11H9.5z'], image: ['M2 3.25h12v9.5H2z', 'M2 10.5 5.75 7l3 2.75L11 7.5l3 3', 'M10.6 6.4a1.1 1.1 0 1 0 0-2.2 1.1 1.1 0 0 0 0 2.2z'], textBox: ['M1.75 4h12.5v8H1.75z', 'M5 6.5h6M5 9h4'], link: ['M6.6 9.4 9.4 6.6', 'M7.4 4.4 9 2.8a2.7 2.7 0 0 1 3.8 3.8l-1.6 1.6', 'M8.6 11.6 7 13.2a2.7 2.7 0 0 1-3.8-3.8l1.6-1.6'], diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte new file mode 100644 index 0000000..8d0dc12 --- /dev/null +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -0,0 +1,274 @@ + + + +
+ toggleMenu('margins')} onCaret={() => toggleMenu('margins')} /> + {#if isMenuOpen('margins')} + + {/if} +
+ +
+ toggleMenu('orientation')} onCaret={() => toggleMenu('orientation')} /> + {#if isMenuOpen('orientation')} + + {/if} +
+ +
+ toggleMenu('pageFormat')} onCaret={() => toggleMenu('pageFormat')} /> + {#if isMenuOpen('pageFormat')} + + {/if} +
+ +
+ toggleMenu('columns')} + onCaret={() => toggleMenu('columns')} + /> + {#if isMenuOpen('columns')} + + {/if} +
+ +
+ toggleMenu('breaks')} + onCaret={() => toggleMenu('breaks')} + /> + {#if isMenuOpen('breaks')} + + {/if} +
+
+ +
+ + + +
+ + + + +
+
+ + diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index d6c4031..f780134 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -470,6 +470,7 @@ const de: Messages = { headerFooter: 'Kopf- und Fußzeile', text: 'Text', symbols: 'Symbole', + pageSetup: 'Seite einrichten', editing: 'Bearbeiten', }, case: { @@ -500,6 +501,20 @@ const de: Messages = { table: 'Tabelle', dateTime: 'Datum & Uhrzeit', symbol: 'Symbol', + margins: 'Seitenränder', + size: 'Größe', + breaks: 'Umbrüche', + sectionBreak: 'Abschnittsumbruch', + sectionBreakHint: 'Beginnt eine eigene Kopf- und Fußzeile', + indentLeft: 'Einzug links', + indentRight: 'Einzug rechts', + spaceBefore: 'Abstand davor', + spaceAfter: 'Abstand danach', + marginPresets: { + normal: 'Normal', + narrow: 'Schmal', + wide: 'Breit', + }, chrome: { title: 'Symbolleisten-Stil', ribbon: 'Menüband', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 46d793a..f07bad6 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -474,6 +474,7 @@ const en = { headerFooter: 'Header & Footer', text: 'Text', symbols: 'Symbols', + pageSetup: 'Page Setup', editing: 'Editing', }, case: { @@ -504,6 +505,20 @@ const en = { table: 'Table', dateTime: 'Date & time', symbol: 'Symbol', + margins: 'Margins', + size: 'Size', + breaks: 'Breaks', + sectionBreak: 'Section break', + sectionBreakHint: 'Starts its own header and footer', + indentLeft: 'Indent left', + indentRight: 'Indent right', + spaceBefore: 'Space before', + spaceAfter: 'Space after', + marginPresets: { + normal: 'Normal', + narrow: 'Narrow', + wide: 'Wide', + }, chrome: { title: 'Toolbar style', ribbon: 'Ribbon', From 0f53571b1f03c04954ff6de3626b2eb8812576ad Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:48:32 +0200 Subject: [PATCH 07/26] feat: the ribbon's References, Review and View tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit References inserts the table of contents and sets how deep it goes — the depth attribute round-tripped with no way to change it. Review holds the statistics panel and the spell-check language, View the ruler, formatting marks and zoom; each of those had lived only in the status bar or the secondary toolbar. That is all seven fixed tabs. --- src/App.svelte | 6 + src/lib/components/ribbon/Ribbon.svelte | 22 ++++ src/lib/components/ribbon/icons.ts | 7 ++ .../ribbon/tabs/ReferencesTab.svelte | 68 ++++++++++++ .../components/ribbon/tabs/ReviewTab.svelte | 105 ++++++++++++++++++ src/lib/components/ribbon/tabs/ViewTab.svelte | 53 +++++++++ src/lib/i18n/locales/de.ts | 9 ++ src/lib/i18n/locales/en.ts | 9 ++ 8 files changed, 279 insertions(+) create mode 100644 src/lib/components/ribbon/tabs/ReferencesTab.svelte create mode 100644 src/lib/components/ribbon/tabs/ReviewTab.svelte create mode 100644 src/lib/components/ribbon/tabs/ViewTab.svelte diff --git a/src/App.svelte b/src/App.svelte index 10535c1..01751f7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -832,6 +832,12 @@ bind:chromeMode bind:documentName bind:showFormattingMarks + bind:showRuler + {documentLanguage} + onLanguage={(code) => (documentLanguage = code)} + {zoom} + onZoom={setZoom} + onDebugDump={import.meta.env.DEV ? handleDebugDump : undefined} bind:pageMargins bind:pageOrientation bind:pageFormat diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index c75029c..7aa2a9b 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -6,6 +6,9 @@ import HomeTab from './tabs/HomeTab.svelte'; import InsertTab from './tabs/InsertTab.svelte'; import LayoutTab from './tabs/LayoutTab.svelte'; + import ReferencesTab from './tabs/ReferencesTab.svelte'; + import ReviewTab from './tabs/ReviewTab.svelte'; + import ViewTab from './tabs/ViewTab.svelte'; import UiLanguagePicker from '../UiLanguagePicker.svelte'; import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte'; import { t } from '../../i18n/i18n.svelte'; @@ -17,6 +20,7 @@ import type { Orientation } from '../../storage/pageOrientation'; import type { PageFormat } from '../../storage/pageFormat'; import type { HfZone } from '../../storage/headerFooter'; + import type { DocumentLanguage } from '../../storage/documentLanguage'; let { editor, @@ -24,6 +28,12 @@ chromeMode = $bindable('ribbon'), documentName = $bindable(''), showFormattingMarks = $bindable(false), + showRuler = $bindable(true), + documentLanguage, + onLanguage, + zoom = 100, + onZoom, + onDebugDump, pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), @@ -44,6 +54,12 @@ chromeMode?: ChromeMode; documentName?: string; showFormattingMarks?: boolean; + showRuler?: boolean; + documentLanguage: DocumentLanguage; + onLanguage: (code: DocumentLanguage) => void; + zoom?: number; + onZoom?: (value: number) => void; + onDebugDump?: () => void; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; @@ -207,6 +223,12 @@ {:else if tab === 'layout'} + {:else if tab === 'references'} + + {:else if tab === 'review'} + + {:else if tab === 'view'} + {/if}
diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index d930b91..ec16e3f 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -55,6 +55,13 @@ export const STROKED = { highlighter: ['M5.5 9.5 10 5l3 3-4.5 4.5H5.5z', 'M9.25 3.25 12.75 6.75', 'M2.5 13.5h4'], shading: ['M4 8.5 8.5 4l4 4-4.5 4.5a1.4 1.4 0 0 1-2 0L4 10.5a1.4 1.4 0 0 1 0-2z', 'M6.5 2 8.5 4', 'M2.5 13.5h11'], + toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'], + tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'], + wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'], + ruler: ['M1.5 5.5h13v5h-13z', 'M4 5.5v2.2M6.5 5.5v3M9 5.5v2.2M11.5 5.5v3'], + zoomIn: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14', 'M5.25 7.25h4M7.25 5.25v4'], + zoomOut: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14', 'M5.25 7.25h4'], + zoomReset: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14'], find: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14'], replace: ['M2 4.5h6.5M2 4.5 4 2.5M2 4.5l2 2', 'M14 11.5H7.5M14 11.5l-2-2M14 11.5l-2 2'], margins: ['M1.75 1.75h12.5v12.5H1.75z', 'M4.5 1.75v12.5M11.5 1.75v12.5', 'M1.75 4.5h12.5M1.75 11.5h12.5'], diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte new file mode 100644 index 0000000..b124e36 --- /dev/null +++ b/src/lib/components/ribbon/tabs/ReferencesTab.svelte @@ -0,0 +1,68 @@ + + + + editor?.chain().focus().setTableOfContents().run()} + /> +
+ toggleMenu('tocLevels')} + onCaret={() => toggleMenu('tocLevels')} + /> + {#if isMenuOpen('tocLevels')} + + {/if} +
+
+ + diff --git a/src/lib/components/ribbon/tabs/ReviewTab.svelte b/src/lib/components/ribbon/tabs/ReviewTab.svelte new file mode 100644 index 0000000..e285f4b --- /dev/null +++ b/src/lib/components/ribbon/tabs/ReviewTab.svelte @@ -0,0 +1,105 @@ + + + +
+ toggleMenu('wordCount')} + /> + {#if isMenuOpen('wordCount')} + + {/if} +
+
+ +
+ + +
+ + {t().spellPicker.label} +
+
+ + diff --git a/src/lib/components/ribbon/tabs/ViewTab.svelte b/src/lib/components/ribbon/tabs/ViewTab.svelte new file mode 100644 index 0000000..3570332 --- /dev/null +++ b/src/lib/components/ribbon/tabs/ViewTab.svelte @@ -0,0 +1,53 @@ + + + +
+ (showRuler = !showRuler)} /> + (showFormattingMarks = !showFormattingMarks)} /> +
+
+ +
+ + + onZoom?.(zoom - 10)} /> + onZoom?.(100)} /> + = MAX_ZOOM} onclick={() => onZoom?.(zoom + 10)} /> + + +{#if onDebugDump} +
+ + + +{/if} + + diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index f780134..69aa146 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -471,6 +471,11 @@ const de: Messages = { text: 'Text', symbols: 'Symbole', pageSetup: 'Seite einrichten', + toc: 'Inhaltsverzeichnis', + proofing: 'Rechtschreibung', + language: 'Sprache', + show: 'Anzeigen', + debug: 'Debug', editing: 'Bearbeiten', }, case: { @@ -515,6 +520,10 @@ const de: Messages = { narrow: 'Schmal', wide: 'Breit', }, + toc: 'Inhaltsverzeichnis', + tocOptions: 'Ebenen', + tocMaxLevel: 'Tiefste angezeigte Ebene', + debugDump: 'Dump', chrome: { title: 'Symbolleisten-Stil', ribbon: 'Menüband', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index f07bad6..8505fe7 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -475,6 +475,11 @@ const en = { text: 'Text', symbols: 'Symbols', pageSetup: 'Page Setup', + toc: 'Table of Contents', + proofing: 'Proofing', + language: 'Language', + show: 'Show', + debug: 'Debug', editing: 'Editing', }, case: { @@ -519,6 +524,10 @@ const en = { narrow: 'Narrow', wide: 'Wide', }, + toc: 'Table of contents', + tocOptions: 'Levels', + tocMaxLevel: 'Deepest level shown', + debugDump: 'Dump', chrome: { title: 'Toolbar style', ribbon: 'Ribbon', From ad3f0507fdced34d555bb2b81d777187fe47865b Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:54:49 +0200 Subject: [PATCH 08/26] feat: the ribbon's contextual tabs for a table, a picture and a shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They appear while the caret is in the object and go with it, handing the strip back to Home. Table Design carries the style gallery, shading, borders and the style options; Table Layout the rows, columns, merge, split, alignment — and cell margins, which round-tripped with nothing to set them. The floating toolbars stay: a mini-toolbar beside the object and a contextual tab above the document are both Word's, and both reuse the same pickers. --- src/lib/components/ribbon/Ribbon.svelte | 66 +++- src/lib/components/ribbon/icons.ts | 22 ++ .../components/ribbon/tabs/FrameTabs.svelte | 146 +++++++++ .../components/ribbon/tabs/TableTabs.svelte | 295 ++++++++++++++++++ src/lib/i18n/locales/de.ts | 8 + src/lib/i18n/locales/en.ts | 8 + 6 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 src/lib/components/ribbon/tabs/FrameTabs.svelte create mode 100644 src/lib/components/ribbon/tabs/TableTabs.svelte diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 7aa2a9b..52e159a 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -1,5 +1,6 @@ + + + {#each WRAPS as w} + setWrap(w.key)} /> + {/each} + + +{#if which === 'shape'} +
+ + + {#each SHAPES as s} + editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} /> + {/each} +
+ editor?.chain().focus().setTextBoxAttrs({ fillColor: c }).run()} + onClear={() => editor?.chain().focus().setTextBoxAttrs({ fillColor: null }).run()} + icon={fillIcon} + /> + {t().textBox.fillColor} +
+
+ editor?.chain().focus().setTextBoxAttrs({ strokeColor: c }).run()} + onClear={() => editor?.chain().focus().setTextBoxAttrs({ strokeColor: null }).run()} + icon={strokeIcon} + /> + {t().textBox.borderColor} +
+ +
+{/if} + +{#snippet fillIcon()}{/snippet} +{#snippet strokeIcon()}{/snippet} + + diff --git a/src/lib/components/ribbon/tabs/TableTabs.svelte b/src/lib/components/ribbon/tabs/TableTabs.svelte new file mode 100644 index 0000000..6be7485 --- /dev/null +++ b/src/lib/components/ribbon/tabs/TableTabs.svelte @@ -0,0 +1,295 @@ + + +{#if which === 'design'} + +
+ + {t().table.tableStyle} +
+
+ +
+ + +
+ editor?.chain().focus().setCellAttribute('backgroundColor', c).run()} + onClear={() => editor?.chain().focus().setCellAttribute('backgroundColor', null).run()} + icon={shadeIcon} + /> + {t().table.cellShading} +
+
+ + {t().borders.title} +
+
+ +
+ + +
+ run((c) => c.toggleHeaderRowStyle())} /> + run((c) => c.toggleHeaderColumnStyle())} /> +
+
+{:else} + +
+ run((c) => c.addRowBefore())} /> + run((c) => c.addRowAfter())} /> +
+
+ run((c) => c.addColumnBefore())} /> + run((c) => c.addColumnAfter())} /> +
+
+ +
+ + +
+ run((c) => c.deleteRow())} /> + run((c) => c.deleteColumn())} /> + run((c) => c.deleteTable())} /> +
+
+ +
+ + + run((c) => c.mergeCells())} /> + + + { splitOpen = false; editor?.chain().focus().splitCellInto(cols, rows).run(); }} + onClose={() => (splitOpen = false)} + /> + + + +
+ + + {#each VALIGNS as v} + editor?.chain().focus().setCellAttribute('verticalAlign', v.key).run()} + /> + {/each} + + +
+ + +
+ toggleMenu('cellMargins')} + onCaret={() => toggleMenu('cellMargins')} + /> + {#if isMenuOpen('cellMargins') && padding} + + {/if} +
+
+{/if} + +{#snippet shadeIcon()}{/snippet} + + diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 69aa146..3ca4d27 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -476,6 +476,13 @@ const de: Messages = { language: 'Sprache', show: 'Anzeigen', debug: 'Debug', + tableDecor: 'Schattierung & Rahmen', + tableOptions: 'Tabellenformatoptionen', + rowsColumns: 'Zeilen & Spalten', + merge: 'Zusammenführen', + cellSize: 'Zellengröße', + arrange: 'Anordnen', + shapeStyles: 'Formenarten', editing: 'Bearbeiten', }, case: { @@ -524,6 +531,7 @@ const de: Messages = { tocOptions: 'Ebenen', tocMaxLevel: 'Tiefste angezeigte Ebene', debugDump: 'Dump', + cellMargins: 'Zellenränder', chrome: { title: 'Symbolleisten-Stil', ribbon: 'Menüband', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 8505fe7..b1b233b 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -480,6 +480,13 @@ const en = { language: 'Language', show: 'Show', debug: 'Debug', + tableDecor: 'Shading & Borders', + tableOptions: 'Table Style Options', + rowsColumns: 'Rows & Columns', + merge: 'Merge', + cellSize: 'Cell Size', + arrange: 'Arrange', + shapeStyles: 'Shape Styles', editing: 'Editing', }, case: { @@ -528,6 +535,7 @@ const en = { tocOptions: 'Levels', tocMaxLevel: 'Deepest level shown', debugDump: 'Dump', + cellMargins: 'Cell margins', chrome: { title: 'Toolbar style', ribbon: 'Ribbon', From 0d8f396c238dbb726f180637b96c776c2684f95d Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:55:48 +0200 Subject: [PATCH 09/26] docs: where the ribbon lives and why it is built that way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette outside the theme system, the token remap that lets a reused picker adopt it, and the two things the fixed-height band forces — a menu that leaves the flow, and a wrapper that names the menu it owns. Also the deletion the ribbon would unlock if classic were ever retired, so the option stays visible without anything being removed now. --- CLAUDE.md | 2 + docs/architecture/ribbon.md | 94 ++++++++++++++++++++++++++++++++++++ src/lib/components/CLAUDE.md | 4 ++ src/lib/storage/CLAUDE.md | 1 + 4 files changed, 101 insertions(+) create mode 100644 docs/architecture/ribbon.md diff --git a/CLAUDE.md b/CLAUDE.md index 0679c2d..287a0d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ src/ App.svelte – app shell + app-level state lib/ components/ – all Svelte UI (toolbars, pickers, dialogs, Editor, HeaderFooterLayer) + ribbon/ – the Word-style ribbon chrome (shell, controls, one file per tab) editor/ extensions.ts – the TipTap extension registry extensions/ – the custom TipTap/ProseMirror extensions, one file per feature @@ -100,6 +101,7 @@ Directory-level `CLAUDE.md` files load automatically when you touch that directo | ODF/DOCX import, style resolver, image formats | `src/lib/import/CLAUDE.md` | | Paragraph + character styles, style manager | `src/lib/styles/CLAUDE.md` | | localStorage keys, page margins/orientation, themes | `src/lib/storage/CLAUDE.md` | +| The ribbon chrome, its palette and the mode switch | `docs/architecture/ribbon.md` | | Pagination, widow/orphan, columns flow | `docs/architecture/pagination.md` | | Images, text boxes, wrap modes | `docs/architecture/frames.md` | | Table extensions + table styles | `docs/architecture/tables.md` | diff --git a/docs/architecture/ribbon.md b/docs/architecture/ribbon.md new file mode 100644 index 0000000..1e594fd --- /dev/null +++ b/docs/architecture/ribbon.md @@ -0,0 +1,94 @@ +# The ribbon + +A second chrome above the document, laid out like Word's ribbon. It does not replace the +floating command island — `chromeMode` picks which of the two mounts, so both can be used and +compared. Everything the ribbon drives is the same editor, through the same commands. + +## The mode switch + +`chromeMode: 'classic' | 'ribbon'` lives in `storage/theme.ts` beside the other UI-chrome prefs +(`odf-editor-chrome`, default `'classic'`). `App.svelte` forks on it: the ribbon docks as a plain +flex child, the island keeps its absolute overlay. Reachable from both sides — the theme dropdown +in classic, the strip's appearance menu in the ribbon. + +`--toolbar-overlay-h` is written **only** in classic mode. `editor.css` reads it as +`var(--toolbar-overlay-h, 0px)`, so the ribbon's docked layout needs no second rule. + +**Classic is the baseline the ribbon is measured against, so it stays untouched.** Where a shared +helper would improve both, the ribbon takes it and classic keeps its copy; where a dialog is +mounted inside `ToolbarExpanded`, the ribbon mounts its own rather than hoisting it. Only one +chrome is mounted at a time, so the two sets never coexist. + +## Palette + +Word's greys live in `--w-*` in `global.css`, deliberately **outside** the `[data-theme]` blocks: +the theme switcher styles the canvas, the page, the status bar and the classic toolbar, never the +ribbon. Accent `--w-accent: #185abd`; the tab's active underline is 2.5px **inset 13px from each +side**, not a full-width border. + +`.ribbon` remaps the app's own tokens onto that palette (`--color-surface`, `--color-primary`, +`--toolbar-btn-size`, …). Custom properties inherit, so every reused picker — `ColorPicker`, +`HistoryButton`, `TablePicker`, `TableStylePicker`, the border pickers — adopts the ribbon's look +with no edit of its own, while the classic toolbar keeps the themed set. + +Consequence, accepted: in dark and AllBlack the ribbon stays light while the rest of the app goes +dark. + +## Layout + +``` +Ribbon.svelte +├─ .ribbon-tabs File pill · quick access (save, undo, redo) · tabs · contextual tabs +│ · spacer · document name · appearance · UI language +└─ .ribbon-body the active tab's groups, a fixed --w-ribbon-h (96px) band +``` + +The band's height never changes: it scrolls horizontally with a hidden scrollbar instead of +collapsing groups into popovers the way Word does when the window narrows. That is the single +biggest simplification here, and the reason two things below are necessary. + +- **A menu must leave the flow.** `.ribbon-body` clips its own overflow, so the `anchored` action + (`ribbon/menu.svelte.ts`) switches a panel to `position: fixed` and pins it under its wrapper, + clamped inside the window. +- **Each wrapper names the menu it owns.** One shared open-menu id means every `clickOutside` + sees every mousedown; without the id each wrapper would close a sibling's open menu, and the + click on one of its rows would never land. + +## Pieces + +| File | Role | +| --- | --- | +| `Ribbon.svelte` | Shell, tab state, the File menu, which contextual tabs are shown | +| `RibbonGroup.svelte` | A group: its controls, its visible label, Word's ↘ dialog launcher | +| `RibbonButton.svelte` | `big` / `small` / `icon`, plus the split button. Hover paints the **icon box**, not the whole button | +| `RibbonMenu.svelte` | The dropdown panel; its look is global CSS, since its rows come from a caller's snippet | +| `Icon.svelte`, `icons.ts` | Path data on a 16-unit canvas. `pinnedStroke(size)` holds the painted stroke at ~1.5px on large glyphs and ~1.1px on small ones, so 14px and 28px icons read as one family | +| `menu.svelte.ts` | The one open-menu id, `anchored`, `clickOutside` | +| `selection.ts` | `saveRange` / `withRange` — a popover steals focus, so the range is replayed before the command | +| `fontList.svelte.ts` | Recent, web-safe and detected fonts, shared by every font picker | +| `controls/` | Font family box, font size box, style gallery | +| `tabs/` | One file per tab | + +Reading what the selection formats is **not** here: `utils/selectionFormat.ts` answers it +framework-free (`uniformFont`, `uniformFontSize`, `uniformMarkColor`, `uniformBlockAttr`), each +returning the shared value or `''` when the selection mixes two. + +## Tabs + +Fixed: **File** (a menu, not a backstage) · **Home** · **Insert** · **Layout** · **References** · +**Review** · **View**. Contextual, shown while the caret is in the object: **Table Design**, +**Table Layout**, **Picture Format**, **Shape Format** — a tab that disappears hands the strip +back to Home. + +The ribbon surfaces a good deal the engine already carried with nothing to reach it: change case, +underline and strikethrough line styles, Find and Replace as buttons, the page break, section +breaks, absolute left/right indent, the table-of-contents depth, cell margins, Save As. + +## What retiring classic would free + +Recorded so the option stays visible, not as a plan: `Toolbar.svelte` and +`ToolbarExpanded.svelte` (~3200 lines), the overlay island and its hand-rolled horizontal +scrollbar in `App.svelte` (~120 lines plus CSS), the `pointer-events` opt-ins, +`--toolbar-overlay-h`, the collapsed-toolbar re-fire shim, `loadToolbarExpanded` / +`saveToolbarExpanded`, the duplicate dialog mounts, and `ToolbarExpanded`'s own copies of the +selection readers. diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 84771f6..c876c1c 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -3,6 +3,10 @@ All Svelte UI. `Editor.svelte` mounts TipTap and owns pagination wiring, zoom, the floating toolbars and the header/footer layer. +Two chromes sit above the document and `chromeMode` (`storage/theme.ts`) picks which mounts: +the floating command island below, or `ribbon/` — see `docs/architecture/ribbon.md`. Both drive +the same editor through `activeEditor`/`activeTick`, and only one is mounted at a time. + ## Data flow ``` diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 58b7305..1827aa1 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -19,6 +19,7 @@ Both margins and orientation are passed into the ODT export so the exported docu - **Document:** `odf-editor-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `odf-editor-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `odf-editor-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. - **Theme:** `odf-editor-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`. - **Toolbar expanded:** `odf-editor-toolbar-expanded` — boolean string. +- **Chrome:** `odf-editor-chrome` — `'classic' | 'ribbon'`; absent = classic. - **Formatting marks:** `odf-editor-formatting-marks` — boolean string. - **Ruler:** `odf-editor-ruler` — boolean string; absent = on. - **Zoom:** `odf-editor-zoom` — integer percent. From 23f234a23731414c14438ab7315d019da07a6cf8 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 02:00:11 +0200 Subject: [PATCH 10/26] feat: the Paragraph and Tabs dialogs, and a picture's alt text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word puts the deep paragraph settings behind a dialog, and so do we — it is the only place the four text-flow attrs can be set: widow/orphan control, keep with next, keep lines together, page break before. All four already round-tripped with nothing to change them. The Tabs dialog types a stop exactly where the ruler can only drag one, and is the only way to give it a leader. Alt text becomes editable rather than just inherited from the file name. --- src/App.svelte | 1 + src/lib/components/ParagraphDialog.svelte | 213 ++++++++++++++++++ src/lib/components/TabsDialog.svelte | 170 ++++++++++++++ src/lib/components/ribbon/Ribbon.svelte | 15 +- .../components/ribbon/tabs/FrameTabs.svelte | 23 +- src/lib/components/ribbon/tabs/HomeTab.svelte | 5 +- .../components/ribbon/tabs/LayoutTab.svelte | 4 +- src/lib/i18n/locales/de.ts | 21 ++ src/lib/i18n/locales/en.ts | 21 ++ 9 files changed, 467 insertions(+), 6 deletions(-) create mode 100644 src/lib/components/ParagraphDialog.svelte create mode 100644 src/lib/components/TabsDialog.svelte diff --git a/src/App.svelte b/src/App.svelte index 01751f7..6b2004c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -838,6 +838,7 @@ {zoom} onZoom={setZoom} onDebugDump={import.meta.env.DEV ? handleDebugDump : undefined} + bind:tabIntervalCm bind:pageMargins bind:pageOrientation bind:pageFormat diff --git a/src/lib/components/ParagraphDialog.svelte b/src/lib/components/ParagraphDialog.svelte new file mode 100644 index 0000000..c0699c4 --- /dev/null +++ b/src/lib/components/ParagraphDialog.svelte @@ -0,0 +1,213 @@ + + + (open = false)} onclick={(e) => e.target === dialogEl && (open = false)} aria-label={t().paragraphDialog.title}> +
+

{t().paragraphDialog.title}

+ +
+ + +
+ + {#if pane === 'indents'} + + +
+ + + + + + +
+ + + {:else} +
+ {#each FLOW as f} + + {/each} +
+

{t().paragraphDialog.flowHint}

+ {/if} + +
+ + + +
+
+
+ + diff --git a/src/lib/components/TabsDialog.svelte b/src/lib/components/TabsDialog.svelte new file mode 100644 index 0000000..30d5865 --- /dev/null +++ b/src/lib/components/TabsDialog.svelte @@ -0,0 +1,170 @@ + + + (open = false)} onclick={(e) => e.target === dialogEl && (open = false)} aria-label={t().tabsDialog.title}> +
+

{t().tabsDialog.title}

+ +
+ {#if stops.length === 0} +

{t().tabsDialog.none}

+ {/if} + {#each stops as s (s.pos)} +
+ {fmt(s.pos)} cm + + + +
+ {/each} +
+ +
+ e.key === 'Enter' && add()} /> + + + +
+ + + +
+ + +
+
+
+ + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 52e159a..186eff9 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -13,6 +13,8 @@ import TableTabs from './tabs/TableTabs.svelte'; import FrameTabs from './tabs/FrameTabs.svelte'; import UiLanguagePicker from '../UiLanguagePicker.svelte'; + import ParagraphDialog from '../ParagraphDialog.svelte'; + import TabsDialog from '../TabsDialog.svelte'; import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte'; import { t } from '../../i18n/i18n.svelte'; import { withShortcut } from '../../i18n/shortcut'; @@ -24,6 +26,7 @@ import type { PageFormat } from '../../storage/pageFormat'; import type { HfZone } from '../../storage/headerFooter'; import type { DocumentLanguage } from '../../storage/documentLanguage'; + import { DEFAULT_TAB_INTERVAL_CM } from '../../storage/tabInterval'; let { editor, @@ -37,6 +40,7 @@ zoom = 100, onZoom, onDebugDump, + tabIntervalCm = $bindable(DEFAULT_TAB_INTERVAL_CM), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), @@ -63,6 +67,7 @@ zoom?: number; onZoom?: (value: number) => void; onDebugDump?: () => void; + tabIntervalCm?: number; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; @@ -122,6 +127,8 @@ }); let docNameSizerWidth = $state(0); + let paragraphDialogOpen = $state(false); + let tabsDialogOpen = $state(false); function run(fn?: () => void) { closeMenu(); @@ -254,11 +261,11 @@
{#if tab === 'home'} - + (paragraphDialogOpen = true)} /> {:else if tab === 'insert'} {:else if tab === 'layout'} - + (paragraphDialogOpen = true)} /> {:else if tab === 'references'} {:else if tab === 'review'} @@ -272,6 +279,7 @@ {editor} which={tab === 'pictureFormat' ? 'picture' : 'shape'} wrap={(frameAttrs?.wrap ?? 'inline') as never} + alt={(frameAttrs?.alt ?? '') as string} shapeKind={frameAttrs?.shapeKind as never} fillColor={frameAttrs?.fillColor as string | null} strokeColor={frameAttrs?.strokeColor as string | null} @@ -279,6 +287,9 @@ /> {/if}
+ + (tabsDialogOpen = true)} /> + diff --git a/src/lib/components/ribbon/tabs/HomeTab.svelte b/src/lib/components/ribbon/tabs/HomeTab.svelte index b7e7d90..a8692fd 100644 --- a/src/lib/components/ribbon/tabs/HomeTab.svelte +++ b/src/lib/components/ribbon/tabs/HomeTab.svelte @@ -25,12 +25,13 @@ import { withShortcut } from '../../../i18n/shortcut'; import { shortcutHint, type ShortcutId } from '../../../editor/shortcuts'; - let { editor, tick, showFormattingMarks = $bindable(false), onManageStyles, onFind }: { + let { editor, tick, showFormattingMarks = $bindable(false), onManageStyles, onFind, onParagraphDialog }: { editor: Editor | null; tick: number; showFormattingMarks?: boolean; onManageStyles?: (family: StyleFamily) => void; onFind?: (mode: 'find' | 'replace') => void; + onParagraphDialog?: () => void; } = $props(); let sheet = $derived(styleSheet()); @@ -240,7 +241,7 @@
- +
diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte index 8d0dc12..383848e 100644 --- a/src/lib/components/ribbon/tabs/LayoutTab.svelte +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -17,6 +17,7 @@ pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable('portrait'), pageFormat = $bindable('A4'), + onParagraphDialog, }: { editor: Editor | null; tick: number; @@ -24,6 +25,7 @@ pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; + onParagraphDialog?: () => void; } = $props(); const FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[]; @@ -195,7 +197,7 @@ - +
{#if pane === 'indents'} - -
- + + + - - - -
- + + + + +
{:else}
{#each FLOW as f} @@ -139,7 +141,7 @@ display: flex; flex-direction: column; gap: 12px; - width: 420px; + width: 500px; padding: 18px 20px 16px; font-family: var(--font-sans); font-size: 0.85rem; @@ -165,14 +167,18 @@ .tabs button.active { color: var(--color-text); border-bottom-color: var(--color-primary); } - .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; } + .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; } + + /* Equal columns plus a fixed unit width put every field on the same edge; the + label takes the rest and never wraps, so no row grows a second line. */ + .newline { grid-column: 1; } .row { display: flex; align-items: center; gap: 8px; } - .row > span { flex: 1; color: var(--color-text-muted); } - .row em { font-style: normal; color: var(--color-text-muted); } + .row > span { flex: 1; white-space: nowrap; color: var(--color-text-muted); } + .row em { width: 1.5em; font-style: normal; color: var(--color-text-muted); } .row input, .row select { - width: 72px; + width: 76px; height: 26px; border: 1px solid var(--color-border); border-radius: var(--radius); @@ -182,7 +188,8 @@ font: inherit; } - .row select { width: 130px; } + /* No unit of its own, so it reaches across the field and unit columns. */ + .row select { width: calc(84px + 1.5em); } .row input { text-align: right; } .flow { display: flex; flex-direction: column; gap: 8px; } diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 186eff9..5a7643e 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -307,7 +307,7 @@ --color-btn-hover: var(--w-hover); --font-sans: var(--w-font); --radius: 3px; - --toolbar-btn-size: 28px; + --toolbar-btn-size: 30px; flex-shrink: 0; background: var(--w-chrome); @@ -317,6 +317,34 @@ user-select: none; } + /* Those pickers frame their trigger for the classic toolbar's tinted island. A + ribbon row is borderless icon buttons, so the frame comes off and the hover + tint takes over — same 30px box as .rb-icon, so a row reads as one strip. */ + .ribbon :global(.color-split) { + border-color: transparent; + background: none; + } + + .ribbon :global(.color-split:hover) { + border-color: transparent; + background: var(--w-hover); + } + + .ribbon :global(.color-main) { + min-width: 28px; + padding: 0 4px; + } + + .ribbon :global(.color-chevron) { + width: 13px; + border-left: none; + color: var(--w-text-dim); + } + + .ribbon :global(.color-chevron:hover) { background: var(--w-pressed); } + + .ribbon :global(.bp-trigger) { height: 30px; } + .ribbon-tabs { display: flex; align-items: center; diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index 88d6cbd..413a63b 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -38,7 +38,7 @@ export const STROKED = { superscript: ['M2 12.5 7.5 5.5M2 5.5 7.5 12.5', 'M10.5 5.75a1.5 1.5 0 1 1 2.7.9L10.5 9.5h3'], subscript: ['M2 10.5 7.5 3.5M2 3.5 7.5 10.5', 'M10.5 9.75a1.5 1.5 0 1 1 2.7.9L10.5 13.5h3'], clearFormat: ['M5.5 3h7.5', 'M9.25 3 6.5 13', 'M2 9.5 5 12.5M5 9.5 2 12.5'], - changeCase: ['M1.5 10.5 4 4l2.5 6.5M2.4 8.6h3.2', 'M9 12.25c1.2 0 2-.7 2-1.7V8.4c0-1-.7-1.6-1.9-1.6-.9 0-1.6.3-2 .8M11 7v5.25'], + changeCase: ['M2.1 11.5 4.8 4.5l2.7 7M3.05 9.4h3.5', 'M11.8 11.5a2.15 2.15 0 1 0 0-4.3 2.15 2.15 0 0 0 0 4.3z', 'M13.95 7.2v4.3'], bulletList: ['M6 4h8.5M6 8h8.5M6 12h8.5', 'M2.5 5.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z', 'M2.5 9.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z', 'M2.5 13.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z'], orderedList: ['M6 4h8.5M6 8h8.5M6 12h8.5', 'M1.6 2.6h.9v2.6M1.4 5.2h1.6', 'M1.3 7.1c.2-.4.6-.5 1-.4.5.1.7.6.4 1L1.4 9.4h1.7', 'M1.4 10.9h1.5l-.9 1.1c.6 0 1 .3 1 .8s-.4.8-1 .8c-.4 0-.8-.1-1-.4'], @@ -52,7 +52,7 @@ export const STROKED = { lineSpacing: ['M6.5 4h8M6.5 8h8M6.5 12h8', 'M3 4.5 3 11.5', 'M1.6 5.9 3 4.5l1.4 1.4', 'M1.6 10.1 3 11.5l1.4-1.4'], pilcrow: ['M9 2.5v11', 'M12 2.5v11', 'M9 2.5H6.75a2.75 2.75 0 0 0 0 5.5H9'], borders: ['M2.25 2.25h11.5v11.5H2.25z', 'M8 2.25v11.5M2.25 8h11.5'], - highlighter: ['M5.5 9.5 10 5l3 3-4.5 4.5H5.5z', 'M9.25 3.25 12.75 6.75', 'M2.5 13.5h4'], + highlighter: ['M6.5 12.5 3.5 9.5 9.5 3.5 12.5 6.5z', 'M8 5 11 8'], shading: ['M4 8.5 8.5 4l4 4-4.5 4.5a1.4 1.4 0 0 1-2 0L4 10.5a1.4 1.4 0 0 1 0-2z', 'M6.5 2 8.5 4', 'M2.5 13.5h11'], rowAbove: ['M1.5 9h13v5.5h-13z', 'M1.5 11.75h13', 'M8 1.5v5M5.5 4h5'], diff --git a/src/styles/global.css b/src/styles/global.css index e27ce44..e0f2a79 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -394,7 +394,7 @@ .rb-combo { display: flex; align-items: stretch; - height: 28px; + height: 30px; border: 1px solid var(--w-border-strong); border-radius: 3px; background: var(--w-surface); From ba99f20cfcab763a8bfc6b8cfae8ae62c0ca169a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 13:13:32 +0200 Subject: [PATCH 13/26] fix: one baseline for the Font group's letter buttons Sub- and superscript were SVG paths whose X sat on its own baseline, two units apart from each other and a size larger than the B, I, U and S beside them. They are text now, in the same font and size, with the digit on a relative offset because a flex item ignores vertical-align. Clear formatting stays a path, redrawn upright with its T on the measured letter baseline. Ink top and baseline agree within 0.15px, measured in the browser. --- src/lib/components/ribbon/icons.ts | 4 +--- src/lib/components/ribbon/tabs/HomeTab.svelte | 13 +++++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index 413a63b..ef894ad 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -35,9 +35,7 @@ export const STROKED = { copy: ['M5.5 5.5h7.25v8.25H5.5z', 'M10.5 5.5V2.25H3.25V10.5H5.5'], paste: ['M4.25 3.25H3v10.5h10V3.25h-1.25', 'M6 2h4v2.25H6z', 'M5.5 7.75h5M5.5 10.5h3.5'], - superscript: ['M2 12.5 7.5 5.5M2 5.5 7.5 12.5', 'M10.5 5.75a1.5 1.5 0 1 1 2.7.9L10.5 9.5h3'], - subscript: ['M2 10.5 7.5 3.5M2 3.5 7.5 10.5', 'M10.5 9.75a1.5 1.5 0 1 1 2.7.9L10.5 13.5h3'], - clearFormat: ['M5.5 3h7.5', 'M9.25 3 6.5 13', 'M2 9.5 5 12.5M5 9.5 2 12.5'], + clearFormat: ['M6 4.15h7.5', 'M9.75 4.15v7.35', 'M2 8.5 5 11.5M5 8.5 2 11.5'], changeCase: ['M2.1 11.5 4.8 4.5l2.7 7M3.05 9.4h3.5', 'M11.8 11.5a2.15 2.15 0 1 0 0-4.3 2.15 2.15 0 0 0 0 4.3z', 'M13.95 7.2v4.3'], bulletList: ['M6 4h8.5M6 8h8.5M6 12h8.5', 'M2.5 5.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z', 'M2.5 9.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z', 'M2.5 13.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z'], diff --git a/src/lib/components/ribbon/tabs/HomeTab.svelte b/src/lib/components/ribbon/tabs/HomeTab.svelte index a8692fd..b8ca06d 100644 --- a/src/lib/components/ribbon/tabs/HomeTab.svelte +++ b/src/lib/components/ribbon/tabs/HomeTab.svelte @@ -210,8 +210,8 @@
{/if}
- - + + I{/snippet} {#snippet underlineIcon()}U{/snippet} {#snippet strikeIcon()}S{/snippet} +{#snippet subIcon()}X2{/snippet} +{#snippet superIcon()}X2{/snippet} {#snippet growIcon()}A▲{/snippet} {#snippet shrinkIcon()}A▼{/snippet} {#snippet fontColorIcon()}A{/snippet} @@ -378,6 +380,13 @@ .glyph-small { font-size: 12px; } .glyph-sup { font-size: 7px; margin-left: 1px; } + /* The digit rides a relative offset, not vertical-align, which a flex item + ignores: baseline alignment keeps the X on the row's baseline and the box + the same 15px as B, I, U and S, so all six letters sit on one line. */ + .glyph-script { position: relative; font-size: 9px; margin-left: 0.5px; } + .glyph-script.up { top: -4.5px; } + .glyph-script.down { top: 2px; } + .line-menu button { padding: 5px 12px; } .line-sample { text-underline-offset: 3px; } From 7db9cc96b30da4926b01ad03c8871c22c0bd8f9a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 13:35:07 +0200 Subject: [PATCH 14/26] fix: unclip the ribbon's picker panels and size their triggers Every reused picker anchors its panel to its own trigger, so the band clipped it: the table grid showed one row of eight. `pinPanels`, one action on the band, pins whatever panel mounts inside it, and panels now drop below the whole band instead of over the controls under them. Their triggers also kept the classic toolbar's compact box, so Table, Symbol and Date & time were half the size of the buttons beside them. `.rb-captioned` moves to global.css, replacing four copies, and gives the trigger the big button's 34px box, a 28px glyph and the ribbon's own hover and open tints. --- docs/architecture/ribbon.md | 9 ++- src/lib/components/ColorPicker.svelte | 5 +- src/lib/components/ribbon/Ribbon.svelte | 4 +- src/lib/components/ribbon/menu.svelte.ts | 49 +++++++++++++++- .../components/ribbon/tabs/FrameTabs.svelte | 15 +---- .../components/ribbon/tabs/InsertTab.svelte | 16 ------ .../components/ribbon/tabs/ReviewTab.svelte | 15 ----- .../components/ribbon/tabs/TableTabs.svelte | 19 +------ src/styles/global.css | 57 +++++++++++++++++++ 9 files changed, 123 insertions(+), 66 deletions(-) diff --git a/docs/architecture/ribbon.md b/docs/architecture/ribbon.md index 360fcf4..fb6fc37 100644 --- a/docs/architecture/ribbon.md +++ b/docs/architecture/ribbon.md @@ -49,7 +49,11 @@ biggest simplification here, and the reason two things below are necessary. - **A menu must leave the flow.** `.ribbon-body` clips its own overflow, so the `anchored` action (`ribbon/menu.svelte.ts`) switches a panel to `position: fixed` and pins it under its wrapper, - clamped inside the window. + clamped inside the window. Panels drop below the **whole band**, not below their own button — + inside it they would cover the controls and group labels underneath. +- **A reused picker anchors its panel itself**, and no prop says when it opened. `pinPanels`, + one action on the band, watches for a mounted absolutely-positioned node and pins that; the + ColorPicker places its own and only needs the band-aware top. - **Each wrapper names the menu it owns.** One shared open-menu id means every `clickOutside` sees every mousedown; without the id each wrapper would close a sibling's open menu, and the click on one of its rows would never land. @@ -63,10 +67,11 @@ biggest simplification here, and the reason two things below are necessary. | `RibbonButton.svelte` | `big` / `small` / `icon`, plus the split button. Hover paints the **icon box**, not the whole button | | `RibbonMenu.svelte` | The dropdown panel; its look is global CSS, since its rows come from a caller's snippet | | `Icon.svelte`, `icons.ts` | Path data on a 16-unit canvas. `pinnedStroke(size)` holds the painted stroke at ~1.5px on large glyphs and ~1.1px on small ones, so 14px and 28px icons read as one family | -| `menu.svelte.ts` | The one open-menu id, `anchored`, `clickOutside` | +| `menu.svelte.ts` | The one open-menu id, `anchored`, `pinPanels`, `clickOutside` | | `selection.ts` | `saveRange` / `withRange` — a popover steals focus, so the range is replayed before the command | | `fontList.svelte.ts` | Recent, web-safe and detected fonts, shared by every font picker | | `controls/` | Font family box, font size box, style gallery | +| `.rb-captioned` (global.css) | Wraps a reused picker standing alone in a group: its trigger grows to the big button's 34px box and 28px glyph, so a group of pickers and a group of buttons are the same size | | `tabs/` | One file per tab | Reading what the selection formats is **not** here: `utils/selectionFormat.ts` answers it diff --git a/src/lib/components/ColorPicker.svelte b/src/lib/components/ColorPicker.svelte index a526047..54000f4 100644 --- a/src/lib/components/ColorPicker.svelte +++ b/src/lib/components/ColorPicker.svelte @@ -86,7 +86,10 @@ async function placePanel() { const anchor = split?.getBoundingClientRect(); if (!anchor) return; - pos = { top: anchor.bottom + 3, left: anchor.left }; + // A ribbon panel drops below the whole band, or it would cover the controls + // under it. The classic toolbar has no band, so this finds nothing there. + const band = split?.closest('.ribbon-body')?.getBoundingClientRect(); + pos = { top: Math.max(anchor.bottom, band?.bottom ?? 0) + 3, left: anchor.left }; await tick(); const box = panel?.getBoundingClientRect(); if (!box) return; diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 5a7643e..d7ee389 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -15,7 +15,7 @@ import UiLanguagePicker from '../UiLanguagePicker.svelte'; import ParagraphDialog from '../ParagraphDialog.svelte'; import TabsDialog from '../TabsDialog.svelte'; - import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte'; + import { clickOutside, isMenuOpen, pinPanels, toggleMenu, closeMenu } from './menu.svelte'; import { t } from '../../i18n/i18n.svelte'; import { withShortcut } from '../../i18n/shortcut'; import { shortcutHint } from '../../editor/shortcuts'; @@ -259,7 +259,7 @@
-
+
{#if tab === 'home'} (paragraphDialogOpen = true)} /> {:else if tab === 'insert'} diff --git a/src/lib/components/ribbon/menu.svelte.ts b/src/lib/components/ribbon/menu.svelte.ts index 5780980..d3526a1 100644 --- a/src/lib/components/ribbon/menu.svelte.ts +++ b/src/lib/components/ribbon/menu.svelte.ts @@ -23,6 +23,13 @@ export function closeMenu(): void { current = null; } +// A panel drops below the whole band, not just below its own button: opened +// inside the band it would cover the controls and group labels underneath. +function dropTop(anchor: DOMRect, panel: HTMLElement): number { + const band = panel.closest('.ribbon-body')?.getBoundingClientRect(); + return Math.max(anchor.bottom, band?.bottom ?? 0) + 3; +} + // The ribbon band clips its own overflow, so a panel inside it has to leave the // flow: this pins the menu under its wrapper in viewport coordinates and keeps it // inside the window. Attach to the panel; its parent is the anchor. @@ -32,7 +39,7 @@ export function anchored(node: HTMLElement, align: 'left' | 'right' = 'left') { if (!anchor) return; const r = anchor.getBoundingClientRect(); node.style.position = 'fixed'; - node.style.top = `${r.bottom + 3}px`; + node.style.top = `${dropTop(r, node)}px`; if (align === 'right') { node.style.left = 'auto'; node.style.right = `${Math.max(8, window.innerWidth - r.right)}px`; @@ -52,6 +59,46 @@ export function anchored(node: HTMLElement, align: 'left' | 'right' = 'left') { }; } +// A reused picker positions its panel against its own trigger, so the band clips +// it to a sliver. Attach to the band: whatever panel opens inside gets the same +// pinning `anchored` gives the ribbon's own menus, with no prop threading. +export function pinPanels(node: HTMLElement) { + let panel: HTMLElement | null = null; + + function place() { + const anchor = panel?.isConnected ? panel.parentElement : null; + if (!panel || !anchor) return void (panel = null); + const r = anchor.getBoundingClientRect(); + panel.style.position = 'fixed'; + panel.style.right = 'auto'; + panel.style.top = `${dropTop(r, panel)}px`; + panel.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - panel.offsetWidth - 8))}px`; + } + + // Only what a picker just mounted, so a permanently absolute element in the + // band (the hidden file input) is never mistaken for a panel. + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const added of record.addedNodes) { + if (!(added instanceof HTMLElement)) continue; + if (getComputedStyle(added).position !== 'absolute') continue; + panel = added; + return place(); + } + } + }); + observer.observe(node, { childList: true, subtree: true }); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + return { + destroy() { + observer.disconnect(); + window.removeEventListener('resize', place); + window.removeEventListener('scroll', place, true); + }, + }; +} + // Closes the menu when a mousedown lands outside `node`. Attach to the element // wrapping a trigger and its panel, and name the menu it owns: every wrapper sees // every mousedown, so without the id each would close a sibling's open menu — and diff --git a/src/lib/components/ribbon/tabs/FrameTabs.svelte b/src/lib/components/ribbon/tabs/FrameTabs.svelte index ebab11a..31c35cf 100644 --- a/src/lib/components/ribbon/tabs/FrameTabs.svelte +++ b/src/lib/components/ribbon/tabs/FrameTabs.svelte @@ -118,24 +118,15 @@ diff --git a/src/lib/components/ribbon/tabs/TableTabs.svelte b/src/lib/components/ribbon/tabs/TableTabs.svelte index 6be7485..b44c400 100644 --- a/src/lib/components/ribbon/tabs/TableTabs.svelte +++ b/src/lib/components/ribbon/tabs/TableTabs.svelte @@ -270,26 +270,11 @@ .margin-field input:focus { outline: none; border-color: var(--w-accent); } .shade-glyph { - width: 14px; - height: 14px; + width: 24px; + height: 24px; border: 1px solid currentColor; border-radius: 2px; background: repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 3px); } - .rb-captioned { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - padding: 4px 7px 6px; - } - - .rb-caption { - font-family: var(--w-font); - font-size: 12px; - color: var(--w-text); - white-space: nowrap; - } diff --git a/src/styles/global.css b/src/styles/global.css index e0f2a79..850359d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -387,6 +387,63 @@ margin: 0 4px; } +/* The reused pickers style their trigger for the classic toolbar's tinted island: + a frame on hover, the accent as the open state. Beside a row of ribbon buttons + that reads as a different control, so both become the ribbon's own tints. */ +.ribbon .table-trigger:hover, +.ribbon .char-trigger:hover, +.ribbon .dt-trigger:hover, +.ribbon .tsp-trigger:hover, +.ribbon .bp-trigger:hover { border-color: transparent; } + +.ribbon .table-trigger[aria-pressed='true'], +.ribbon .char-trigger[aria-pressed='true'], +.ribbon .dt-trigger[aria-pressed='true'], +.ribbon .tsp-trigger[aria-pressed='true'], +.ribbon .bp-trigger[aria-pressed='true'] { + background: var(--w-active); + color: var(--w-text); +} + +/* A picker standing alone in a group is a labelled button, so its own compact + trigger grows to the big variant's box and icon size. The stroke is pinned to + the same 1.5px the ribbon's own icons paint at 28px. */ +.rb-captioned { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px 7px 6px; + --toolbar-btn-size: 34px; +} + +/* The first svg is the glyph; a second one is the trigger's own caret and keeps + the size every other caret in the ribbon has. */ +.rb-captioned > * > button > svg:first-of-type { width: 28px; height: 28px; } +.rb-captioned > * > button > svg:first-of-type :is(path, rect, line, circle, polyline) { stroke-width: 0.86; } +.rb-captioned .color-bar { width: 24px; } + +/* The spell-check picker is chromeless for the status bar; in a group it needs + the combo frame and the same box as the labelled buttons beside it. */ +.rb-captioned .lang-picker { + height: 34px; + padding: 0 6px; + border: 1px solid var(--w-border-strong); + border-radius: 3px; + background: var(--w-surface); +} + +.rb-captioned .lang-picker:hover, +.rb-captioned .lang-picker:focus-within { border-color: var(--w-accent); } + +.rb-caption { + font-family: var(--w-font); + font-size: 12px; + color: var(--w-text); + white-space: nowrap; +} + /* Combo box: a borderless input welded to a caret button, as in Word's font and size fields. */ .rb-combo-wrap { position: relative; } From ab7d8ddb01bf2227f0e6d3f1fc02c5d12472c330 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 13:40:12 +0200 Subject: [PATCH 15/26] fix: a page number that reads as a 1, and Word's stacked split button The glyph was a stem on a base with no flag, so it read as a down-tack rather than the digit it stands for. A caret welded to the right of a big button floated at icon height, beside the label instead of with it, on every menu button of the Layout tab. Big split buttons stack now, as in Word: the icon runs the command, the label and its caret open the menu. Measured: every label in a group still tops at 87px. --- src/lib/components/ribbon/RibbonButton.svelte | 24 +++++++++++++++---- src/lib/components/ribbon/icons.ts | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/lib/components/ribbon/RibbonButton.svelte b/src/lib/components/ribbon/RibbonButton.svelte index 4726620..e93b525 100644 --- a/src/lib/components/ribbon/RibbonButton.svelte +++ b/src/lib/components/ribbon/RibbonButton.svelte @@ -36,7 +36,7 @@ const ICON_SIZE = { big: 28, small: 14, icon: 20 } as const; -{#snippet face()} +{#snippet face(withLabel = true)} {#if content}{@render content()} {:else if icon}{/if} @@ -44,13 +44,14 @@ {/if} - {#if label && variant !== 'icon'}{label}{/if} + {#if withLabel && label && variant !== 'icon'}{label}{/if} {/snippet} {#if caret && onCaret} - + {@const stacked = variant === 'big'} + @@ -166,6 +168,20 @@ cursor: pointer; } + /* A big split button stacks, as in Word: the icon runs the command, the label + and its caret open the menu. Welded to the right the caret would float at + icon height, beside the label rather than with it. */ + .rb-split-col { flex-direction: column; } + .rb-split-col .rb-big { padding: 4px 7px 0; } + + .rb-split-col .rb-caret { + width: auto; + gap: 3px; + padding: 4px 7px 6px; + border-radius: 0 0 3px 3px; + color: var(--w-text); + } + .rb-caret:hover:not(:disabled) { background: var(--w-pressed); } .rb-caret:disabled { opacity: 0.4; cursor: default; } .rb-caret.active { background: var(--w-active); } diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index ef894ad..ee97929 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -95,7 +95,7 @@ export const STROKED = { crossRef: ['M2.5 3.5h6M2.5 6.5h4M2.5 9.5h5', 'M8.5 12.5h5M11 10l2.5 2.5L11 15'], header: ['M2 2.5h12v3H2z', 'M2 8h12M2 11h8'], footer: ['M2 10.5h12v3H2z', 'M2 5h12M2 8h8'], - pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.75 10.5h2.5', 'M8 5.5v5'], + pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'], formula: ['M9 3.5h4', 'M9.5 8h3', 'M2.5 13.5c1.6 0 2-.9 2.4-2.4L7.2 3.4C7.5 2.3 8 2 8.8 2', 'M2.5 6.5h4'], pageBreak: ['M2 4.5h12M2 11.5h12', 'M8 6.5v3', 'M6.4 8.2 8 9.8l1.6-1.6'], } as const; From fa2726a1df2d1c3597451edfe3f6493870c1a947 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 13:47:13 +0200 Subject: [PATCH 16/26] fix: Word's pi for the Equation button Word's Equation button is a pi, as its Symbol button beside it is an omega. Same canvas, baseline and weight as that omega, at font-size 20 rather than its 14: pi is lowercase, so at one size its x-height measures 1.44x smaller. --- src/lib/components/ribbon/icons.ts | 1 - src/lib/components/ribbon/tabs/InsertTab.svelte | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index ee97929..1b5144a 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -96,7 +96,6 @@ export const STROKED = { header: ['M2 2.5h12v3H2z', 'M2 8h12M2 11h8'], footer: ['M2 10.5h12v3H2z', 'M2 5h12M2 8h8'], pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'], - formula: ['M9 3.5h4', 'M9.5 8h3', 'M2.5 13.5c1.6 0 2-.9 2.4-2.4L7.2 3.4C7.5 2.3 8 2 8.8 2', 'M2.5 6.5h4'], pageBreak: ['M2 4.5h12M2 11.5h12', 'M8 6.5v3', 'M6.4 8.2 8 9.8l1.6-1.6'], } as const; diff --git a/src/lib/components/ribbon/tabs/InsertTab.svelte b/src/lib/components/ribbon/tabs/InsertTab.svelte index 465cdec..200dd68 100644 --- a/src/lib/components/ribbon/tabs/InsertTab.svelte +++ b/src/lib/components/ribbon/tabs/InsertTab.svelte @@ -276,7 +276,7 @@ /> {t().ribbon.symbol}
- + + +{#snippet equationIcon()} + +{/snippet} + diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte index b124e36..0e7c0ee 100644 --- a/src/lib/components/ribbon/tabs/ReferencesTab.svelte +++ b/src/lib/components/ribbon/tabs/ReferencesTab.svelte @@ -48,9 +48,8 @@ title={t().ribbon.tocOptions} disabled={!toc} caret - caretActive={isMenuOpen('tocLevels')} + active={isMenuOpen('tocLevels')} onclick={() => toggleMenu('tocLevels')} - onCaret={() => toggleMenu('tocLevels')} /> {#if isMenuOpen('tocLevels')}