From 40da4ac402a51f7bc7f071a38c8e98352eb326b2 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 4 Nov 2026 19:41:13 +0800 Subject: [PATCH] Expand the Example drawing over the editor panel Example drawing showed only a strip of its 16:10 canvas and scrolled the rest, about 30% of it at first and at most 65% with the divider dragged all the way up. A button on its toolbar now lays the board over the editor panel, whose code and tests are not needed while drawing, and shows the whole canvas while the sidebar stays usable. The strokes stay on the one canvas, so what Jim reads and what undo removes do not change on the way back, by the button, Escape or closing Example drawing. Neither the button nor Escape acts during a stroke or a text-box gesture, and Escape is left to a field, a dialog, an input method, a held key, a modifier chord or a handler that already took it. What the board covers cannot be reached from the keyboard while it is expanded: the rest of the panel is inert, and Cmd/Ctrl+Enter runs nothing, since its results would land in the covered drawer. Jim's stage becomes the compact card a narrow window already gets, over a corner of the board, so his captions stay on screen and announced while the avatar and Hide avatar go; his voice is unaffected and shrinking gives the stage back as the window allows. Close #579 Change-Id: Ia6d7ac6bcb2284cce8d72739f4c4b8cbbcabd18c --- docs/avatar-contract.md | 9 +- tests/browser/example-board-ui.test.js | 658 ++++++++++++++++++++++++- tests/browser/interview-layout.test.js | 489 ++++++++++++++++++ tests/browser/run-shortcut.test.js | 26 + web/board-text.js | 2 +- web/example-board.js | 128 ++++- web/interview-drawer.js | 10 +- web/interview.html | 61 +-- web/interview.js | 28 +- web/styles.css | 55 ++- web/workspace.js | 9 +- 11 files changed, 1411 insertions(+), 64 deletions(-) diff --git a/docs/avatar-contract.md b/docs/avatar-contract.md index fb31b40a3..d99188d25 100644 --- a/docs/avatar-contract.md +++ b/docs/avatar-contract.md @@ -305,8 +305,10 @@ drawing tools at the board) stay visible at every width the avatar is shown. The stylesheet's `4.25rem` is only the fallback before that first measurement. The stage takes no pointer events, so it can never swallow a click meant for the editor; the Hide avatar button and the captions re-enable them for themselves. -Below 1200px wide the avatar and its button are hidden rather than allowed to -cover code, and the captions move to the bottom corner. +Below 1200px wide, beside an editor panel too narrow for the avatar, or over an +expanded Example drawing, the avatar and its button are hidden rather than +allowed to cover code or the drawing, and the captions move to the bottom +corner. Every VRM loads in a T-pose and the format ships no idle animation, so `loadVrm` drops the upper arms once at load. This is model-independent: without @@ -447,7 +449,8 @@ button; if the button held focus, focus moves to the stage, since the box that would have held a note is removed. The render loop stops scheduling frames while the document is hidden, the stage -is narrower than the breakpoint, or the preflight overlay covers it. It starts +is compact (below the breakpoint, beside a narrow editor panel, or over an +expanded Example drawing), or the preflight overlay covers it. It starts again on `visibilitychange`, when a `ResizeObserver` on the mount sees it widen, and when the page reports the preflight closed (`setStageCovered(false)`). The stage is `inert` while covered, so nothing on it can be reached by keyboard diff --git a/tests/browser/example-board-ui.test.js b/tests/browser/example-board-ui.test.js index a279b5441..ec719a96b 100644 --- a/tests/browser/example-board-ui.test.js +++ b/tests/browser/example-board-ui.test.js @@ -17,7 +17,7 @@ before(async () => { handle: (_request, response, url) => { if (url.pathname === "/drawer") { response.setHeader("content-type", "text/html"); - response.end(drawerMarkup()); + response.end(drawerMarkup(url.searchParams.has("sidebar"))); return true; } if (url.pathname !== "/") return false; @@ -38,8 +38,23 @@ function exampleMarkup() { return html.slice(start, html.indexOf("", start) + 10); } -function drawerMarkup() { - return `
Add a case
${exampleMarkup()}
`; +// The real ending wait, so the board is stacked against the page's own +// overlay rules rather than a stand-in. +function endingMarkup() { + const html = read("web/interview.html"); + const start = html.lastIndexOf( + "", start) + 10); +} + +function drawerMarkup(sidebar = false) { + const panel = `
Add a case
${exampleMarkup()}
`; + const body = sidebar + ? `
${panel}${endingMarkup()}
` + : panel; + return `${body}`; } test("selecting an example pen color switches from erasing to drawing", async (t) => { @@ -799,7 +814,7 @@ test("offline drawings skip JPEG encoding and locked controls repaint", async (t await page.waitForFunction( () => window.uploads === 1 && - document.querySelector("#example-board > [role=status]").textContent === + document.querySelector(".example-pane > [role=status]").textContent === "Drawing ready for Jim to read when you refer to it.", ); assert.equal(await page.evaluate(() => window.encodings), 1); @@ -820,3 +835,638 @@ test("offline drawings skip JPEG encoding and locked controls repaint", async (t assert.equal(await page.locator("[data-action=clear]").isDisabled(), false); await page.evaluate(() => window.example.finish()); }); + +// The interview's own grid with a stand-in sidebar, so an expanded board can be +// checked against both the editor panel it covers and the sidebar it leaves. +async function openExpandableBoard(viewport) { + const page = await browser.newPage({ viewport }); + await page.goto(`${base}/drawer?sidebar`); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector(".drawer-resizer")).height === + "8px", + ); + await page.evaluate(async (base) => { + const { mountExampleBoard } = await import(`${base}/example-board.js`); + const { mountInterviewDrawer } = await import( + `${base}/interview-drawer.js` + ); + window.uploads = []; + window.turns = 0; + document + .querySelector("#yield-turn") + .addEventListener("click", () => window.turns++); + mountExampleBoard(document.querySelector("#example-board"), { + locked: () => false, + // Kept as the bytes Jim's read_board is handed, to decode later. + publish: async (blob, strokes) => { + const image = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + window.uploads.push({ strokes, image }); + return true; + }, + }); + mountInterviewDrawer(document.querySelector(".editor-panel")); + }, base); + await page.locator("#example-board summary").click(); + await page.waitForFunction(() => + document.querySelector(".example-surface").style.height.endsWith("px"), + ); + return page; +} + +function boardLayout(page) { + return page.evaluate(() => { + const box = (selector) => { + const { x, y, width, height } = document + .querySelector(selector) + .getBoundingClientRect(); + return { x, y, width, height }; + }; + const surface = document.querySelector(".example-surface"); + return { + panel: box(".editor-panel"), + pane: box(".example-pane"), + surface: box(".example-surface"), + canvas: box(".example-surface canvas"), + status: box(".example-pane > [role=status]"), + hidden: + surface.scrollHeight - + surface.clientHeight + + surface.scrollWidth - + surface.clientWidth, + }; + }); +} + +const isExpanded = (page) => + page + .locator("#example-board") + .evaluate((node) => node.hasAttribute("data-expanded")); + +async function drawAcross(page, canvas, fromX, toX, y) { + await page.mouse.move(canvas.x + canvas.width * fromX, canvas.y + y); + await page.mouse.down(); + await page.mouse.move(canvas.x + canvas.width * toX, canvas.y + y, { + steps: 4, + }); + await page.mouse.up(); +} + +// The darkest red channel around each board point, on the canvas or in an +// image sent to Jim; both keep the board's 1600x1000 pixels. +function inkAt(page, points, upload = null) { + return page.evaluate( + async ({ points, upload }) => { + let context = document + .querySelector(".example-surface canvas") + .getContext("2d"); + if (upload !== null) { + const image = new Image(); + image.src = window.uploads.at(upload).image; + await image.decode(); + context = document.createElement("canvas").getContext("2d"); + context.canvas.width = image.width; + context.canvas.height = image.height; + context.drawImage(image, 0, 0); + } + return points.map(([x, y]) => { + const { data } = context.getImageData(x, y - 3, 1, 7); + return Math.min(...data.filter((_, index) => index % 4 === 0)); + }); + }, + { points, upload }, + ); +} + +// The three sizes #579 measured. +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, +]) + test(`at ${viewport.width}x${viewport.height} the expanded example board shows the whole canvas over the editor panel only`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard(viewport); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const lifted = await boardLayout(page); + assert.equal(lifted.hidden, 0); + assert.ok( + Math.abs(lifted.canvas.width / lifted.canvas.height - 1.6) < 0.01, + ); + assert.ok( + Math.abs(lifted.canvas.width - lifted.surface.width) <= 1 || + Math.abs(lifted.canvas.height - lifted.surface.height) <= 1, + "the canvas fills the width or the height it is given", + ); + const left = lifted.canvas.x - lifted.surface.x; + const right = + lifted.surface.x + + lifted.surface.width - + lifted.canvas.x - + lifted.canvas.width; + assert.ok(Math.abs(left - right) <= 1, "the canvas is centred"); + // The pane is the panel inside its 1px border, and the drawing takes the + // height its toolbar and status line leave. + for (const side of ["x", "y", "width", "height"]) + assert.ok(Math.abs(lifted.pane[side] - lifted.panel[side]) <= 2, side); + assert.ok( + lifted.pane.y + + lifted.pane.height - + lifted.status.y - + lifted.status.height <= + 16, + ); + await page.getByRole("button", { name: "Your turn is done" }).click(); + assert.equal(await page.evaluate(() => window.turns), 1); + }); + +test("strokes drawn before and while expanded stay on the canvas, in Jim's image and undoable", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + // Board y=100 in the drawer, then board y=500 once expanded. + const docked = await boardLayout(page); + await drawAcross(page, docked.canvas, 0.2, 0.6, docked.canvas.height * 0.1); + await page.waitForFunction(() => window.uploads.length === 1); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const { canvas } = await boardLayout(page); + assert.ok((await inkAt(page, [[640, 100]]))[0] < 128); + await drawAcross(page, canvas, 0.2, 0.6, canvas.height / 2); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + + const [before, during, blank] = await inkAt(page, [ + [640, 100], + [640, 500], + [640, 800], + ]); + assert.ok(before < 128 && during < 128 && blank > 200); + // What Jim reads is the sent image, not the screen. + await page.waitForFunction(() => window.uploads.at(-1).strokes === 2); + const sent = await inkAt( + page, + [ + [640, 100], + [640, 500], + [640, 800], + ], + -1, + ); + assert.ok(sent[0] < 128, "Jim's image keeps the stroke from the drawer"); + assert.ok(sent[1] < 128, "Jim's image has the stroke drawn while expanded"); + assert.ok(sent[2] > 200, "Jim's image is not dark everywhere"); + + await page.getByRole("button", { name: "Undo", exact: true }).click(); + const [kept, undone] = await inkAt(page, [ + [640, 100], + [640, 500], + ]); + assert.ok(kept < 128 && undone > 200); +}); + +test("the button or Escape shrinks the board, but not from a sidebar field or mid-stroke", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const docked = await boardLayout(page); + const expand = page.getByRole("button", { name: "Expand drawing" }); + const shrink = page.getByRole("button", { name: "Shrink drawing" }); + const turn = page.getByRole("button", { name: "Your turn is done" }); + const icon = await expand.innerHTML(); + await expand.click(); + // The label names the action, so the button is not also a pressed toggle. + assert.equal(await shrink.getAttribute("aria-pressed"), null); + assert.equal(await shrink.getAttribute("title"), "Shrink drawing"); + assert.notEqual(await shrink.innerHTML(), icon); + await shrink.click(); + assert.equal(await isExpanded(page), false); + await expand.click(); + const { canvas } = await boardLayout(page); + + // A field outside the board keeps Escape for itself, as a select does. + await page.locator("#sidebar-field").focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true); + + // Mid-stroke, neither Escape nor the focused button changes the canvas size. + await shrink.focus(); + await page.mouse.move(canvas.x + 100, canvas.y + 200); + await page.mouse.down(); + await page.mouse.move(canvas.x + 300, canvas.y + 300, { steps: 3 }); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true); + await page.keyboard.press("Enter"); + assert.equal(await isExpanded(page), true); + await page.mouse.up(); + + // A sidebar button has no use for Escape, so from there it shrinks the board. + await turn.click(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false); + assert.equal(await expand.getAttribute("title"), "Expand drawing"); + assert.equal(await expand.innerHTML(), icon); + // Focus stays in the sidebar the key came from. + assert.equal( + await page.evaluate(() => document.activeElement.id), + "yield-turn", + ); + const back = await boardLayout(page); + assert.ok(Math.abs(back.canvas.width - docked.canvas.width) <= 1); +}); + +// Escape never moves focus: from the board's own button, a tool, or the +// sidebar, the candidate stays where they were. +test("shrinking by Escape leaves focus where it was, in the board or the sidebar", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const toggle = page.locator("#example-board [data-action=expand]"); + const focused = (locator) => + locator.evaluate((node) => node === document.activeElement); + for (const from of [ + toggle, + page.getByRole("button", { name: "Pen", exact: true }), + page.getByRole("button", { name: "Your turn is done" }), + ]) { + await toggle.click(); + assert.equal(await isExpanded(page), true); + await from.focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false); + assert.equal( + await focused(from), + true, + (await from.getAttribute("aria-label")) ?? (await from.textContent()), + ); + } + // The button is the same element either way, so it keeps focus through the + // label change. + assert.equal(await toggle.getAttribute("aria-label"), "Expand drawing"); +}); + +test("Escape from an input method, a modifier chord or a held key leaves the board expanded", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const turn = page.getByRole("button", { name: "Your turn is done" }); + // An input method's Escape, flagged by either field depending on the + // browser, sent from outside any field so the field guard cannot stop it. + for (const flag of [{ isComposing: true }, { keyCode: 229 }]) { + await turn.evaluate( + (node, flag) => + node.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + ...flag, + }), + ), + flag, + ); + assert.equal(await isExpanded(page), true, JSON.stringify(flag)); + } + await turn.focus(); + for (const chord of [ + "Alt+Escape", + "Shift+Escape", + "Control+Escape", + "Meta+Escape", + ]) { + await page.keyboard.press(chord); + assert.equal(await isExpanded(page), true, chord); + } + // A held key's repeats are not a new press. + await page.evaluate(() => + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + repeat: true, + bubbles: true, + }), + ), + ); + assert.equal(await isExpanded(page), true); +}); + +test("the covered editor panel is out of reach while expanded and all of it comes back on shrink", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const active = () => + page.evaluate( + () => document.activeElement.id || document.activeElement.tagName, + ); + // Every control the pane covers, not only the ones Shift+Tab passes. + const reachable = () => + page.evaluate(() => + [ + ...document + .querySelector(".editor-panel") + .querySelectorAll( + "button, input, select, textarea, summary, [tabindex]", + ), + ] + .filter((node) => !node.closest(".example-pane, [inert]")) + .map((node) => node.id || node.textContent.trim() || node.tagName), + ); + const docked = await reachable(); + assert.ok(docked.length > 3); + // Twice, shrinking by the button and then by Escape from the sidebar. + for (const shrinkBy of ["button", "Escape"]) { + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.deepEqual(await reachable(), [], shrinkBy); + await page.getByRole("button", { name: "Shrink drawing" }).focus(); + await page.keyboard.press("Shift+Tab"); + assert.equal(await active(), "yield-turn", shrinkBy); + if (shrinkBy === "button") + await page.getByRole("button", { name: "Shrink drawing" }).click(); + else await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false, shrinkBy); + assert.deepEqual(await reachable(), docked, shrinkBy); + } + await page.getByRole("button", { name: "Expand drawing" }).focus(); + await page.keyboard.press("Shift+Tab"); + assert.equal(await active(), "SUMMARY"); + await page.locator("#code").focus(); + assert.equal(await active(), "code"); +}); + +// An ink stroke and each text-box gesture, against each key that resizes the +// board, docked and expanded; each key is asserted alone so a wrongly obeyed +// one cannot be undone by the next. +test("Expand, Shrink and Escape are ignored during a stroke or a text-box gesture, and the button works once it ends", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const toggle = page.locator("#example-board [data-action=expand]"); + const draft = page.locator(".board-text-draft"); + const centre = (box) => ({ + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }); + async function holdsThrough(from, to, keys, what) { + const expanded = await isExpanded(page); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 3 }); + for (const key of keys) { + // A new text box leaves focus in its label, so the button is focused + // again first: otherwise Enter would never reach it and prove nothing. + if (key !== "Escape") { + await toggle.focus(); + assert.equal( + await page.evaluate(() => document.activeElement.dataset.action), + "expand", + ); + } + await page.keyboard.press(key); + assert.equal(await isExpanded(page), expanded, `${what}, ${key}`); + } + await page.mouse.up(); + } + for (const expanded of [false, true]) { + const state = expanded ? "expanded" : "docked"; + if (expanded) await toggle.click(); + // Every key goes to the focused button, so Escape reaches the board's own + // handler rather than the draft's, which listens on the box and canvas. + const keys = expanded ? ["Enter", " ", "Escape"] : ["Enter", " "]; + const { canvas } = await boardLayout(page); + const at = (x, y) => ({ x: canvas.x + x, y: canvas.y + y }); + await page.getByRole("button", { name: "Pen", exact: true }).click(); + await holdsThrough(at(60, 40), at(200, 90), keys, `${state} stroke`); + await page.getByRole("button", { name: "Text", exact: true }).click(); + await holdsThrough(at(260, 30), at(420, 100), keys, `${state} new box`); + // The box the new-box step drew stays open for the move and the scale. + const edge = centre(await draft.locator("[data-edge=n]").boundingBox()); + await holdsThrough( + edge, + { x: edge.x + 30, y: edge.y + 10 }, + keys, + `${state} moved box`, + ); + const corner = centre( + await draft.locator('[data-handle="se"]').boundingBox(), + ); + await holdsThrough( + corner, + { x: corner.x + 30, y: corner.y + 10 }, + keys, + `${state} scaled box`, + ); + // Once the gesture is over, the button works again. + await toggle.focus(); + await page.keyboard.press("Enter"); + assert.equal(await isExpanded(page), !expanded, `${state}, after`); + if (!expanded) await toggle.click(); + // A closed draft is hidden rather than removed, so it is open when shown. + if (await draft.isVisible()) + await page.getByRole("textbox", { name: "Label text" }).press("Escape"); + } +}); + +test("the ending wait covers an expanded board and keeps its own Escape", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = false)); + const { pane } = await boardLayout(page); + const covered = await page.evaluate( + ([x, y]) => + Boolean(document.elementFromPoint(x, y).closest("#ending-overlay")), + [pane.x + pane.width / 2, pane.y + pane.height / 2], + ); + assert.equal(covered, true); + // Escape inside that dialog is not the covered board's to take. + await page + .locator("#ending-overlay") + .evaluate((node) => + node.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ), + ); + assert.equal(await isExpanded(page), true); +}); + +test("shrinking gives back only what expanding made inert, round after round, and a docked close nothing", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const inert = () => + page.evaluate(() => [ + document.querySelector(".drawer-resizer").inert, + document.querySelector(".editor-stack").inert, + ]); + const roundTrip = async () => { + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + }; + // Something else holds the divider inert before the board expands. + await page.locator(".drawer-resizer").evaluate((node) => (node.inert = true)); + await roundTrip(); + assert.deepEqual(await inert(), [true, false]); + // The next round gives back only what it made inert: the editor, which + // something else has held inert since the last shrink, stays so. + await page.locator(".editor-stack").evaluate((node) => (node.inert = true)); + await roundTrip(); + assert.deepEqual(await inert(), [true, true]); + // Closing Example drawing while docked has nothing to give back, so a node + // the last round froze and something else holds inert since stays so. + const results = page.locator(".test-drawer > button"); + await results.evaluate((node) => (node.inert = true)); + // The details element fires toggle on a later task; waiting on a flag the + // event sets fails on the wait's own deadline if it never comes. + await page.locator("#example-board").evaluate((node) => { + node.addEventListener("toggle", () => (globalThis.boardClosed = true), { + once: true, + }); + node.open = false; + }); + await page.waitForFunction(() => globalThis.boardClosed === true); + assert.equal(await results.evaluate((node) => node.inert), true); +}); + +test("a click that leaves focus in the editor moves it to Shrink", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.locator("#code").focus(); + // Safari and Firefox on macOS do not focus a clicked button; a scripted + // click does the same here. + await page + .locator("#example-board [data-action=expand]") + .evaluate((node) => node.click()); + assert.equal(await isExpanded(page), true); + assert.equal( + await page.evaluate(() => + document.activeElement.getAttribute("aria-label"), + ), + "Shrink drawing", + ); +}); + +test("Escape another handler took, or one from a native dialog, leaves the board expanded", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + // A handler that takes Escape with preventDefault alone, before it bubbles. + const turn = page.getByRole("button", { name: "Your turn is done" }); + await turn.evaluate((node) => + node.addEventListener( + "keydown", + (event) => event.key === "Escape" && event.preventDefault(), + { once: true }, + ), + ); + await turn.focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true, "taken by another handler"); + // A native dialog carries no role attribute. + await page.evaluate(() => { + const dialog = document.createElement("dialog"); + dialog.innerHTML = ''; + document.body.append(dialog); + dialog.show(); + }); + await page.locator("#native-dialog-button").focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true, "from a native dialog"); +}); + +// A text box near the canvas's bottom puts its controls below the canvas, so +// the drawing still has room to scroll; expanding from a strip scrolled down to +// it must show the canvas from its top. +test("expanding with a text box at the bottom shows the canvas from its top", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + // #579's short window, where the expanded canvas takes the pane's full + // height and leaves no room below it for the box's controls. + const page = await openExpandableBoard({ width: 1250, height: 630 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Text", exact: true }).click(); + const surface = page.locator(".example-surface"); + await surface.evaluate((node) => (node.scrollTop = node.scrollHeight)); + const box = await surface.boundingBox(); + await page.mouse.click(box.x + 120, box.y + box.height - 20); + assert.ok(await page.locator(".board-text-draft").isVisible()); + await surface.evaluate((node) => (node.scrollTop = node.scrollHeight)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const { canvas } = await boardLayout(page); + const top = await surface.evaluate( + (node) => node.getBoundingClientRect().top, + ); + assert.ok(canvas.y >= top - 1, `the canvas starts ${top - canvas.y}px above`); +}); + +test("shrinking puts the docked drawing back where it was scrolled", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + // A panel narrower than the docked canvas's 32rem, as a widened sidebar + // leaves it, so the strip scrolls sideways as well as down. + await page + .locator(".editor-panel") + .evaluate((node) => (node.style.width = "400px")); + const strip = page.locator(".example-surface"); + const scrolled = () => strip.evaluate((node) => node.scrollTop); + const across = () => strip.evaluate((node) => node.scrollLeft); + await strip.evaluate((node) => { + node.scrollTop = 100; + node.scrollLeft = 60; + }); + assert.deepEqual([await scrolled(), await across()], [100, 60]); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + assert.deepEqual( + [await scrolled(), await across()], + [100, 60], + "after a round trip", + ); +}); + +test("the Escape that shrinks the board is marked as taken, and a docked one is not", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + // A listener registered after the board's, as another part of the page is. + await page.evaluate(() => + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") globalThis.taken = event.defaultPrevented; + }), + ); + const taken = () => page.evaluate(() => globalThis.taken); + const turn = page.getByRole("button", { name: "Your turn is done" }); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await turn.focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false); + assert.equal(await taken(), true, "the shrinking Escape"); + await page.keyboard.press("Escape"); + assert.equal(await taken(), false, "a docked Escape"); +}); + +test("closing Example drawing shrinks an expanded board and frees the editor", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.locator("#example-board").evaluate((node) => (node.open = false)); + // The details element fires toggle on a later task. + await page.waitForFunction( + () => + !document.querySelector("#example-board").hasAttribute("data-expanded"), + ); + assert.equal( + await page.evaluate( + () => document.querySelectorAll(".editor-panel [inert]").length, + ), + 0, + ); +}); diff --git a/tests/browser/interview-layout.test.js b/tests/browser/interview-layout.test.js index 99527081c..38e4797eb 100644 --- a/tests/browser/interview-layout.test.js +++ b/tests/browser/interview-layout.test.js @@ -280,6 +280,495 @@ test("the wait keeps Jim heard and the recording withdrawable", async (t) => { } }); +// #579's own case: a 13.6-inch MacBook Air window with the divider at its +// maximum, where the editor panel overflows by a few pixels. The expanded +// board must not scroll with it and show what it covers. +test("the expanded Example drawing stays at the panel's top when the panel could scroll, and the scroll comes back on shrink", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1250, height: 630 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.locator(".drawer-resizer").focus(); + await page.keyboard.press("End"); + const offset = () => + page.evaluate(() => { + const panel = document.querySelector(".editor-panel"); + const pane = document.querySelector(".example-pane"); + return Math.round( + pane.getBoundingClientRect().top - panel.getBoundingClientRect().top, + ); + }); + // Scrolled before expanding, and wheeled over the board after. + const scrolled = await page + .locator(".editor-panel") + .evaluate( + (panel) => (panel.scrollTop = panel.scrollHeight) && panel.scrollTop, + ); + assert.ok(scrolled > 0, "the panel can scroll at this size"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.ok( + Math.abs(await offset()) <= 1, + "the pane starts at the panel's top", + ); + const pane = await page.locator(".example-pane").boundingBox(); + await page.mouse.move(pane.x + pane.width / 2, pane.y + pane.height / 2); + await page.mouse.wheel(0, 300); + // The wheel scrolls on a later frame. + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.ok(Math.abs(await offset()) <= 1, "and stays there under the wheel"); + // Shrinking puts the panel back where the candidate had scrolled it. + await page.getByRole("button", { name: "Shrink drawing" }).click(); + assert.equal( + await page.locator(".editor-panel").evaluate((panel) => panel.scrollTop), + scrolled, + "the panel's scroll comes back on shrink", + ); + } finally { + await page.close(); + } +}); + +// The drawer sizes the docked drawing from its own layout, which an expanded +// board is not in: a resize while expanded must neither measure it from the +// floating box nor leave a wrong height for the docked board to come back to. +test("a resize while expanded leaves the docked height alone until shrinking sizes it for the new window", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + // The drawer repaints from observers, a frame or two after a change. + const settled = () => + page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + const surface = () => + page + .locator(".example-surface") + .evaluate((node) => node.getBoundingClientRect().height); + const reserved = () => + page.locator(".example-surface").evaluate((node) => node.style.height); + // The divider at its maximum, which the shorter window then lowers, so + // the docked drawing must come back shorter than it left. + await page.locator(".drawer-resizer").focus(); + await page.keyboard.press("End"); + await settled(); + const docked = await reserved(); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.setViewportSize({ width: 1280, height: 600 }); + await settled(); + // The resize repaints the drawer, which leaves the expanded board's docked + // height alone rather than measuring it from the box the board floats in. + assert.equal(await reserved(), docked, "while expanded"); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await settled(); + const back = await surface(); + // What the drawer gives the drawing at this size when it paints afresh. + await page + .locator("#results-body") + .evaluate((node) => (node.hidden = !node.hidden)); + await settled(); + await page + .locator("#results-body") + .evaluate((node) => (node.hidden = !node.hidden)); + await settled(); + assert.ok(Math.abs(back - (await surface())) <= 1, `${back}px`); + } finally { + await page.close(); + } +}); + +// A resize while expanded changes the docked strip's height, which the drawer +// measures again only after the board shrinks; the strip's scroll waits for +// that, or it is clamped against the old height and lands short. +test("a resize while expanded keeps the docked strip's scroll on shrink", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.locator(".drawer-resizer").focus(); + await page.keyboard.press("End"); + const settled = () => + page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + await settled(); + const strip = page.locator(".example-surface"); + const scrolled = await strip.evaluate( + (node) => (node.scrollTop = node.scrollHeight) && node.scrollTop, + ); + assert.ok(scrolled > 0, "the strip can scroll at this size"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.setViewportSize({ width: 1280, height: 700 }); + await settled(); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await settled(); + const back = await strip.evaluate((node) => ({ + top: node.scrollTop, + max: node.scrollHeight - node.clientHeight, + })); + assert.ok(back.max >= scrolled, "the new strip can hold the old scroll"); + assert.equal(back.top, scrolled); + } finally { + await page.close(); + } +}); + +// The panel's own layers under an expanded board: the Test results chevron is +// transformed, which would paint it over a board with no stacking order of its +// own. Inert nodes are skipped by hit-testing, which is not what is painted, +// so the chevron's covered ancestor is let back in for the one lookup. +test("an expanded Example drawing is drawn over the editor panel's own layers", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const onBoard = await page.evaluate(() => { + const chevron = document.querySelector("#results-chevron"); + const covered = chevron.closest("[inert]"); + covered.inert = false; + const box = chevron.getBoundingClientRect(); + const hit = document.elementFromPoint( + box.x + box.width / 2, + box.y + box.height / 2, + ); + covered.inert = true; + return Boolean(hit.closest(".example-pane")); + }); + assert.ok(onBoard, "the chevron is under the board"); + } finally { + await page.close(); + } +}); + +// Each of the page's own overlays over an expanded board, on the real page: +// the pane sits under them, so whichever one opens is what the candidate sees. +test("an expanded Example drawing stays under the setup check, the ending wait and the report", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + for (const overlay of [ + "#audio-check", + "#ending-overlay", + "#report-modal", + ]) { + const covered = await page.evaluate((overlay) => { + const node = document.querySelector(overlay); + node.hidden = false; + const pane = document + .querySelector(".example-pane") + .getBoundingClientRect(); + const hit = document.elementFromPoint( + pane.x + pane.width / 2, + pane.y + pane.height / 2, + ); + node.hidden = true; + return node.contains(hit); + }, overlay); + assert.ok(covered, overlay); + } + } finally { + await page.close(); + } +}); + +// On the real page, wide and narrow: expanding makes Jim's stage the compact +// card a narrow window already has, so the captions stay on top of the board +// while the avatar and Hide avatar go; shrinking gives the wide window its full +// stage back. +test("an expanded Example drawing turns Jim's stage into the compact card over the board", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + for (const [width, height, docked] of [ + [1440, 900, false], + [900, 800, true], + ]) { + const size = `${width}x${height}`; + const page = await browser.newPage({ viewport: { width, height } }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => + document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page + .locator("#example-board") + .evaluate((node) => (node.open = true)); + const compact = () => + page + .locator("#jim-stage") + .evaluate((node) => node.classList.contains("compact")); + // Whether the element at the centre of `selector` is inside it. + const onTop = (selector) => + page.evaluate((selector) => { + const node = document.querySelector(selector); + const box = node.getBoundingClientRect(); + return node.contains( + document.elementFromPoint( + box.x + box.width / 2, + box.y + box.height / 2, + ), + ); + }, selector); + assert.equal(await compact(), docked, size); + // The avatar's mount has no width, which is what stops its drawing, and + // Hide avatar goes with it; a wide window has both before expanding. + const avatar = () => + page.evaluate(() => [ + document.querySelector("#jim-avatar").clientWidth > 0, + getComputedStyle(document.querySelector("#jim-stage .hide-avatar")) + .display !== "none", + ]); + assert.deepEqual(await avatar(), [!docked, !docked], size); + await page.getByRole("button", { name: "Expand drawing" }).click(); + // The class follows on the observer's microtask. + await page.waitForFunction(() => + document.querySelector("#jim-stage").classList.contains("compact"), + ); + assert.ok(await onTop("#captions-bar"), size); + assert.deepEqual(await avatar(), [false, false], size); + // The card's volume slider, under Audio controls, keeps Escape for itself. + await page + .locator("#jim-stage .jim-volume-controls") + .evaluate((node) => (node.open = true)); + await page.locator("#jim-volume").focus(); + assert.equal( + await page.evaluate(() => document.activeElement.id), + "jim-volume", + size, + ); + await page.keyboard.press("Escape"); + assert.equal( + await page + .locator("#example-board") + .evaluate((node) => node.hasAttribute("data-expanded")), + true, + size, + ); + // Blocked audio puts Resume audio in the card, on top as well. + await page.evaluate( + () => (document.querySelector("#audio-playback-notice").hidden = false), + ); + assert.ok(await onTop("#resume-audio"), size); + await page.evaluate( + () => (document.querySelector("#audio-playback-notice").hidden = true), + ); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await page.waitForFunction( + (docked) => + document.querySelector("#jim-stage").classList.contains("compact") === + docked, + docked, + ); + } finally { + await page.close(); + } + } +}); + +// Safari and Firefox on macOS leave focus where it was on a click, so it can +// still be on Hide avatar when the card hides that button; it moves to the +// stage, as when an automatic turn-off hides it. +test("expanding with focus on Hide avatar moves it to Jim's stage", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.locator("#hide-avatar").focus(); + // A resize that leaves the window wide keeps the button, and its focus. + await page.setViewportSize({ width: 1400, height: 900 }); + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.equal( + await page.evaluate(() => document.activeElement.id), + "hide-avatar", + ); + // A scripted click moves no focus, as those browsers' clicks do not. + await page + .locator("#example-board [data-action=expand]") + .evaluate((node) => node.click()); + await page.waitForFunction(() => + document.querySelector("#jim-stage").classList.contains("compact"), + ); + assert.equal( + await page.evaluate(() => document.activeElement.id), + "jim-stage", + ); + } finally { + await page.close(); + } +}); + +// The wait for the report over an expanded board: its own rule lifts Jim's card +// over the dialog so his closing lines are seen, and the board behind the +// dialog is inert, so Escape from the card leaves it expanded. +test("over an expanded Example drawing the ending wait lifts Jim's card and keeps Escape from the board", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = false)); + // The page makes the workspace inert on the observer's microtask. + await page.waitForFunction( + () => document.querySelector(".editor-panel").inert, + ); + const captions = await page.evaluate(() => { + const bar = document.querySelector("#captions-bar"); + const box = bar.getBoundingClientRect(); + return bar.contains( + document.elementFromPoint( + box.x + box.width / 2, + box.y + box.height / 2, + ), + ); + }); + assert.ok(captions, "the card's captions are over the wait"); + await page.locator("#jim-stage").focus(); + await page.keyboard.press("Escape"); + assert.equal( + await page + .locator("#example-board") + .evaluate((node) => node.hasAttribute("data-expanded")), + true, + ); + } finally { + await page.close(); + } +}); + +// The whiteboard page has no Example drawing to expand, so only the window and +// its panel decide: a wide one keeps Jim's full stage, resize after resize. +test("the whiteboard page keeps Jim's full stage in a wide window", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&mode=whiteboard`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector(".editor-panel") === null, + ); + const compact = () => + page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => + requestAnimationFrame(() => + done( + document + .querySelector("#jim-stage") + .classList.contains("compact"), + ), + ), + ), + ), + ); + assert.equal(await compact(), false); + for (const width of [1400, 1440]) { + await page.setViewportSize({ width, height: 900 }); + assert.equal(await compact(), false, `${width}`); + } + } finally { + await page.close(); + } +}); + // On a phone the compact stage runs along the bottom edge above the wait, so a // long closing line must not grow it over the wait's own buttons. test("on a phone Jim's closing lines leave the wait's buttons reachable", async (t) => { diff --git a/tests/browser/run-shortcut.test.js b/tests/browser/run-shortcut.test.js index 76bc1fa24..5e9e6f68d 100644 --- a/tests/browser/run-shortcut.test.js +++ b/tests/browser/run-shortcut.test.js @@ -410,6 +410,32 @@ test("the shortcut does nothing while the media preflight is still up", async (t } }); +test("the shortcut runs nothing while Example drawing is expanded, and runs again once it shrinks", async (t) => { + const ctx = await interviewPage(t, { params: { exampleDrawing: "1" } }); + if (!ctx) return; + const { page, errors } = ctx; + try { + await runButtonReady(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + // Taken, so it presses nothing else, and runs nothing while the board + // covers the results. + assert.equal(await runs(page), 0); + assert.equal(await enterDefaultPrevented(page), true); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + assert.equal(await runs(page), 1); + assert.deepEqual( + errors, + [], + `the shortcut threw in the browser: ${errors[0]}`, + ); + } finally { + await page.close(); + } +}); + test("the shortcut does nothing once the interview has ended", async (t) => { const ctx = await interviewPage(t, { clock: true }); if (!ctx) return; diff --git a/web/board-text.js b/web/board-text.js index 6084c4e94..6a9d91d8a 100644 --- a/web/board-text.js +++ b/web/board-text.js @@ -455,5 +455,5 @@ export function mountBoardText( canvas.addEventListener("keydown", onDraftKeydown); const observer = new ResizeObserver(refresh); observer.observe(canvas); - return { refresh, cancel: close }; + return { refresh, cancel: close, busy: () => gesture !== null }; } diff --git a/web/example-board.js b/web/example-board.js index 94bb122fe..c8bb44fb7 100644 --- a/web/example-board.js +++ b/web/example-board.js @@ -16,10 +16,15 @@ const TOOL_ICONS = { eraser: '', undo: '', clear: '', + expand: '', + shrink: '', }; -function setToolIcon(button, name) { - const label = name[0].toUpperCase() + name.slice(1); +function setToolIcon( + button, + name, + label = name[0].toUpperCase() + name.slice(1), +) { button.setAttribute("aria-label", label); button.title = name === "clear" ? "Clear drawing; Undo restores it" : label; button.classList.add("example-tool-icon"); @@ -44,10 +49,21 @@ export function mountExampleBoard( const model = createBoard(); const pointers = createPointerGate(); const tools = root.querySelector("[data-tools]"); - const status = root.querySelector(":scope > [role=status]"); + // The interview wraps the board in `.example-pane` so it can lift out of + // the drawer; the task page keeps it directly under the details element. + const status = root.querySelector( + ":scope > [role=status], :scope > .example-pane > [role=status]", + ); + const expand = root.querySelector("[data-action=expand]"); + const pane = root.querySelector(":scope > .example-pane"); + const panel = root.closest(".editor-panel"); + const strip = canvas.parentElement; let tool = "pen"; let color = PEN_COLORS[0].color; let drag = null; + let scrollBack = 0; + let stripBack = [0, 0]; + let covered = []; let settle = null; let unsentSince = 0; let dirty = false; @@ -269,13 +285,106 @@ export function mountExampleBoard( }); canvas.addEventListener("pointercancel", cancel); canvas.addEventListener("lostpointercapture", cancel); + // Expanding moves no element: the pane is restyled in place, so the canvas, + // its strokes and anything waiting to reach Jim stay exactly as they were. + // Only the drawn size changes, and `boardPoint` already scales by it. + function expandBoard() { + // Read before the restyle, which can clamp the drawing's scroll. + scrollBack = panel.scrollTop; + stripBack = [strip.scrollTop, strip.scrollLeft]; + root.setAttribute("data-expanded", ""); + setToolIcon(expand, "shrink", "Shrink drawing"); + // The panel can scroll a little on a short screen, and the pane would + // scroll with it; the stylesheet stops it scrolling while expanded. A text + // box near the bottom keeps its controls below the canvas, so the drawing + // can still scroll; it starts from the canvas's top. + panel.scrollTop = 0; + strip.scrollTop = 0; + // What the pane covers leaves the tab order with it, or Shift+Tab walks + // into a code editor nobody can see. The sidebar is outside the panel. + // Only what this makes inert is given back, so a node held inert by + // something else stays so. + covered = []; + for (let node = pane; node !== panel; node = node.parentElement) + for (const sibling of node.parentElement.children) + if (sibling !== node && !sibling.inert) { + sibling.inert = true; + covered.push(sibling); + } + // A click focuses the button in Chromium but not in Safari or Firefox on + // macOS, so focus left in what is now covered moves to the button. + if (document.activeElement.closest("[inert]")) expand.focus(); + } + function shrinkBoard() { + if (!root.hasAttribute("data-expanded")) return; + root.removeAttribute("data-expanded"); + setToolIcon(expand, "expand", "Expand drawing"); + for (const sibling of covered) sibling.inert = false; + // The panel and the strip go back where the candidate had them, once the + // drawer's observer has measured the docked height again after this + // returns; restored before it, the strip is clamped against its old + // height after a resize while expanded. + const [top, left] = stripBack; + queueMicrotask(() => { + panel.scrollTop = scrollBack; + strip.scrollTop = top; + strip.scrollLeft = left; + }); + } + if (expand) { + setToolIcon(expand, "expand", "Expand drawing"); + expand.addEventListener("click", () => { + if (drag || textDraft.busy()) return; + if (root.hasAttribute("data-expanded")) shrinkBoard(); + else expandBoard(); + }); + // Not a modal dialog: the sidebar's turn and mute controls stay usable + // while the board is large, so Escape is handled here rather than by + // `showModal`. A text draft claims Escape first and stops it. An ink + // stroke takes no focus, so after a sidebar button the key still arrives + // there. A field or a dialog keeps Escape for itself, and so does an + // input method, a modifier chord, a held key or a handler that already + // took it. Under a dialog the board is inert, so a key from Jim's stage, + // which the ending wait lifts over that dialog, is not the board's; with + // no dialog the stage's card is like the sidebar. The Escape that shrinks + // the board is marked as taken, so a later listener does not act on it + // too; one while docked passes through untouched. + document.addEventListener("keydown", (event) => { + if ( + event.key !== "Escape" || + event.isComposing || + event.keyCode === 229 || + event.repeat || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + drag || + textDraft.busy() || + root.closest("[inert]") || + event.defaultPrevented || + event.target.closest?.("input, textarea, select, dialog, [role=dialog]") + ) + return; + if (!root.hasAttribute("data-expanded")) return; + event.preventDefault(); + shrinkBoard(); + }); + } root.addEventListener("toggle", () => { - if (!root.open) cancelTurn?.(); - if (!root.open && drag) { - model.cancel(); - drag = null; - if (previewShared) dirty = true; - previewShared = false; + // Nothing on the page closes it while expanded, but whatever does must + // not leave the editor inert under a board that is gone. Everything a + // close undoes is here: the board shrinks, then the turn and any stroke + // are cancelled. + if (!root.open) { + shrinkBoard(); + cancelTurn?.(); + if (drag) { + model.cancel(); + drag = null; + if (previewShared) dirty = true; + previewShared = false; + } } paint(); if (dirty && !drag) schedule(); @@ -285,6 +394,7 @@ export function mountExampleBoard( paint(); return { refresh: paint, + expanded: () => root.hasAttribute("data-expanded"), async flush() { const deadline = Date.now() + 2000; while (uploading && Date.now() < deadline) diff --git a/web/interview-drawer.js b/web/interview-drawer.js index 6031e85e3..ad95ab0d0 100644 --- a/web/interview-drawer.js +++ b/web/interview-drawer.js @@ -45,7 +45,9 @@ export function mountInterviewDrawer(panel) { "aria-valuenow", String(Math.round(sized ? height : drawer.clientHeight)), ); - if (example.open) { + // An expanded board sizes itself over the panel; its docked height is + // measured again when it shrinks, not from the box it floats in. + if (example.open && !example.hasAttribute("data-expanded")) { // Reserve room for summaries, tools and results; the drawing scrolls // inside its viewport without changing the model's coordinates. const contentHeight = [...drawer.children].reduce((total, node) => { @@ -107,6 +109,12 @@ export function mountInterviewDrawer(panel) { }); for (const details of drawer.querySelectorAll("details")) details.addEventListener("toggle", paint); + // A shrinking board comes back to a drawer that may have been resized while + // it was out, so its docked height is measured again. + new MutationObserver(paint).observe(example, { + attributes: true, + attributeFilter: ["data-expanded"], + }); new MutationObserver(paint).observe(results, { attributes: true, attributeFilter: ["hidden"], diff --git a/web/interview.html b/web/interview.html index e434c31b4..2ee046efb 100644 --- a/web/interview.html +++ b/web/interview.html @@ -397,38 +397,41 @@

Loading interview...