fix(editor): the header measures whether the toolbar strip fits instead of guessing by breakpoint
With the AI chat docked at 1440px, a board with many canvas controls (XIAO: display + camera + mic + scope) wrapped the strip INSIDE the header — the canvas controls orphaned on a second line next to a black hole — because the drop-to-own-bar fallback fired on media-query thresholds (1040 / 1060 / 1400px by chat state) that were only right for one board's strip width. AppHeader now measures: natural single-row width of the strip (each zone at its content width) against the room left next to the brand + menus, and sets app-header--strip-below when it does not fit. Always measured in the inline layout (class removed, measured, restored before paint) so the below-state's margins and uncollapsed labels never bias the decision, and there is nothing to oscillate. Re-measured via ResizeObserver on the strip host, the brand block and the zones (plus a MutationObserver for the portaled canvas controls), deferred a frame so a toggle never re-enters delivery. The three media-query blocks collapse into one class rule; a 1000px window without the chat now keeps one row when the collapsed strip fits, instead of being forced to two.
This commit is contained in:
parent
7fc131d4fb
commit
534b2ff21b
|
|
@ -566,63 +566,33 @@ body {
|
|||
height: 44px;
|
||||
}
|
||||
|
||||
/* ── Tight fallback for the in-header strip ──────────────────────────────
|
||||
/* ── Fallback for the in-header strip: its own full-width bar ───────────
|
||||
Between "fits in one row" and "phone" there is an awkward middle (e.g.
|
||||
1440px with the chat docked) where flex-wrap produced an orphan group of
|
||||
canvas controls floating inside a tall transparent header, next to a
|
||||
dead hole before the right-side controls. Below the width where one row
|
||||
GENUINELY fits — thresholds account for the docked chat's reserved
|
||||
380px / 36px — the strip drops back to being its own full-width second
|
||||
bar with its old skin: exactly the familiar pre-merge layout, which
|
||||
reads as intentional instead of broken. Three blocks because a media
|
||||
query cannot test a body class in its condition. */
|
||||
dead hole before the right edge. When one row GENUINELY cannot hold the
|
||||
strip, it drops back to being its own full-width second bar with its old
|
||||
skin: exactly the familiar pre-merge layout, which reads as intentional
|
||||
instead of broken.
|
||||
|
||||
@media (max-width: 1039.9px) {
|
||||
.app-header--with-toolbar .header-content {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.app-header--with-toolbar .header-editor-toolbar {
|
||||
flex-basis: 100%;
|
||||
order: 10;
|
||||
margin: 0 -16px;
|
||||
}
|
||||
.app-header--with-toolbar .header-editor-toolbar .unified-toolbar {
|
||||
background: #252526;
|
||||
border-top: 1px solid #333;
|
||||
min-height: 38px;
|
||||
}
|
||||
Whether it fits is MEASURED by AppHeader (headerStripFit.ts), which sets
|
||||
this class — not guessed from media queries. The old thresholds
|
||||
(1040 / 1060 / 1400px by chat state) were right for one board only: a
|
||||
XIAO with display + camera + mic + scope controls needs a wider strip
|
||||
than an Uno, and at 1440px with the chat docked it wrapped inside the
|
||||
header again. */
|
||||
.app-header--with-toolbar.app-header--strip-below .header-content {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1059.9px) {
|
||||
body.velxio-agent-collapsed .app-header--with-toolbar .header-content {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
body.velxio-agent-collapsed .app-header--with-toolbar .header-editor-toolbar {
|
||||
flex-basis: 100%;
|
||||
order: 10;
|
||||
margin: 0 -16px;
|
||||
}
|
||||
body.velxio-agent-collapsed .app-header--with-toolbar .header-editor-toolbar .unified-toolbar {
|
||||
background: #252526;
|
||||
border-top: 1px solid #333;
|
||||
min-height: 38px;
|
||||
}
|
||||
.app-header--with-toolbar.app-header--strip-below .header-editor-toolbar {
|
||||
flex-basis: 100%;
|
||||
order: 10;
|
||||
margin: 0 -16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1399.9px) {
|
||||
body.velxio-agent-open .app-header--with-toolbar .header-content {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
body.velxio-agent-open .app-header--with-toolbar .header-editor-toolbar {
|
||||
flex-basis: 100%;
|
||||
order: 10;
|
||||
margin: 0 -16px;
|
||||
}
|
||||
body.velxio-agent-open .app-header--with-toolbar .header-editor-toolbar .unified-toolbar {
|
||||
background: #252526;
|
||||
border-top: 1px solid #333;
|
||||
min-height: 38px;
|
||||
}
|
||||
.app-header--with-toolbar.app-header--strip-below .header-editor-toolbar .unified-toolbar {
|
||||
background: #252526;
|
||||
border-top: 1px solid #333;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
/* ── Editor corner box (bottom-left) ─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Whether the editor toolbar strip fits on the header's brand row.
|
||||
*
|
||||
* The complaint this fixes: with the AI chat docked at 1440px, a board with
|
||||
* many canvas controls (XIAO: display + camera + mic + scope) wrapped the
|
||||
* 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
|
||||
* query thresholds instead of the strip's real width. These lock the rule:
|
||||
* needed width vs available width, nothing else.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stripAvailableWidth,
|
||||
stripMustDropBelow,
|
||||
stripNeededWidth,
|
||||
} from '../components/layout/headerStripFit';
|
||||
|
||||
const base = {
|
||||
contentWidth: 1044, // 1440 minus 16px header padding minus 380px docked chat
|
||||
leftWidth: 370, // brand + File/Edit/View/Account/Help
|
||||
hostMarginX: 20, // .header-editor-toolbar { margin: 0 10px }
|
||||
stripPaddingX: 6, // in-header strip pads 6px right
|
||||
};
|
||||
|
||||
describe('header strip fit', () => {
|
||||
it('sums the zones plus the strip and host chrome', () => {
|
||||
expect(stripNeededWidth({ ...base, zoneWidths: [0, 382, 268] })).toBe(676);
|
||||
expect(stripAvailableWidth({ ...base, zoneWidths: [] })).toBe(674);
|
||||
});
|
||||
|
||||
it('keeps an Uno-sized strip on the brand row at 1440 with the chat docked', () => {
|
||||
// 0 = the view-mode toggle hidden by its container query.
|
||||
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 382, 266] })).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a XIAO-sized strip below at the same width', () => {
|
||||
// Display + camera + mic + scope + add: ~344px of canvas controls.
|
||||
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 420, 344] })).toBe(true);
|
||||
});
|
||||
|
||||
it('is decided by the real widths, not a viewport threshold', () => {
|
||||
// Same 1000px window without the chat: the old media query forced two
|
||||
// rows below 1040px; a strip whose labels have collapsed fits fine.
|
||||
const noChat = { ...base, contentWidth: 968, leftWidth: 352 };
|
||||
expect(stripMustDropBelow({ ...noChat, zoneWidths: [0, 322, 268] })).toBe(false);
|
||||
// ...and a wider one on the same window does not.
|
||||
expect(stripMustDropBelow({ ...noChat, zoneWidths: [139, 395, 268] })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an exact fit as fitting', () => {
|
||||
expect(stripMustDropBelow({ ...base, zoneWidths: [0, 380, 268] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useProjectStore } from '../../store/useProjectStore';
|
||||
|
|
@ -7,6 +7,7 @@ import { LanguageSwitcher } from './LanguageSwitcher';
|
|||
import { useLocalizedHref, useCurrentLocale } from '../../i18n/useLocalizedNavigate';
|
||||
import { blogUrlFor } from '../../i18n/path';
|
||||
import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics';
|
||||
import { measureStripFit, stripMustDropBelow } from './headerStripFit';
|
||||
import './LanguageSwitcher.css';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
|
||||
|
|
@ -40,6 +41,64 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
|
|||
setMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Editor variant: does the toolbar strip fit on the brand row? Measured,
|
||||
// not guessed — see headerStripFit.ts. `app-header--strip-below` moves
|
||||
// it to a full-width second bar. Re-measured whenever the strip host,
|
||||
// the brand block or a strip zone resizes (window, docked chat, board
|
||||
// controls mounting, label collapses). The first measure runs before
|
||||
// paint; later ones are deferred a frame so toggling the class never
|
||||
// re-enters ResizeObserver delivery.
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const hasStrip = !!editorToolbar;
|
||||
useLayoutEffect(() => {
|
||||
const header = headerRef.current;
|
||||
if (!header || !hasStrip) return;
|
||||
const CLS = 'app-header--strip-below';
|
||||
const apply = () => {
|
||||
// Always measure the INLINE layout — the question is "would it fit on
|
||||
// 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();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
let raf = 0;
|
||||
const schedule = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
apply();
|
||||
});
|
||||
};
|
||||
const ro = new ResizeObserver(schedule);
|
||||
const content = header.querySelector<HTMLElement>(':scope > .header-content');
|
||||
const left = content?.querySelector<HTMLElement>(':scope > .header-left');
|
||||
const host = content?.querySelector<HTMLElement>(':scope > .header-editor-toolbar');
|
||||
const strip = host?.firstElementChild;
|
||||
for (const el of [left, host, ...(strip ? Array.from(strip.children) : [])]) {
|
||||
if (el) ro.observe(el);
|
||||
}
|
||||
// Zones mount their controls later (the canvas side is a portal) —
|
||||
// pick up children that appear after mount.
|
||||
const mo = strip
|
||||
? new MutationObserver(() => {
|
||||
for (const z of Array.from(strip.children)) ro.observe(z);
|
||||
schedule();
|
||||
})
|
||||
: null;
|
||||
if (strip && mo) mo.observe(strip, { childList: true });
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
mo?.disconnect();
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
header.classList.remove(CLS);
|
||||
};
|
||||
}, [hasStrip]);
|
||||
|
||||
// Tauri desktop: skip the header entirely. The marketing nav was
|
||||
// already hidden, but the strip itself was still painting an empty
|
||||
// black bar over the editor. Brand/auto-save/share/auth-slot all
|
||||
|
|
@ -56,7 +115,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({ editorMenu, editorToolbar
|
|||
location.pathname === localize(path) ? ' header-nav-link-active' : '';
|
||||
|
||||
return (
|
||||
<header className={"app-header" + (editorToolbar ? ' app-header--with-toolbar' : '')}>
|
||||
<header ref={headerRef} className={"app-header" + (editorToolbar ? ' app-header--with-toolbar' : '')}>
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
{/* Brand */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Does the editor toolbar strip fit on the header's first row?
|
||||
*
|
||||
* 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
|
||||
* it and it must drop to a full-width second bar. Deciding that with media
|
||||
* queries meant guessing thresholds (1040 / 1060 / 1400px by chat state)
|
||||
* that were only right for one board: a XIAO with display + camera + mic +
|
||||
* scope controls needs a wider strip than an Uno, so at 1440px with the
|
||||
* chat docked the strip wrapped INSIDE the header instead — an orphan
|
||||
* group of canvas controls floating in a tall header next to a black hole.
|
||||
*
|
||||
* So the header measures instead. Pure function over the DOM so it can be
|
||||
* unit-tested; `AppHeader` wires it to a ResizeObserver.
|
||||
*/
|
||||
|
||||
export interface StripFitInput {
|
||||
/** Width of `.header-content` — the row the brand, menus and strip share. */
|
||||
contentWidth: number;
|
||||
/** Width of `.header-left` (brand + menus), pinned to the first row. */
|
||||
leftWidth: number;
|
||||
/** Horizontal margins of the strip host (`.header-editor-toolbar`). */
|
||||
hostMarginX: number;
|
||||
/** Horizontal padding of the strip itself (`.unified-toolbar`). */
|
||||
stripPaddingX: number;
|
||||
/** Natural width of each strip zone at its own content size. */
|
||||
zoneWidths: readonly number[];
|
||||
}
|
||||
|
||||
/** Natural single-row width the strip needs. */
|
||||
export function stripNeededWidth(i: StripFitInput): number {
|
||||
return i.zoneWidths.reduce((a, b) => a + b, 0) + i.stripPaddingX + i.hostMarginX;
|
||||
}
|
||||
|
||||
/** Width the first row can give the strip next to the brand + menus. */
|
||||
export function stripAvailableWidth(i: StripFitInput): number {
|
||||
return i.contentWidth - i.leftWidth;
|
||||
}
|
||||
|
||||
/** True when the strip must drop below the brand row. */
|
||||
export function stripMustDropBelow(i: StripFitInput): boolean {
|
||||
return stripNeededWidth(i) > stripAvailableWidth(i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the live header. Zones are measured at `flex: 0 0 auto` so a
|
||||
* zone that flexbox has grown to fill the row (the editor zone) reports its
|
||||
* content width, not the width it happens to occupy — the styles are
|
||||
* restored before returning, and the whole thing runs before paint.
|
||||
*/
|
||||
export function measureStripFit(header: HTMLElement): StripFitInput | null {
|
||||
const content = header.querySelector<HTMLElement>(':scope > .header-content');
|
||||
const left = content?.querySelector<HTMLElement>(':scope > .header-left');
|
||||
const host = content?.querySelector<HTMLElement>(':scope > .header-editor-toolbar');
|
||||
const strip = host?.firstElementChild as HTMLElement | null | undefined;
|
||||
if (!content || !left || !host || !strip) return null;
|
||||
|
||||
const zones = Array.from(strip.children) as HTMLElement[];
|
||||
const saved = 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 hostCs = getComputedStyle(host);
|
||||
const stripCs = getComputedStyle(strip);
|
||||
return {
|
||||
contentWidth: content.clientWidth,
|
||||
leftWidth: left.getBoundingClientRect().width,
|
||||
hostMarginX: px(hostCs.marginLeft) + px(hostCs.marginRight),
|
||||
stripPaddingX: px(stripCs.paddingLeft) + px(stripCs.paddingRight),
|
||||
zoneWidths,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue