fix(editor): the toolbar strip's label collapse is measured too, and each layout is judged by its own widths
Follow-up to 534b2ff. Dropping the strip to its own bar was now measured,
but two things still produced a wrapped bar on the XIAO example at 1440
with the chat docked:
- The first collapse tier (Serial / Camera / Mic / Scope / Add / Libraries
labels -> icons) was still a fixed 1010px container query, so on the
1060px own-bar the labels stayed and the bar wrapped in two. That tier is
now the unified-toolbar--compact class, set by the same measurement:
labels on the brand row if they fit, icons on the brand row if those fit,
else the own bar — labelled if the full bar holds it, icons otherwise.
The finer tiers (board pill 905, count/zoom 835, view toggle 760/1140)
stay as container queries.
- Each candidate layout is measured IN that layout: the zones carry their
own container queries (the view-mode toggle hides on a narrow row and
reappears on the wide bar), so a width read on the brand row said the
bar would fit when it would not.
This commit is contained in:
parent
534b2ff21b
commit
b9f663f02e
|
|
@ -1,53 +1,111 @@
|
||||||
/**
|
/**
|
||||||
* Whether the editor toolbar strip fits on the header's brand row.
|
* Where the editor toolbar strip goes: on the header's brand row or on its
|
||||||
|
* own bar below, labelled or icon-only.
|
||||||
*
|
*
|
||||||
* The complaint this fixes: with the AI chat docked at 1440px, a board with
|
* The complaint this fixes: with the AI chat docked at 1440px, a board with
|
||||||
* many canvas controls (XIAO: display + camera + mic + scope) wrapped the
|
* many canvas controls (XIAO: display + camera + mic + scope) wrapped the
|
||||||
* strip INSIDE the header — canvas controls orphaned on a second line next
|
* strip INSIDE the header — canvas controls orphaned on a second line next
|
||||||
* to a black hole — because the drop-below fallback fired on guessed media
|
* to a black hole — because the drop-below fallback and the label collapse
|
||||||
* query thresholds instead of the strip's real width. These lock the rule:
|
* fired on guessed thresholds instead of the strip's real width. These
|
||||||
* needed width vs available width, nothing else.
|
* lock the rule: measured needed width vs measured available width, one
|
||||||
|
* row preferred over two, labels preferred over icons.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
stripAvailableWidth,
|
decideStripLayout,
|
||||||
stripMustDropBelow,
|
stripAvailableBelow,
|
||||||
|
stripAvailableInline,
|
||||||
stripNeededWidth,
|
stripNeededWidth,
|
||||||
} from '../components/layout/headerStripFit';
|
} from '../components/layout/headerStripFit';
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
contentWidth: 1044, // 1440 minus 16px header padding minus 380px docked chat
|
contentWidth: 1044, // 1440 minus 16px header padding minus 380px docked chat
|
||||||
leftWidth: 370, // brand + File/Edit/View/Account/Help
|
leftWidth: 352, // brand + File/Edit/View/Account/Help
|
||||||
hostMarginX: 20, // .header-editor-toolbar { margin: 0 10px }
|
hostMarginXInline: 20, // .header-editor-toolbar { margin: 0 10px }
|
||||||
|
hostMarginXBelow: -32, // own bar bleeds into the header padding
|
||||||
stripPaddingX: 6, // in-header strip pads 6px right
|
stripPaddingX: 6, // in-header strip pads 6px right
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('header strip fit', () => {
|
// Zone widths per candidate layout: [view-mode toggle, editor zone, canvas
|
||||||
it('sums the zones plus the strip and host chrome', () => {
|
// controls]. On the narrow brand row the toggle is hidden by its own
|
||||||
expect(stripNeededWidth({ ...base, zoneWidths: [0, 382, 268] })).toBe(676);
|
// container query (0); on the wide own-bar it shows (139).
|
||||||
expect(stripAvailableWidth({ ...base, zoneWidths: [] })).toBe(674);
|
const uno = {
|
||||||
|
inlineZoneWidths: [0, 382, 268],
|
||||||
|
inlineCompactZoneWidths: [0, 350, 240],
|
||||||
|
belowZoneWidths: [139, 395, 268],
|
||||||
|
};
|
||||||
|
// Display + camera + mic + scope + add: ~640px of labelled canvas controls,
|
||||||
|
// ~344px icon-only; the editor zone carries "Libraries" (~530 / ~420).
|
||||||
|
const xiao = {
|
||||||
|
inlineZoneWidths: [0, 530, 640],
|
||||||
|
inlineCompactZoneWidths: [0, 420, 344],
|
||||||
|
belowZoneWidths: [139, 530, 640],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('header strip layout', () => {
|
||||||
|
it('sums the zones plus the strip padding; available subtracts brand + margins', () => {
|
||||||
|
const i = { ...base, ...uno };
|
||||||
|
expect(stripNeededWidth(i, 'inline')).toBe(656);
|
||||||
|
expect(stripNeededWidth(i, 'inline-compact')).toBe(596);
|
||||||
|
expect(stripNeededWidth(i, 'below')).toBe(808);
|
||||||
|
expect(stripAvailableInline(i)).toBe(672);
|
||||||
|
expect(stripAvailableBelow(i)).toBe(1076);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an Uno-sized strip on the brand row at 1440 with the chat docked', () => {
|
it('keeps an Uno-sized strip labelled on the brand row at 1440 with the chat docked', () => {
|
||||||
// 0 = the view-mode toggle hidden by its container query.
|
expect(decideStripLayout({ ...base, ...uno })).toEqual({ below: false, compact: false });
|
||||||
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 382, 266] })).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops a XIAO-sized strip below at the same width', () => {
|
it('goes icon-only on the brand row before dropping below', () => {
|
||||||
// Display + camera + mic + scope + add: ~344px of canvas controls.
|
// 1300px window with the chat: 904 content, 532 for the strip.
|
||||||
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 420, 344] })).toBe(true);
|
const i = { ...base, contentWidth: 904, ...uno };
|
||||||
|
expect(stripAvailableInline(i)).toBe(532);
|
||||||
|
// labelled 656 does not fit, compact 596 does not either -> below
|
||||||
|
expect(decideStripLayout(i)).toEqual({ below: true, compact: false });
|
||||||
|
// a slightly narrower compact strip fits inline -> one row, icons
|
||||||
|
expect(decideStripLayout({ ...i, inlineCompactZoneWidths: [0, 300, 220] })).toEqual({
|
||||||
|
below: false,
|
||||||
|
compact: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a XIAO-sized strip below AND compacts it when the full bar cannot hold the labels', () => {
|
||||||
|
// 1440 with the chat: 1076px bar, 1315px labelled on that bar -> compact.
|
||||||
|
expect(decideStripLayout({ ...base, ...xiao })).toEqual({ below: true, compact: true });
|
||||||
|
// The same board on a 1920 window with the chat: 1524 content ->
|
||||||
|
// 1152 inline (labels 1176 no, compact 770 yes) -> one row, icons.
|
||||||
|
expect(decideStripLayout({ ...base, contentWidth: 1524, ...xiao })).toEqual({
|
||||||
|
below: false,
|
||||||
|
compact: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges the own-bar by the widths the strip has THERE (the view toggle reappears)', () => {
|
||||||
|
// 1300 with the chat: 532 inline (no), 936 own-bar; measured on the brand
|
||||||
|
// row the strip would seem to fit the bar (656 <= 936) — but there the
|
||||||
|
// hidden view-mode toggle comes back and the bar wraps in two.
|
||||||
|
const i = { ...base, contentWidth: 904, ...uno, belowZoneWidths: [139, 530, 268] };
|
||||||
|
expect(decideStripLayout(i)).toEqual({ below: true, compact: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is decided by the real widths, not a viewport threshold', () => {
|
it('is decided by the real widths, not a viewport threshold', () => {
|
||||||
// Same 1000px window without the chat: the old media query forced two
|
// 1000px window without the chat: the old media query forced two rows
|
||||||
// rows below 1040px; a strip whose labels have collapsed fits fine.
|
// below 1040px; a strip whose labels fit stays on one row.
|
||||||
const noChat = { ...base, contentWidth: 968, leftWidth: 352 };
|
const noChat = { ...base, contentWidth: 968 };
|
||||||
expect(stripMustDropBelow({ ...noChat, zoneWidths: [0, 322, 268] })).toBe(false);
|
expect(
|
||||||
// ...and a wider one on the same window does not.
|
decideStripLayout({
|
||||||
expect(stripMustDropBelow({ ...noChat, zoneWidths: [139, 395, 268] })).toBe(true);
|
...noChat,
|
||||||
|
inlineZoneWidths: [0, 322, 268],
|
||||||
|
inlineCompactZoneWidths: [0, 300, 240],
|
||||||
|
belowZoneWidths: [139, 395, 268],
|
||||||
|
}),
|
||||||
|
).toEqual({ below: false, compact: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats an exact fit as fitting', () => {
|
it('treats an exact fit as fitting', () => {
|
||||||
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 380, 268] })).toBe(false);
|
expect(decideStripLayout({ ...base, ...uno, inlineZoneWidths: [0, 398, 268] })).toEqual({
|
||||||
|
below: false,
|
||||||
|
compact: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -173,16 +173,16 @@
|
||||||
|
|
||||||
/* Same two collapses when the toolbar rides inside the shared desktop bar
|
/* Same two collapses when the toolbar rides inside the shared desktop bar
|
||||||
(App.css turns the wrapper's own containment off there so the bar can
|
(App.css turns the wrapper's own containment off there so the bar can
|
||||||
wrap instead of overlap): key them to the BAR's width, at the thresholds
|
wrap instead of overlap). The Libraries label goes with the first tier —
|
||||||
where the canvas controls drop their labels (1010px) and the board
|
the `unified-toolbar--compact` class AppHeader sets when it measures that
|
||||||
selector shrinks (905px), so the whole row degrades in step. */
|
the labelled strip does not fit (see SimulatorCanvas.css) — and the board
|
||||||
@container unified-toolbar (max-width: 1010px) {
|
pill label with the bar's own 905px tier, so the whole row degrades in
|
||||||
.tb-libraries-label {
|
step. */
|
||||||
display: none;
|
.unified-toolbar.unified-toolbar--compact .tb-libraries-label {
|
||||||
}
|
display: none;
|
||||||
.tb-btn-libraries {
|
}
|
||||||
padding: 0 6px;
|
.unified-toolbar.unified-toolbar--compact .tb-btn-libraries {
|
||||||
}
|
padding: 0 6px;
|
||||||
}
|
}
|
||||||
@container unified-toolbar (max-width: 905px) {
|
@container unified-toolbar (max-width: 905px) {
|
||||||
.tb-board-pill-label {
|
.tb-board-pill-label {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
import { useLocalizedHref, useCurrentLocale } from '../../i18n/useLocalizedNavigate';
|
import { useLocalizedHref, useCurrentLocale } from '../../i18n/useLocalizedNavigate';
|
||||||
import { blogUrlFor } from '../../i18n/path';
|
import { blogUrlFor } from '../../i18n/path';
|
||||||
import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics';
|
import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics';
|
||||||
import { measureStripFit, stripMustDropBelow } from './headerStripFit';
|
import { applyStripLayout, STRIP_BELOW_CLASS } from './headerStripFit';
|
||||||
import './LanguageSwitcher.css';
|
import './LanguageSwitcher.css';
|
||||||
|
|
||||||
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
|
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
|
||||||
|
|
@ -41,28 +41,21 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
// Editor variant: does the toolbar strip fit on the brand row? Measured,
|
// Editor variant: where does the toolbar strip go — on the brand row or
|
||||||
// not guessed — see headerStripFit.ts. `app-header--strip-below` moves
|
// on its own bar below, labelled or icon-only? Measured, not guessed —
|
||||||
// it to a full-width second bar. Re-measured whenever the strip host,
|
// see headerStripFit.ts, which sets `app-header--strip-below` on the
|
||||||
// the brand block or a strip zone resizes (window, docked chat, board
|
// header and `unified-toolbar--compact` on the strip. Re-measured
|
||||||
// controls mounting, label collapses). The first measure runs before
|
// whenever the strip host, the brand block or a strip zone resizes
|
||||||
// paint; later ones are deferred a frame so toggling the class never
|
// (window, docked chat, board controls mounting). The first measure runs
|
||||||
// re-enters ResizeObserver delivery.
|
// before paint; later ones are deferred a frame so toggling the classes
|
||||||
|
// never re-enters ResizeObserver delivery.
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
const hasStrip = !!editorToolbar;
|
const hasStrip = !!editorToolbar;
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const header = headerRef.current;
|
const header = headerRef.current;
|
||||||
if (!header || !hasStrip) return;
|
if (!header || !hasStrip) return;
|
||||||
const CLS = 'app-header--strip-below';
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
// Always measure the INLINE layout — the question is "would it fit on
|
applyStripLayout(header);
|
||||||
// the brand row?", and the below-state has different host margins and
|
|
||||||
// wider (uncollapsed) labels. Removing the class, measuring and
|
|
||||||
// restoring it happens in one synchronous pass before the rendering
|
|
||||||
// step, so nothing paints and ResizeObserver sees no transient.
|
|
||||||
header.classList.remove(CLS);
|
|
||||||
const m = measureStripFit(header);
|
|
||||||
header.classList.toggle(CLS, !!m && stripMustDropBelow(m));
|
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
if (typeof ResizeObserver === 'undefined') return;
|
if (typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
@ -95,7 +88,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
mo?.disconnect();
|
mo?.disconnect();
|
||||||
if (raf) cancelAnimationFrame(raf);
|
if (raf) cancelAnimationFrame(raf);
|
||||||
header.classList.remove(CLS);
|
header.classList.remove(STRIP_BELOW_CLASS);
|
||||||
};
|
};
|
||||||
}, [hasStrip]);
|
}, [hasStrip]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* Does the editor toolbar strip fit on the header's first row?
|
* How the editor toolbar strip is laid out in the header: on the brand row
|
||||||
|
* or on its own full-width bar below it, and with labels or icon-only.
|
||||||
*
|
*
|
||||||
* The strip rides inside the header next to the brand + menus. When the
|
* The strip rides inside the header next to the brand + menus. When the
|
||||||
* window is small or the AI chat is docked, one row genuinely cannot hold
|
* window is small or the AI chat is docked, one row genuinely cannot hold
|
||||||
* it and it must drop to a full-width second bar. Deciding that with media
|
* it and it must drop to a full-width second bar; and before that, its
|
||||||
* queries meant guessing thresholds (1040 / 1060 / 1400px by chat state)
|
* labels (Serial / Camera / Mic / Scope / Add / Libraries) go icon-only.
|
||||||
* that were only right for one board: a XIAO with display + camera + mic +
|
* Deciding either with media / container queries meant guessing widths
|
||||||
* scope controls needs a wider strip than an Uno, so at 1440px with the
|
* (1040 / 1060 / 1400px by chat state; 1010px for the labels) that were
|
||||||
* chat docked the strip wrapped INSIDE the header instead — an orphan
|
* only right for one board: a XIAO with display + camera + mic + scope
|
||||||
* group of canvas controls floating in a tall header next to a black hole.
|
* needs a wider strip than an Uno, so at 1440px with the chat docked the
|
||||||
|
* strip wrapped INSIDE the header — an orphan group of canvas controls
|
||||||
|
* floating in a tall header next to a black hole — and, once dropped
|
||||||
|
* below, kept its labels at 1060px and wrapped that bar in two as well.
|
||||||
*
|
*
|
||||||
* So the header measures instead. Pure function over the DOM so it can be
|
* So the header measures instead. Pure decision over measured widths so it
|
||||||
* unit-tested; `AppHeader` wires it to a ResizeObserver.
|
* can be unit-tested; `AppHeader` wires it to a ResizeObserver.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface StripFitInput {
|
export interface StripFitInput {
|
||||||
|
|
@ -19,34 +23,81 @@ export interface StripFitInput {
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
/** Width of `.header-left` (brand + menus), pinned to the first row. */
|
/** Width of `.header-left` (brand + menus), pinned to the first row. */
|
||||||
leftWidth: number;
|
leftWidth: number;
|
||||||
/** Horizontal margins of the strip host (`.header-editor-toolbar`). */
|
/** Horizontal margins of the strip host (`.header-editor-toolbar`) on the
|
||||||
hostMarginX: number;
|
* brand row (positive) and as its own bar (negative: it bleeds into the
|
||||||
|
* header padding). */
|
||||||
|
hostMarginXInline: number;
|
||||||
|
hostMarginXBelow: number;
|
||||||
/** Horizontal padding of the strip itself (`.unified-toolbar`). */
|
/** Horizontal padding of the strip itself (`.unified-toolbar`). */
|
||||||
stripPaddingX: number;
|
stripPaddingX: number;
|
||||||
/** Natural width of each strip zone at its own content size. */
|
/** Natural width of each strip zone at its own content size, measured IN
|
||||||
zoneWidths: readonly number[];
|
* the candidate layout it is compared against — the zones carry their
|
||||||
|
* own container queries (the view-mode toggle hides under 760px, its
|
||||||
|
* labels under 1140px, the board pill under 905px), so the same strip
|
||||||
|
* is wider on the wide own-bar than on the narrow brand row. */
|
||||||
|
inlineZoneWidths: readonly number[];
|
||||||
|
inlineCompactZoneWidths: readonly number[];
|
||||||
|
belowZoneWidths: readonly number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Natural single-row width the strip needs. */
|
export interface StripLayout {
|
||||||
export function stripNeededWidth(i: StripFitInput): number {
|
/** The strip drops to a full-width bar under the brand row. */
|
||||||
return i.zoneWidths.reduce((a, b) => a + b, 0) + i.stripPaddingX + i.hostMarginX;
|
below: boolean;
|
||||||
|
/** The strip's first collapse tier (labels -> icons) is forced on. */
|
||||||
|
compact: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Width the first row can give the strip next to the brand + menus. */
|
const sum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0);
|
||||||
export function stripAvailableWidth(i: StripFitInput): number {
|
|
||||||
return i.contentWidth - i.leftWidth;
|
/** Natural single-row width the strip needs in a candidate layout. */
|
||||||
|
export function stripNeededWidth(
|
||||||
|
i: StripFitInput,
|
||||||
|
layout: 'inline' | 'inline-compact' | 'below' = 'inline',
|
||||||
|
): number {
|
||||||
|
const zones =
|
||||||
|
layout === 'inline'
|
||||||
|
? i.inlineZoneWidths
|
||||||
|
: layout === 'inline-compact'
|
||||||
|
? i.inlineCompactZoneWidths
|
||||||
|
: i.belowZoneWidths;
|
||||||
|
return sum(zones) + i.stripPaddingX;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the strip must drop below the brand row. */
|
/** Width the strip gets on the brand row next to the brand + menus. */
|
||||||
export function stripMustDropBelow(i: StripFitInput): boolean {
|
export function stripAvailableInline(i: StripFitInput): number {
|
||||||
return stripNeededWidth(i) > stripAvailableWidth(i);
|
return i.contentWidth - i.leftWidth - i.hostMarginXInline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Width the strip gets as its own bar. */
|
||||||
|
export function stripAvailableBelow(i: StripFitInput): number {
|
||||||
|
return i.contentWidth - i.hostMarginXBelow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measure the live header. Zones are measured at `flex: 0 0 auto` so a
|
* One row beats two: stay on the brand row with labels if they fit, else
|
||||||
* zone that flexbox has grown to fill the row (the editor zone) reports its
|
* icon-only if that fits; only then drop below — with labels if the full
|
||||||
* content width, not the width it happens to occupy — the styles are
|
* bar holds them, icon-only otherwise (past that the bar wraps, which is
|
||||||
* restored before returning, and the whole thing runs before paint.
|
* the phone regime).
|
||||||
|
*/
|
||||||
|
export function decideStripLayout(i: StripFitInput): StripLayout {
|
||||||
|
const inline = stripAvailableInline(i);
|
||||||
|
if (stripNeededWidth(i, 'inline') <= inline) return { below: false, compact: false };
|
||||||
|
if (stripNeededWidth(i, 'inline-compact') <= inline) return { below: false, compact: true };
|
||||||
|
return { below: true, compact: stripNeededWidth(i, 'below') > stripAvailableBelow(i) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STRIP_BELOW_CLASS = 'app-header--strip-below';
|
||||||
|
export const STRIP_COMPACT_CLASS = 'unified-toolbar--compact';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the live header in the three layouts the decision compares —
|
||||||
|
* brand-row with labels, brand-row compact, own-bar with labels — by
|
||||||
|
* toggling the two classes and reading widths, then restoring what was
|
||||||
|
* there. Zones are read at
|
||||||
|
* `flex: 0 0 auto` so a zone flexbox has grown to fill the row (the editor
|
||||||
|
* zone) reports its content width, not the width it happens to occupy.
|
||||||
|
* Everything runs synchronously before the rendering step: nothing paints
|
||||||
|
* in between and ResizeObserver never sees the transients.
|
||||||
*/
|
*/
|
||||||
export function measureStripFit(header: HTMLElement): StripFitInput | null {
|
export function measureStripFit(header: HTMLElement): StripFitInput | null {
|
||||||
const content = header.querySelector<HTMLElement>(':scope > .header-content');
|
const content = header.querySelector<HTMLElement>(':scope > .header-content');
|
||||||
|
|
@ -55,22 +106,64 @@ export function measureStripFit(header: HTMLElement): StripFitInput | null {
|
||||||
const strip = host?.firstElementChild as HTMLElement | null | undefined;
|
const strip = host?.firstElementChild as HTMLElement | null | undefined;
|
||||||
if (!content || !left || !host || !strip) return null;
|
if (!content || !left || !host || !strip) return null;
|
||||||
|
|
||||||
|
const hadBelow = header.classList.contains(STRIP_BELOW_CLASS);
|
||||||
|
const hadCompact = strip.classList.contains(STRIP_COMPACT_CLASS);
|
||||||
const zones = Array.from(strip.children) as HTMLElement[];
|
const zones = Array.from(strip.children) as HTMLElement[];
|
||||||
const saved = zones.map((z) => z.style.flex);
|
const savedFlex = zones.map((z) => z.style.flex);
|
||||||
for (const z of zones) z.style.flex = '0 0 auto';
|
|
||||||
const zoneWidths = zones.map((z) => z.getBoundingClientRect().width);
|
|
||||||
zones.forEach((z, k) => {
|
|
||||||
z.style.flex = saved[k];
|
|
||||||
});
|
|
||||||
|
|
||||||
const px = (v: string) => parseFloat(v) || 0;
|
const px = (v: string) => parseFloat(v) || 0;
|
||||||
const hostCs = getComputedStyle(host);
|
const marginX = (el: HTMLElement) => {
|
||||||
const stripCs = getComputedStyle(strip);
|
const cs = getComputedStyle(el);
|
||||||
return {
|
return px(cs.marginLeft) + px(cs.marginRight);
|
||||||
contentWidth: content.clientWidth,
|
|
||||||
leftWidth: left.getBoundingClientRect().width,
|
|
||||||
hostMarginX: px(hostCs.marginLeft) + px(hostCs.marginRight),
|
|
||||||
stripPaddingX: px(stripCs.paddingLeft) + px(stripCs.paddingRight),
|
|
||||||
zoneWidths,
|
|
||||||
};
|
};
|
||||||
|
const zoneWidths = () => zones.map((z) => z.getBoundingClientRect().width);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const z of zones) z.style.flex = '0 0 auto';
|
||||||
|
header.classList.remove(STRIP_BELOW_CLASS);
|
||||||
|
strip.classList.remove(STRIP_COMPACT_CLASS);
|
||||||
|
const inlineZoneWidths = zoneWidths();
|
||||||
|
const stripCs = getComputedStyle(strip);
|
||||||
|
const stripPaddingX = px(stripCs.paddingLeft) + px(stripCs.paddingRight);
|
||||||
|
const hostMarginXInline = marginX(host);
|
||||||
|
const contentWidth = content.clientWidth;
|
||||||
|
const leftWidth = left.getBoundingClientRect().width;
|
||||||
|
|
||||||
|
strip.classList.add(STRIP_COMPACT_CLASS);
|
||||||
|
const inlineCompactZoneWidths = zoneWidths();
|
||||||
|
|
||||||
|
strip.classList.remove(STRIP_COMPACT_CLASS);
|
||||||
|
header.classList.add(STRIP_BELOW_CLASS);
|
||||||
|
const belowZoneWidths = zoneWidths();
|
||||||
|
const hostMarginXBelow = marginX(host);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentWidth,
|
||||||
|
leftWidth,
|
||||||
|
hostMarginXInline,
|
||||||
|
hostMarginXBelow,
|
||||||
|
stripPaddingX,
|
||||||
|
inlineZoneWidths,
|
||||||
|
inlineCompactZoneWidths,
|
||||||
|
belowZoneWidths,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
zones.forEach((z, k) => {
|
||||||
|
z.style.flex = savedFlex[k];
|
||||||
|
});
|
||||||
|
header.classList.toggle(STRIP_BELOW_CLASS, hadBelow);
|
||||||
|
strip.classList.toggle(STRIP_COMPACT_CLASS, hadCompact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Measure and apply: the two classes end up matching the decision. */
|
||||||
|
export function applyStripLayout(header: HTMLElement): StripLayout | null {
|
||||||
|
const m = measureStripFit(header);
|
||||||
|
if (!m) return null;
|
||||||
|
const layout = decideStripLayout(m);
|
||||||
|
header.classList.toggle(STRIP_BELOW_CLASS, layout.below);
|
||||||
|
const strip = header.querySelector<HTMLElement>(
|
||||||
|
':scope > .header-content > .header-editor-toolbar > *',
|
||||||
|
);
|
||||||
|
strip?.classList.toggle(STRIP_COMPACT_CLASS, layout.compact);
|
||||||
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -446,44 +446,46 @@
|
||||||
mobile block does — but keyed to the bar's own width via a container query,
|
mobile block does — but keyed to the bar's own width via a container query,
|
||||||
not the viewport, so it triggers on a wide screen with the chat open too.
|
not the viewport, so it triggers on a wide screen with the chat open too.
|
||||||
Priority: labels -> icons, then drop the non-interactive count, then the
|
Priority: labels -> icons, then drop the non-interactive count, then the
|
||||||
zoom buttons (mouse-wheel zoom still works). Mirrors the thresholds the
|
zoom buttons (mouse-wheel zoom still works). The finer tiers below stay
|
||||||
editor toolbar and view-mode toggle collapse at, so the whole bar degrades
|
keyed to the bar's width; the FIRST tier (labels -> icons) is instead the
|
||||||
together and nothing overlaps. */
|
`unified-toolbar--compact` class, which AppHeader sets when it MEASURES
|
||||||
@container unified-toolbar (max-width: 1010px) {
|
that the labelled strip does not fit the row it is on (headerStripFit.ts)
|
||||||
|
— a fixed 1010px threshold was right for one board's set of controls
|
||||||
|
only: a XIAO with camera + mic + scope kept its labels at 1060px and
|
||||||
|
wrapped the bar into two rows. */
|
||||||
|
.unified-toolbar.unified-toolbar--compact .canvas-header-left,
|
||||||
|
.unified-toolbar.unified-toolbar--compact .canvas-header-right {
|
||||||
/* Pack the toolbar buttons tighter once the AI chat steals horizontal
|
/* Pack the toolbar buttons tighter once the AI chat steals horizontal
|
||||||
room, so Serial/Camera/Mic/Scope stop overlapping. */
|
room, so Serial/Camera/Mic/Scope stop overlapping. */
|
||||||
.canvas-header-left,
|
gap: 4px;
|
||||||
.canvas-header-right {
|
}
|
||||||
gap: 4px;
|
.unified-toolbar.unified-toolbar--compact .canvas-serial-btn {
|
||||||
}
|
padding: 0 8px;
|
||||||
.canvas-serial-btn {
|
font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */
|
||||||
padding: 0 8px;
|
}
|
||||||
font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */
|
.unified-toolbar.unified-toolbar--compact .canvas-serial-btn svg {
|
||||||
}
|
width: 18px;
|
||||||
.canvas-serial-btn svg {
|
height: 18px;
|
||||||
width: 18px;
|
}
|
||||||
height: 18px;
|
/* Same collapse as Serial: hide the "Camera" / "Mic" label, keep the icon.
|
||||||
}
|
gap: 0 drops the now-empty icon-to-label space. */
|
||||||
/* Same collapse as Serial: hide the "Camera" / "Mic" label, keep the icon.
|
.unified-toolbar.unified-toolbar--compact .canvas-cam-mic-btn {
|
||||||
gap: 0 drops the now-empty icon-to-label space. */
|
padding: 0 8px;
|
||||||
.canvas-cam-mic-btn {
|
gap: 0;
|
||||||
padding: 0 8px;
|
font-size: 0;
|
||||||
gap: 0;
|
}
|
||||||
font-size: 0;
|
.unified-toolbar.unified-toolbar--compact .add-component-btn {
|
||||||
}
|
padding: 0 9px;
|
||||||
.add-component-btn {
|
font-size: 0; /* hide "Add" text, keep the icon */
|
||||||
padding: 0 9px;
|
}
|
||||||
font-size: 0; /* hide "Add" text, keep the icon */
|
.unified-toolbar.unified-toolbar--compact .add-component-btn svg {
|
||||||
}
|
width: 18px;
|
||||||
.add-component-btn svg {
|
height: 18px;
|
||||||
width: 18px;
|
}
|
||||||
height: 18px;
|
.unified-toolbar.unified-toolbar--compact .board-selector {
|
||||||
}
|
max-width: 120px;
|
||||||
.board-selector {
|
text-overflow: ellipsis;
|
||||||
max-width: 120px;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@container unified-toolbar (max-width: 905px) {
|
@container unified-toolbar (max-width: 905px) {
|
||||||
.component-count {
|
.component-count {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue