feat: a Word-style ribbon can take the toolbar's place

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.
This commit is contained in:
Steffen Becker committed 2026-08-12 01:20:47 +02:00
1 parent b98cc550c3
commit 2aeac7a574
13 files changed
+936 -5

No files matched your search

+46 -3
View File
@@ -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 @@
}
</script>
<main style="--toolbar-overlay-h: {toolbarRegionH}px">
<!-- The ribbon docks in flow, so only the classic island needs the editor's top
padding to clear an overlay. -->
<main style="--toolbar-overlay-h: {chromeMode === 'ribbon' ? 0 : toolbarRegionH}px">
{#snippet toolbarScrollbar()}
{#if tbOverflow > 0}
<div
@@ -815,6 +824,30 @@
</div>
{/if}
{/snippet}
{#if chromeMode === 'ribbon'}
<div bind:clientHeight={toolbarRegionH}>
<Ribbon
editor={activeEditor}
tick={activeTick}
bind:chromeMode
bind:documentName
{namePlaceholder}
{themeMode}
onSelectTheme={selectTheme}
{docxBusy}
{pdfBusy}
onNew={handleNew}
onOpen={handleOpen}
onSave={handleSave}
onSaveAs={handleSaveAs}
onSaveDocx={handleSaveDocx}
onExportPdf={handleExportPdf}
onPrintPdf={handlePrintPdf}
onPrint={handlePrint}
onAbout={() => (aboutOpen = true)}
/>
</div>
{:else}
<div class="toolbar-region" bind:clientHeight={toolbarRegionH}>
<div class="toolbar-clip" id="primary-toolbar" bind:this={toolbarClipEl} onwheel={onToolbarWheel}>
<div class="toolbar-stack" bind:this={toolbarStackEl} style="transform: translateX(-{tbScroll}px);">
@@ -973,6 +1006,15 @@
{/if}
</button>
{/each}
<div class="theme-heading">{t().ribbon.chrome.title}</div>
<button
class="theme-option"
onclick={() => { chromeMode = 'ribbon'; themeOpen = false; }}
role="menuitem"
>
<span>{t().ribbon.chrome.ribbon}</span>
<span class="theme-option-hint">{t().ribbon.chrome.ribbonHint}</span>
</button>
</div>
{/if}
</div>
@@ -1036,6 +1078,7 @@
</div>
</div>
</div>
{/if}
<EditorComponent
bind:editor
bind:tick
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { STROKED, FILLED, pinnedStroke, type IconName } from './icons';
let { name, size = 20 }: { name: IconName; size?: number } = $props();
let stroked = $derived((STROKED as Record<string, readonly string[]>)[name] ?? []);
let filled = $derived((FILLED as Record<string, readonly string[]>)[name] ?? []);
</script>
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width={pinnedStroke(size)}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#each stroked as d}<path {d} />{/each}
{#each filled as d}<path {d} fill="currentColor" stroke="none" />{/each}
</svg>
+352
View File
@@ -0,0 +1,352 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import Icon from './Icon.svelte';
import RibbonMenu from './RibbonMenu.svelte';
import HistoryButton from '../HistoryButton.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';
let {
editor,
tick,
chromeMode = $bindable<ChromeMode>('ribbon'),
documentName = $bindable(''),
namePlaceholder = '',
themeMode = 'auto',
onSelectTheme,
docxBusy = false,
pdfBusy = false,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout,
}: {
editor: Editor | null;
tick: number;
chromeMode?: ChromeMode;
documentName?: string;
namePlaceholder?: string;
themeMode?: ThemeMode;
onSelectTheme?: (mode: ThemeMode) => void;
docxBusy?: boolean;
pdfBusy?: boolean;
onNew?: () => void;
onOpen?: () => void;
onSave?: () => void;
onSaveAs?: () => void;
onSaveDocx?: () => void;
onExportPdf?: () => void;
onPrintPdf?: () => void;
onPrint?: () => void;
onAbout?: () => void;
} = $props();
const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const;
type Tab = (typeof TABS)[number];
// Word opens on Home every time, so the active tab is not persisted.
let tab = $state<Tab>('home');
let docNameSizerWidth = $state(0);
function run(fn?: () => void) {
closeMenu();
fn?.();
}
</script>
<div class="ribbon">
<div class="ribbon-tabs">
<div class="file-tab-wrap" use:clickOutside>
<button
class="ribbon-tab-file"
class:open={isMenuOpen('file')}
onclick={() => toggleMenu('file')}
aria-haspopup="menu"
aria-expanded={isMenuOpen('file')}
>
{t().ribbon.tabs.file}
</button>
{#if isMenuOpen('file')}
<RibbonMenu minWidth={230}>
<button onclick={() => run(onNew)} disabled={!editor}>
<Icon name="newDoc" size={16} />{t().app.newDocument}
</button>
<button onclick={() => run(onOpen)} disabled={!editor}>
<Icon name="folder" size={16} />{t().app.openOdt}
<span class="menu-key">{shortcutHint('open')}</span>
</button>
<hr />
<button onclick={() => run(onSave)} disabled={!editor || pdfBusy}>
<Icon name="save" size={16} />{t().app.save}
<span class="menu-key">{withShortcut('Ctrl+S')}</span>
</button>
<button onclick={() => run(onSaveAs)} disabled={!editor || pdfBusy}>
<Icon name="save" size={16} />{t().ribbon.saveAs}
</button>
<hr />
<button onclick={() => run(onSaveDocx)} disabled={docxBusy}>
<Icon name="export" size={16} />{docxBusy ? t().app.exporting : t().app.wordDocx}
<span class="menu-sub">{t().app.microsoftWord}</span>
</button>
<button onclick={() => run(onExportPdf)} disabled={pdfBusy}>
<Icon name="export" size={16} />{pdfBusy ? t().app.exporting : t().app.rasterPdf}
<span class="menu-sub">{t().app.rasterHint}</span>
</button>
<button onclick={() => run(onPrintPdf)}>
<Icon name="export" size={16} />{t().app.vectorPdf}
<span class="menu-sub">{t().app.vectorHint}</span>
</button>
<hr />
<button onclick={() => run(onPrint)} disabled={!editor || pdfBusy}>
<Icon name="print" size={16} />{t().app.print}
<span class="menu-key">{withShortcut('Ctrl+P')}</span>
</button>
<button onclick={() => run(onAbout)}>
<Icon name="info" size={16} />{t().about.label}
</button>
</RibbonMenu>
{/if}
</div>
<button class="qa-btn" onclick={onSave} disabled={!editor || pdfBusy} title={`${t().app.save} (${withShortcut('Ctrl+S')})`}>
<Icon name="save" size={16} />
</button>
<HistoryButton {editor} {tick} direction="undo" />
<HistoryButton {editor} {tick} direction="redo" />
<span class="qa-sep"></span>
{#each TABS as id}
<button class="ribbon-tab" class:active={tab === id} onclick={() => (tab = id)}>
{t().ribbon.tabs[id]}
</button>
{/each}
<span class="ribbon-tabs-spacer"></span>
<div class="doc-name">
<span class="doc-name-sizer" aria-hidden="true" bind:clientWidth={docNameSizerWidth}>{documentName || namePlaceholder}</span>
<input
class="doc-name-input"
type="text"
style="width: {docNameSizerWidth + 14}px"
bind:value={documentName}
placeholder={namePlaceholder}
title={t().app.documentName}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
onblur={() => (documentName = documentName.trim())}
/>
<span class="doc-name-ext">.odt</span>
</div>
<div class="appearance-wrap" use:clickOutside>
<button
class="qa-btn"
class:open={isMenuOpen('appearance')}
onclick={() => toggleMenu('appearance')}
title={t().appearance.title}
aria-haspopup="menu"
aria-expanded={isMenuOpen('appearance')}
>
<Icon name="ribbon" size={16} />
</button>
{#if isMenuOpen('appearance')}
<RibbonMenu align="right" minWidth={220} heading={t().ribbon.chrome.title}>
<button class:selected={chromeMode === 'ribbon'} onclick={() => { chromeMode = 'ribbon'; closeMenu(); }}>
{t().ribbon.chrome.ribbon}<span class="menu-sub">{t().ribbon.chrome.ribbonHint}</span>
</button>
<button class:selected={chromeMode === 'classic'} onclick={() => { chromeMode = 'classic'; closeMenu(); }}>
{t().ribbon.chrome.classic}<span class="menu-sub">{t().ribbon.chrome.classicHint}</span>
</button>
<hr />
<div class="menu-heading">{t().appearance.title}</div>
{#each (['light', 'dark', 'allBlack', 'auto'] as const) as m}
<button class:selected={themeMode === m} onclick={() => { onSelectTheme?.(m); closeMenu(); }}>
{t().appearance[m]}
{#if m === 'allBlack'}<span class="menu-sub">{t().appearance.allBlackHint}</span>{/if}
</button>
{/each}
</RibbonMenu>
{/if}
</div>
<UiLanguagePicker />
</div>
<div class="ribbon-body">
{#if tab === 'home'}
<!-- tabs land here phase by phase -->
{/if}
</div>
</div>
<style>
/* Word's palette reaches the reused pickers (ColorPicker, HistoryButton, the
table pickers …) by remapping the app's own tokens here: custom properties
inherit, so those components adopt the ribbon's look with no edits, and the
classic toolbar keeps the themed set. */
.ribbon {
--color-surface: var(--w-surface);
--color-toolbar-bg: var(--w-chrome);
--color-border: var(--w-border);
--color-text: var(--w-text);
--color-text-muted: var(--w-text-dim);
--color-primary: var(--w-accent);
--color-btn-hover: var(--w-hover);
--font-sans: var(--w-font);
--radius: 3px;
--toolbar-btn-size: 28px;
flex-shrink: 0;
background: var(--w-chrome);
border-bottom: 1px solid var(--w-border-strong);
font-family: var(--w-font);
color: var(--w-text);
user-select: none;
}
.ribbon-tabs {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 10px 0;
}
.ribbon-tab {
position: relative;
border: none;
background: none;
padding: 7px 9px 6px;
border-radius: 4px 4px 0 0;
color: var(--w-text);
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ribbon-tab:hover { color: var(--w-accent); }
.ribbon-tab.active { color: var(--w-accent); }
/* Word's underline is inset from the tab's edges, not full width. */
.ribbon-tab.active::after {
content: '';
position: absolute;
left: 13px;
right: 13px;
bottom: -2.5px;
height: 2.5px;
border-radius: 1px;
background: var(--w-accent);
}
.file-tab-wrap {
position: relative;
}
.ribbon-tab-file {
border: none;
background: var(--w-accent);
color: #fff;
border-radius: 4px;
padding: 6px 16px;
margin-right: 6px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ribbon-tab-file:hover,
.ribbon-tab-file.open { background: var(--w-accent-dark); }
.qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 28px;
border: none;
background: none;
border-radius: 4px;
color: var(--w-text);
cursor: pointer;
}
.qa-btn:hover:not(:disabled),
.qa-btn.open { background: var(--w-hover); }
.qa-btn:disabled { color: var(--w-text-tertiary); cursor: default; }
.qa-sep {
width: 1px;
height: 16px;
margin: 0 6px;
background: var(--w-border-strong);
}
.ribbon-tabs-spacer { flex: 1; }
.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 {
display: inline-flex;
align-items: center;
max-width: 30%;
color: var(--w-text-dim);
font-size: 12px;
}
.doc-name-sizer {
position: absolute;
visibility: hidden;
white-space: pre;
font-size: 12px;
font-family: inherit;
}
.doc-name-input {
min-width: 2rem;
max-width: 16rem;
border: 1px solid transparent;
border-radius: 3px;
padding: 2px 4px;
background: none;
color: var(--w-text-dim);
font: inherit;
font-size: 12px;
text-align: right;
}
.doc-name-input:hover { border-color: var(--w-border-strong); }
.doc-name-input:focus { outline: none; border-color: var(--w-accent); color: var(--w-text); }
.doc-name-ext { margin-right: 6px; }
.ribbon-body {
display: flex;
align-items: stretch;
height: var(--w-ribbon-h);
padding: 6px 12px;
/* Fixed height, scroll instead of Word's responsive group collapsing — the
scrollbar is hidden so the band stays clean. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.ribbon-body::-webkit-scrollbar { display: none; }
</style>
@@ -0,0 +1,172 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
import type { IconName } from './icons';
// `caret` welds a second button to the right that opens a menu — Word's split
// button. Without `onCaret` the caret is decorative and the whole control opens.
let {
icon,
content,
label,
title,
variant = 'icon',
active = false,
disabled = false,
caret = false,
caretTitle,
caretActive = false,
onclick,
onCaret,
}: {
icon?: IconName;
content?: Snippet;
label?: string;
title?: string;
variant?: 'big' | 'small' | 'icon';
active?: boolean;
disabled?: boolean;
caret?: boolean;
caretTitle?: string;
caretActive?: boolean;
onclick?: () => void;
onCaret?: () => void;
} = $props();
const ICON_SIZE = { big: 28, small: 14, icon: 20 } as const;
</script>
{#snippet face()}
<span class="rb-face">
{#if content}{@render content()}
{:else if icon}<Icon name={icon} size={ICON_SIZE[variant]} />{/if}
{#if caret && !onCaret}
<Icon name="chevronDown" size={10} />
{/if}
</span>
{#if label && variant !== 'icon'}<span class="rb-label">{label}</span>{/if}
{/snippet}
{#if caret && onCaret}
<span class="rb-split" class:rb-split-active={active || caretActive}>
<button class="rb rb-{variant}" class:active {disabled} {title} {onclick}>
{@render face()}
</button>
<button
class="rb-caret"
class:active={caretActive}
{disabled}
title={caretTitle ?? title}
aria-haspopup="menu"
aria-expanded={caretActive}
onclick={onCaret}
>
<Icon name="chevronDown" size={10} />
</button>
</span>
{:else}
<button class="rb rb-{variant}" class:active {disabled} {title} {onclick}>
{@render face()}
</button>
{/if}
<style>
.rb {
display: flex;
align-items: center;
border: none;
background: none;
border-radius: 4px;
color: var(--w-text);
font-family: var(--w-font);
font-size: 12px;
white-space: nowrap;
cursor: pointer;
}
.rb:disabled {
opacity: 0.4;
cursor: default;
}
.rb-face {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
}
/* Icon over label. The hover tint paints the icon box, not the whole button —
Word's signature, and what keeps a labelled column from looking like a slab. */
.rb-big {
flex-direction: column;
gap: 4px;
padding: 4px 7px 6px;
}
.rb-big .rb-face {
align-self: stretch;
min-height: 28px;
flex: 1;
border-radius: 4px;
padding: 3px 4px;
margin: 0 -4px;
}
.rb-big:hover:not(:disabled) .rb-face { background: var(--w-hover); }
.rb-big:active:not(:disabled) .rb-face { background: var(--w-pressed); }
.rb-big.active .rb-face { background: var(--w-active); }
/* A row in a stacked mini-column (Cut / Copy under a big Paste). */
.rb-small {
gap: 5px;
border-radius: 3px;
padding: 3px 8px;
}
.rb-icon {
justify-content: center;
min-width: 28px;
height: 30px;
border-radius: 3px;
padding: 0 4px;
}
.rb-small:hover:not(:disabled),
.rb-icon:hover:not(:disabled) { background: var(--w-hover); }
.rb-small:active:not(:disabled),
.rb-icon:active:not(:disabled) { background: var(--w-pressed); }
.rb-small.active,
.rb-icon.active { background: var(--w-active); }
.rb-label {
color: inherit;
}
/* Split button: the two halves read as one control, so the hover outline sits on
the wrapper and each half only tints its own surface. */
.rb-split {
display: inline-flex;
align-items: stretch;
border-radius: 3px;
}
.rb-split:hover { background: var(--w-hover); }
.rb-split-active { background: var(--w-active); }
.rb-caret {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
border: none;
background: none;
border-radius: 0 3px 3px 0;
color: var(--w-text-dim);
cursor: pointer;
}
.rb-caret:hover:not(:disabled) { background: var(--w-pressed); }
.rb-caret:disabled { opacity: 0.4; cursor: default; }
.rb-caret.active { background: var(--w-active); }
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// `onLauncher` renders Word's ↘ corner arrow, which opens the group's dialog.
let { label, grow = false, onLauncher, launcherTitle, children }: {
label: string;
grow?: boolean;
onLauncher?: () => void;
launcherTitle?: string;
children: Snippet;
} = $props();
</script>
<div class="ribbon-group" class:grow>
<div class="ribbon-group-items">{@render children()}</div>
<div class="ribbon-group-foot">
<span class="ribbon-group-label">{label}</span>
{#if onLauncher}
<button class="ribbon-launcher" onclick={onLauncher} title={launcherTitle ?? label} aria-label={launcherTitle ?? label}>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" aria-hidden="true">
<path d="M8 1v7H1" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.6 6.6 3.2 3.2" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
</svg>
</button>
{/if}
</div>
</div>
<style>
.ribbon-group {
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 2px 4px 0;
}
.ribbon-group.grow {
flex: 1;
min-width: 0;
}
.ribbon-group-items {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
/* Word's labelled group footer; the ↘ launcher sits at its right edge. */
.ribbon-group-foot {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 15px;
}
.ribbon-group-label {
font-family: var(--w-font);
font-size: 11px;
color: var(--w-text-dim);
white-space: nowrap;
}
.ribbon-launcher {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: none;
background: none;
border-radius: 2px;
color: var(--w-text-dim);
cursor: pointer;
}
.ribbon-launcher:hover { background: var(--w-hover); color: var(--w-text); }
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Drops from the control that owns it; the wrapper must be position: relative.
// `align="right"` pins the right edges instead, for menus near the window edge.
let { align = 'left', minWidth = 180, heading, children }: {
align?: 'left' | 'right';
minWidth?: number;
heading?: string;
children: Snippet;
} = $props();
</script>
<div class="ribbon-menu" class:right={align === 'right'} style="min-width: {minWidth}px" role="menu">
{#if heading}<div class="ribbon-menu-heading">{heading}</div>{/if}
{@render children()}
</div>
<style>
.ribbon-menu {
position: absolute;
top: calc(100% + 3px);
left: 0;
z-index: 60;
display: flex;
flex-direction: column;
padding: 5px;
background: var(--w-surface);
border: 1px solid var(--w-border-strong);
border-radius: 6px;
box-shadow: var(--w-menu-shadow);
font-family: var(--w-font);
color: var(--w-text);
}
.ribbon-menu.right {
left: auto;
right: 0;
}
.ribbon-menu-heading {
padding: 4px 12px 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--w-text-tertiary);
user-select: none;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
// Ribbon glyphs on a 16-unit canvas, as bare path data so one <svg> 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;
}
+34
View File
@@ -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<string | null>(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); } };
}
+20
View File
@@ -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();
}
+24
View File
@@ -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',
+24
View File
@@ -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',
+13
View File
@@ -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';
}
+54 -2
View File
@@ -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"] {