mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): the title bar folds into one button instead of colliding at high scale
At a couple of hundred CSS pixels the composer's model group could neither shrink nor wrap and the title bar's right-hand controls left its title no room, so rows ran over each other. Below 300px of room (the width at which the five controls leave the title under its 4em floor) the bar gathers phone, find, theme, sandbox and browser under one button; each stays one press away. The model group wraps, the tabs shrink, the meter row drops its cache figure and the placeholder keeps to one line at the narrowest widths. The size slider's fallback range parks the thumb mid-track while no range is announced.
This commit is contained in:
1 parent
2f86d06a78
commit
e6ca850e1d
19 files changed
+383
-6
No files matched your search
@@ -72,6 +72,7 @@ node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写
|
||||
node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏
|
||||
node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载)
|
||||
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
|
||||
node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照)
|
||||
```
|
||||
|
||||
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/tips.css";
|
||||
import "../src/styles/waitnote.css";
|
||||
import "../src/styles/narrow.css";
|
||||
import "../src/styles/feedback.css";
|
||||
import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Under a high interface scale the layout's room is the window divided by the zoom, so a phone-width window can leave
|
||||
// the shell a couple of hundred CSS pixels. Rows that cannot fit must give something up, never paint over each other.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.NARROW_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const probe = (page) =>
|
||||
page.evaluate(() => {
|
||||
const live = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null;
|
||||
};
|
||||
const name = (el) => (el.className && String(el.className).split(/\s+/)[0]) || el.tagName;
|
||||
const overlaps = (parent) => {
|
||||
const boxes = [...parent.children].map((el) => [el, live(el)]).filter(([, r]) => r);
|
||||
const hits = [];
|
||||
for (let i = 0; i < boxes.length; i++)
|
||||
for (let j = i + 1; j < boxes.length; j++) {
|
||||
const [a, ra] = boxes[i];
|
||||
const [b, rb] = boxes[j];
|
||||
const x = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
|
||||
const y = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
||||
if (x > 1 && y > 1) hits.push(`${name(a)}/${name(b)}`);
|
||||
}
|
||||
return hits;
|
||||
};
|
||||
const outside = (parent) =>
|
||||
[...parent.children].filter((el) => live(el) && live(el).right > innerWidth + 1).map(name);
|
||||
const bar = document.querySelector(".chrome");
|
||||
const row = document.querySelector(".compose .row");
|
||||
const tools = document.querySelector(".compose .turntools");
|
||||
const crumb = bar.querySelector(".crumb");
|
||||
const title = crumb.querySelector("b");
|
||||
const clip = crumb.getBoundingClientRect();
|
||||
const t = title.getBoundingClientRect();
|
||||
return {
|
||||
bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) },
|
||||
row: { overlaps: overlaps(row), outside: outside(row) },
|
||||
toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
|
||||
titleShown: t.width,
|
||||
titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
|
||||
crumbWidth: clip.width,
|
||||
room: document.body.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-action"];
|
||||
|
||||
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
|
||||
async function reachable(page) {
|
||||
const more = page.locator(".chrome .r-more");
|
||||
if (await more.isVisible()) {
|
||||
await more.click();
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
return page.evaluate((controls) => {
|
||||
const out = [];
|
||||
for (const sel of controls) {
|
||||
const el = document.querySelector(`.chrome ${sel}`);
|
||||
if (!el) {
|
||||
out.push(`${sel} missing`);
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
const inside = r.width > 0 && r.left >= 0 && r.right <= innerWidth + 1;
|
||||
if (!inside || !(el === top || el.contains(top))) out.push(`${sel}${inside ? " covered" : " off screen"}`);
|
||||
}
|
||||
return out;
|
||||
}, CONTROLS);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// The fold's threshold is derived, not chosen: wherever the bar is not folded the title keeps its floor.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const bad = [];
|
||||
for (let room = 190; room <= 460; room += 10) {
|
||||
await page.setViewportSize({ width: room, height: 800 });
|
||||
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(200);
|
||||
const w = await page.evaluate(() => document.querySelector(".chrome .crumb").getBoundingClientRect().width);
|
||||
if (w < 48) bad.push(`${room}:${Math.round(w)}`);
|
||||
}
|
||||
check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [1280, 1]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&turns=2&zoom=${zoom}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(500);
|
||||
const tag = `${scheme} ${width}px zoom ${zoom}`;
|
||||
const m = await probe(page);
|
||||
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
|
||||
check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", "));
|
||||
check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", "));
|
||||
check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
|
||||
check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
|
||||
const hint = await page.evaluate(() => {
|
||||
const el = document.querySelector(".compose textarea");
|
||||
const cs = getComputedStyle(el);
|
||||
const ctx = document.createElement("canvas").getContext("2d");
|
||||
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
||||
const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
return { text: el.placeholder, width: ctx.measureText(el.placeholder).width, room };
|
||||
});
|
||||
check(`${tag}: the composer's placeholder is on one line and not empty`, hint.text.length > 0 && hint.width <= hint.room + 1, JSON.stringify(hint));
|
||||
const reach = await reachable(page);
|
||||
check(`${tag}: every title-bar control can be reached, folded or not`, reach.length === 0, reach.join(", "));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-open-${scheme}-${width}-z${zoom}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -56,6 +56,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -18,6 +18,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"正在停止…": "Stopping…",
|
||||
"正在发送…": "Sending…",
|
||||
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
|
||||
"描述任务…": "Describe a task…",
|
||||
"任务输入": "Task input",
|
||||
"命令与技能": "Commands and skills",
|
||||
"正在加载命令与技能…": "Loading commands and skills…",
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./styles/dock.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/tips.css";
|
||||
import "./styles/waitnote.css";
|
||||
import "./styles/narrow.css";
|
||||
import "./styles/feedback.css";
|
||||
import "./styles/community.css";
|
||||
import "./styles/sponsor.css";
|
||||
|
||||
@@ -639,6 +639,13 @@ told apart by the colour of a symbol.
|
||||
- Under it the rail is a drawer, not a column (see *Phones*). No seam is left
|
||||
to drag and the chrome's rail button is the entrance, so the grip goes; so
|
||||
does the workbench grip, whose entrance is the 「工作台」 tab.
|
||||
- The title bar folds the same way: it gathers its controls under one button, it does not drop them.
|
||||
- Below 300px of room (`bar`, in `viewport.ts`) the phone, find, theme, sandbox and browser buttons move into one
|
||||
popover under a "more" button, every one still a press away.
|
||||
- 300 is derived, not chosen: unfolded, the controls leave the title `room - 196` px, and 4em is its floor.
|
||||
- `perf/narrow.mjs` sweeps the room from 190 to 460 and asserts the title keeps that floor, and that each control is
|
||||
on screen with nothing over it, folded or not.
|
||||
- Below `bar` the page tabs shrink and ellipsise rather than clip, and the meter row drops its cache figure first.
|
||||
- When both side columns have yielded, the transcript rail yields too; its ticks
|
||||
and viewport remain separately reachable.
|
||||
- A short window divides its height proportionally, and what is left over is the
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.studio-model-group { flex-wrap: wrap; }
|
||||
.chrome .r-fold { display: contents; }
|
||||
[data-fold~="bar"] .chrome .r-fold { display: none; position: absolute; top: calc(100% + 6px); right: 12px; z-index: 80; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); box-shadow: 0 12px 32px -14px #000; }
|
||||
[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; }
|
||||
[data-fold~="bar"] .tab { flex: 0 1 auto; min-width: 0; padding-inline: 6px; overflow: hidden; text-overflow: ellipsis; }
|
||||
@container conversation (max-width: 360px) { .studio-effort-picker::before { display: none; } }
|
||||
@container conversation (max-width: 200px) { .studio-meterrail .studio-meter-cache { display: none; } }
|
||||
.chrome .thbtn.r-more { color: var(--muted); }
|
||||
.chrome .thbtn.r-more svg { stroke-width: 2.6; }
|
||||
@@ -502,7 +502,7 @@ body { background: var(--page) !important; }
|
||||
|
||||
/* Prototype header */
|
||||
.chrome .studio-menu { width: 30px; margin-left: -6px; }
|
||||
.chrome .crumb { gap: 8px; color: var(--faint); font-size: 12px; }
|
||||
.chrome .crumb { gap: 8px; color: var(--faint); font-size: 12px; overflow: hidden; }
|
||||
.chrome .crumb-folder { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.25; }
|
||||
.chrome .crumb-proj { color: var(--muted); font-weight: 600; }
|
||||
.chrome .crumbsep { color: #555852; }
|
||||
@@ -2215,7 +2215,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
flex: 1 0 172px;
|
||||
flex: 1 1 172px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.studio-mode-control,
|
||||
|
||||
@@ -152,7 +152,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
const set = useCallback((patch: Partial<Look>) => onLook({ ...look, ...patch }), [look, onLook]);
|
||||
// Until the kernel has announced its range there is nothing to draw the
|
||||
// slider from; it is held inert rather than given a range of its own.
|
||||
const zoomRange = look.zoomRange ?? { min: 0, max: 1, step: 1 };
|
||||
const zoomRange = look.zoomRange ?? { min: 0, max: 2, step: 1 };
|
||||
const setPaper = useCallback(
|
||||
(patch: Partial<NonNullable<Look["wallpaper"]>>) =>
|
||||
look.wallpaper && onLook({ ...look, wallpaper: { ...look.wallpaper, ...patch } }),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { HubPort } from "../port/hub";
|
||||
import type { AccountState, AgentPort, SessionStatus, WorkspaceInfo } from "../port/port";
|
||||
import { DeviceBar } from "./DeviceBar";
|
||||
import { useDismiss } from "./dismiss";
|
||||
import { useFolded } from "./usefold";
|
||||
import { ariaChord, chord } from "./keys";
|
||||
import { PhonePop } from "./PhonePop";
|
||||
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
|
||||
@@ -51,6 +53,21 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow
|
||||
port.workspaces().then(setWs).catch(() => setWs(null));
|
||||
}, [port, root]);
|
||||
|
||||
const gathered = useFolded("bar");
|
||||
const [more, setMore] = useState(false);
|
||||
const group = useRef<HTMLDivElement>(null);
|
||||
const shut = useCallback(() => setMore(false), []);
|
||||
useDismiss(more && gathered, group, shut);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
// Escape leaves focus on a control that has just been hidden; give it back to the button that opened it.
|
||||
const at = document.activeElement;
|
||||
const lost = !at || at === document.body || !!document.getElementById("chrome-fold")?.contains(at);
|
||||
if (wasOpen.current && !more && lost) trigger.current?.focus();
|
||||
wasOpen.current = more;
|
||||
}, [more]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hub && <DeviceBar hub={hub} />}
|
||||
@@ -80,7 +97,13 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow
|
||||
{t("插话待送达")} <b>{steer}</b>
|
||||
</span>
|
||||
|
||||
<div className="r">
|
||||
<div className="r" ref={group}>
|
||||
{gathered && (
|
||||
<button ref={trigger} className="thbtn r-more" data-action="chrome.more" aria-haspopup="true" aria-controls="chrome-fold" aria-expanded={more} aria-label={t("更多")} onClick={() => setMore((v) => !v)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8h.01M8 8h.01M12.5 8h.01" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="r-fold" id="chrome-fold" data-open={more ? "" : undefined}>
|
||||
{hub && onError && <PhonePop hub={hub} />}
|
||||
{port && (
|
||||
<button
|
||||
@@ -114,6 +137,7 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow
|
||||
<button className="thbtn browser-action" data-action="browser.open" onClick={onBrowser} aria-pressed={browser} aria-label="Browser" title={browser ? t("关闭内置 Browser") : t("打开内置 Browser")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.4" /><path d="M2.8 8h10.4M8 2.6c1.5 1.5 2.3 3.3 2.3 5.4S9.5 11.9 8 13.4C6.5 11.9 5.7 10.1 5.7 8S6.5 4.1 8 2.6Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
|
||||
<WindowControls />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPi
|
||||
import { CompletionMenu, useCompletion } from "./Completion";
|
||||
import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
import { usePlaceholderFit } from "./placeholderfit";
|
||||
import { useFitHeight } from "./fitHeight";
|
||||
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||
import { kindOf, nameOf, previewURL } from "./chipfile";
|
||||
@@ -199,6 +200,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
pending.current = null;
|
||||
}, []);
|
||||
useFitHeight(box, text, placeCaret);
|
||||
const placeholder = usePlaceholderFit(box, [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
|
||||
|
||||
// Attachments ride into the turn as path references, exactly as they do from
|
||||
// the CLI — the host saved the bytes, the turn parser resolves the token. A
|
||||
@@ -494,7 +496,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
rows={1}
|
||||
value={text}
|
||||
data-chips={chips.list.length > 0 ? "" : undefined}
|
||||
placeholder={t("描述任务、问题或要改的内容…")}
|
||||
placeholder={placeholder}
|
||||
role="combobox"
|
||||
aria-label={t("任务输入")}
|
||||
aria-describedby={guide}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { refresh } from "./viewport";
|
||||
|
||||
// jsdom lays nothing out, so the width the fold reads is whatever is said here.
|
||||
function room(width: number) {
|
||||
vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(width);
|
||||
vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900);
|
||||
refresh();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await import("./Settings");
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
room(1440);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
delete document.documentElement.dataset.fold;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function open() {
|
||||
const hub = new MockHub();
|
||||
const build = hub.portFor.bind(hub);
|
||||
const prepared = new WeakSet();
|
||||
hub.portFor = (rt) => {
|
||||
const port = build(rt);
|
||||
if (!prepared.has(port)) {
|
||||
prepared.add(port);
|
||||
port.providerSetup = async () => null;
|
||||
port.welcomeSeen = async () => true;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
const view = render(<App hub={hub} />);
|
||||
await screen.findByRole("combobox", { name: "任务输入" });
|
||||
return { hub, view };
|
||||
}
|
||||
|
||||
|
||||
const more = () => screen.queryByRole("button", { name: "更多" });
|
||||
const fold = () => document.querySelector<HTMLElement>(".chrome .r-fold")!;
|
||||
|
||||
it("keeps every title-bar control in the bar while there is room, with no 'more' button", async () => {
|
||||
await open();
|
||||
expect(more()).toBeNull();
|
||||
expect(document.querySelector(".chrome .theme-toggle")).not.toBeNull();
|
||||
expect(document.documentElement.dataset.fold ?? "").not.toContain("bar");
|
||||
});
|
||||
|
||||
it("gathers the controls under one button below the bar fold, keeps them in the document, and closes on Escape", async () => {
|
||||
room(300);
|
||||
await open();
|
||||
expect(document.documentElement.dataset.fold).toContain("bar");
|
||||
const button = more()!;
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
for (const sel of [".find-action", ".theme-toggle", ".top-action", ".browser-action"]) {
|
||||
expect(document.querySelector(`.chrome .r-fold ${sel}`), sel).not.toBeNull();
|
||||
}
|
||||
await userEvent.click(button);
|
||||
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(fold().hasAttribute("data-open")).toBe(true);
|
||||
expect(button.getAttribute("aria-haspopup")).toBe("true");
|
||||
expect(document.getElementById(button.getAttribute("aria-controls")!)).toBe(fold());
|
||||
document.querySelector<HTMLElement>(".chrome .find-action")!.focus();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(fold().hasAttribute("data-open")).toBe(false);
|
||||
expect(document.activeElement).toBe(button);
|
||||
});
|
||||
|
||||
it("a control inside the fold still does its job", async () => {
|
||||
room(300);
|
||||
await open();
|
||||
const before = document.documentElement.dataset.theme;
|
||||
await userEvent.click(more()!);
|
||||
await userEvent.click(document.querySelector<HTMLElement>(".chrome .theme-toggle")!);
|
||||
expect(document.documentElement.dataset.theme).not.toBe(before);
|
||||
});
|
||||
|
||||
it("unfolds again when the room comes back", async () => {
|
||||
room(300);
|
||||
await open();
|
||||
expect(more()).not.toBeNull();
|
||||
act(() => room(900));
|
||||
expect(more()).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import "./testkit";
|
||||
import { usePlaceholderFit } from "./placeholderfit";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function Box({ width }: { width: number }) {
|
||||
const box = useRef<HTMLTextAreaElement>(null);
|
||||
const placeholder = usePlaceholderFit(box, ["a long placeholder", "short", "…"]);
|
||||
return <textarea ref={(el) => { box.current = el; if (el) Object.defineProperty(el, "clientWidth", { value: width, configurable: true }); }} placeholder={placeholder} aria-label="box" />;
|
||||
}
|
||||
|
||||
describe("the placeholder that fits", () => {
|
||||
const measure = () => vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ font: "", measureText: (t: string) => ({ width: t.length * 10 }) } as never);
|
||||
|
||||
it("keeps the whole text while it fits", () => {
|
||||
measure();
|
||||
render(<Box width={400} />);
|
||||
expect(screen.getByLabelText("box").getAttribute("placeholder")).toBe("a long placeholder");
|
||||
});
|
||||
|
||||
it("falls back to the shorter text, then to the ellipsis, instead of being cut", () => {
|
||||
measure();
|
||||
render(<Box width={80} />);
|
||||
expect(screen.getByLabelText("box").getAttribute("placeholder")).toBe("short");
|
||||
cleanup();
|
||||
render(<Box width={20} />);
|
||||
expect(screen.getByLabelText("box").getAttribute("placeholder")).toBe("…");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
|
||||
function widthOf(el: HTMLElement, text: string): number {
|
||||
canvas ??= document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return 0;
|
||||
const cs = getComputedStyle(el);
|
||||
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
/** usePlaceholderFit picks the longest candidate that fits the box on one line.
|
||||
* A placeholder that wraps is cut by the box's one-line height, and a browser
|
||||
* draws no ellipsis on a textarea's, so the text is chosen by measuring rather
|
||||
* than clipped. The last candidate is the fallback when none fits. */
|
||||
export function usePlaceholderFit(box: RefObject<HTMLTextAreaElement | null>, candidates: string[]): string {
|
||||
const [pick, setPick] = useState(candidates[0]);
|
||||
const key = candidates.join("\u0000");
|
||||
useEffect(() => {
|
||||
const el = box.current;
|
||||
if (!el) return;
|
||||
const list = key.split("\u0000");
|
||||
const fit = () => {
|
||||
const cs = getComputedStyle(el);
|
||||
const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
const hit = list.find((c) => widthOf(el, c) <= room);
|
||||
setPick(hit ?? list[list.length - 1]);
|
||||
};
|
||||
fit();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [box, key]);
|
||||
return pick;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { folded, onFolds } from "./viewport";
|
||||
|
||||
/** useFolded is whether the layout has given up what the named fold gives up. */
|
||||
export function useFolded(name: string): boolean {
|
||||
const [now, setNow] = useState(() => folded(name));
|
||||
useEffect(() => {
|
||||
setNow(folded(name));
|
||||
return onFolds((f) => setNow(f.split(" ").includes(name)));
|
||||
}, [name]);
|
||||
return now;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ describe("layout folds", () => {
|
||||
expect(foldsAt(1200)).toBe("rail");
|
||||
expect(foldsAt(840)).toBe("rail side");
|
||||
expect(foldsAt(640)).toBe("rail side scene");
|
||||
expect(foldsAt(301)).toBe("rail side scene");
|
||||
expect(foldsAt(300)).toBe("rail side scene bar");
|
||||
});
|
||||
|
||||
it("accumulates, so a narrower window never un-folds a wider one's column", () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ const FOLDS = [
|
||||
{ upTo: 1200, fold: "rail" }, // the workspace column goes
|
||||
{ upTo: 840, fold: "side" }, // metrics moves under the flow
|
||||
{ upTo: 640, fold: "scene" }, // the opening scene tightens
|
||||
{ upTo: 300, fold: "bar" }, // the bar's controls gather into one: unfolded they leave the title under its 4em floor
|
||||
] as const;
|
||||
|
||||
// Height folds the same way width does, and for the same reason: every surface
|
||||
|
||||
@@ -55,6 +55,15 @@ describe("the interface size control", () => {
|
||||
expect(slider().parentElement!.querySelector(".now")!.textContent).toBe("180%");
|
||||
});
|
||||
|
||||
it("parks the thumb mid-track while the kernel has announced no range", async () => {
|
||||
const { paint, again } = await open();
|
||||
paint.result.current.look.zoomRange = undefined;
|
||||
again();
|
||||
expect(slider().disabled).toBe(true);
|
||||
const at = (Number(slider().value) - Number(slider().min)) / (Number(slider().max) - Number(slider().min));
|
||||
expect(at).toBe(0.5);
|
||||
});
|
||||
|
||||
it("marks the preset a click chose and leaves the body text size alone", async () => {
|
||||
const { paint, again } = await open();
|
||||
fireEvent.click(screen.getByRole("button", { name: "宽松" }));
|
||||
|
||||
Reference in new issue
Block a user