feat: collapse the ribbon to its tab strip

A chevron in the tab strip drops the band, leaving the strip; persisted, so
it survives a reload. Clicking a tab expands again, since a collapsed strip
whose tabs do nothing is a trap.

Word puts that chevron in the band's corner. It rides the strip so the band
keeps its full width: a flex row can only reserve a column, never a corner.

Skipped: Word floats the band over the document until the next click lands
in the text.
This commit is contained in:
Steffen Becker committed 2026-08-12 19:51:35 +02:00
1 parent 33784b239f
commit c334b5368e
6 files changed
+48 -2

No files matched your search

+9
View File
@@ -53,6 +53,15 @@ The band's height never changes: it scrolls horizontally with a hidden scrollbar
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 chevron in the tab strip drops the band entirely, leaving the strip (`odf-editor-ribbon-collapsed`).
Clicking any tab brings it back — Word instead floats it over the document until the next click
lands in the text, which is the part not built.
Word puts that chevron in the band's bottom-right corner, and this one did too. Both ways of
reserving that corner failed: as a layer it covered whichever group's dialog launcher reached the
edge, and since the band scrolls, which one that is varies; in the flow it cost the band a column
at every height. A flex row cannot reserve width in part of its height, so it moved to the strip.
- **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. Panels drop below the **whole band**, not below their own button —
+25 -2
View File
@@ -19,7 +19,7 @@
import { t } from '../../i18n/i18n.svelte';
import { withShortcut } from '../../i18n/shortcut';
import { shortcutHint } from '../../editor/shortcuts';
import type { ChromeMode, ThemeMode } from '../../storage/theme';
import { loadRibbonCollapsed, saveRibbonCollapsed, type ChromeMode, type ThemeMode } from '../../storage/theme';
import type { StyleFamily } from '../../styles/styleSheet';
import { DEFAULT_MARGINS, type PageMargins } from '../../storage/pageMargins';
import type { Orientation } from '../../storage/pageOrientation';
@@ -130,6 +130,9 @@
let paragraphDialogOpen = $state(false);
let tabsDialogOpen = $state(false);
let collapsed = $state(loadRibbonCollapsed());
$effect(() => saveRibbonCollapsed(collapsed));
function run(fn?: () => void) {
closeMenu();
fn?.();
@@ -202,7 +205,7 @@
class="ribbon-tab"
class:active={tab === id}
class:contextual={!(TABS as readonly string[]).includes(id)}
onclick={() => (tab = id)}
onclick={() => { tab = id; collapsed = false; }}
>
{t().ribbon.tabs[id]}
</button>
@@ -225,6 +228,20 @@
<span class="doc-name-ext">.odt</span>
</div>
<!-- Word puts this chevron in the band's corner. Here it rides the strip: in
the corner it covered whichever group's dialog launcher reached the edge,
and reserving that corner costs the band a column at every height. -->
<button
class="qa-btn rb-collapse"
class:rb-collapse-up={!collapsed}
onclick={() => (collapsed = !collapsed)}
title={collapsed ? t().ribbon.expand : t().ribbon.collapse}
aria-label={collapsed ? t().ribbon.expand : t().ribbon.collapse}
aria-expanded={!collapsed}
>
<Icon name="chevronDown" size={16} />
</button>
<div class="appearance-wrap" use:clickOutside={'appearance'}>
<button
class="qa-btn"
@@ -259,6 +276,7 @@
<UiLanguagePicker />
</div>
{#if !collapsed}
<div class="ribbon-body" use:pinPanels>
{#if tab === 'home'}
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} onParagraphDialog={() => (paragraphDialogOpen = true)} />
@@ -287,6 +305,7 @@
/>
{/if}
</div>
{/if}
<ParagraphDialog bind:open={paragraphDialogOpen} {editor} {tick} onTabs={() => (tabsDialogOpen = true)} />
<TabsDialog bind:open={tabsDialogOpen} {editor} {tick} bind:tabIntervalCm />
@@ -319,6 +338,10 @@
user-select: none;
}
/* Points down to bring the band back, up to send it away. */
.rb-collapse :global(svg) { transition: transform 0.12s ease; }
.rb-collapse-up :global(svg) { transform: rotate(180deg); }
/* 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. */
+2
View File
@@ -474,6 +474,8 @@ const de: Messages = {
shapeFormat: 'Formformat',
},
saveAs: 'Speichern unter…',
collapse: 'Menüband minimieren',
expand: 'Menüband erweitern',
export: 'Exportieren',
find: 'Suchen',
replace: 'Ersetzen',
+2
View File
@@ -478,6 +478,8 @@ const en = {
shapeFormat: 'Shape Format',
},
saveAs: 'Save as…',
collapse: 'Collapse the ribbon',
expand: 'Expand the ribbon',
export: 'Export',
find: 'Find',
replace: 'Replace',
+1
View File
@@ -20,6 +20,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
- **Theme:** `odf-editor-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `odf-editor-toolbar-expanded` — boolean string.
- **Chrome:** `odf-editor-chrome` — `'classic' | 'ribbon'`; absent = classic.
- **Ribbon collapsed:** `odf-editor-ribbon-collapsed` — boolean string; absent = expanded.
- **Formatting marks:** `odf-editor-formatting-marks` — boolean string.
- **Ruler:** `odf-editor-ruler` — boolean string; absent = on.
- **Zoom:** `odf-editor-zoom` — integer percent.
+9
View File
@@ -7,6 +7,7 @@ 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 RIBBON_COLLAPSED_KEY = 'odf-editor-ribbon-collapsed';
const FORMATTING_MARKS_KEY = 'odf-editor-formatting-marks';
const RULER_KEY = 'odf-editor-ruler';
@@ -36,6 +37,14 @@ export function saveChromeMode(mode: ChromeMode): void {
localStorage.setItem(CHROME_KEY, mode);
}
export function loadRibbonCollapsed(): boolean {
return localStorage.getItem(RIBBON_COLLAPSED_KEY) === 'true';
}
export function saveRibbonCollapsed(collapsed: boolean): void {
localStorage.setItem(RIBBON_COLLAPSED_KEY, String(collapsed));
}
export function loadFormattingMarks(): boolean {
return localStorage.getItem(FORMATTING_MARKS_KEY) === 'true';
}