From 7db9cc96b30da4926b01ad03c8871c22c0bd8f9a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 12 Aug 2026 13:35:07 +0200 Subject: [PATCH] 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; }