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; }