From 2aeac7a574bc8f8e5cbacc7324d0e096c54c2eb9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 01:20:47 +0200 Subject: [PATCH] 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"] {