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.
This commit is contained in:
1 parent
ba99f20cfc
commit
7db9cc96b3
9 files changed
+123
-66
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
<UiLanguagePicker />
|
||||
</div>
|
||||
|
||||
<div class="ribbon-body">
|
||||
<div class="ribbon-body" use:pinPanels>
|
||||
{#if tab === 'home'}
|
||||
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} onParagraphDialog={() => (paragraphDialogOpen = true)} />
|
||||
{:else if tab === 'insert'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,24 +118,15 @@
|
||||
|
||||
<style>
|
||||
.swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--w-border-strong);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.swatch.outline { background: transparent; border-width: 2px; }
|
||||
|
||||
.rb-captioned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 7px 6px;
|
||||
}
|
||||
|
||||
.rb-caption, .field {
|
||||
.field {
|
||||
font-family: var(--w-font);
|
||||
font-size: 12px;
|
||||
color: var(--w-text);
|
||||
|
||||
@@ -300,22 +300,6 @@
|
||||
|
||||
.link-anchor { position: relative; }
|
||||
|
||||
/* A reused picker draws its own compact trigger, so the caption is what makes
|
||||
it read like the labelled buttons beside it. */
|
||||
.rb-captioned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 7px 6px;
|
||||
}
|
||||
|
||||
.rb-caption {
|
||||
font-family: var(--w-font);
|
||||
font-size: 12px;
|
||||
color: var(--w-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rb-menu-wrap { position: relative; }
|
||||
|
||||
.file-input {
|
||||
|
||||
@@ -87,19 +87,4 @@
|
||||
|
||||
.stat b { color: var(--w-text); }
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user