diff --git a/README.md b/README.md index 332441df..160a026f 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ what you can draw, say aloud and defend by tracing an example by hand. |---|---| | Pen colors | Black, red, blue and green. Write in black and mark up in the others. | | Eraser | Rubs out what it passes over. The three size buttons beside it pick a size and the eraser together: small for one character, medium for one line, large for a region. Pick a pen color, or press Eraser again, to draw again. | - | Undo, Redo | Step back and forward one stroke or erasure at a time. | + | Undo, Redo | Step back and forward one stroke or erasure at a time. With focus in the whiteboard canvas or drawing toolbar, use Ctrl+Z to undo and Ctrl+Y or Ctrl+Shift+Z to redo on Windows/Linux, or Cmd+Z / Cmd+Shift+Z on macOS. Tab can focus the canvas. | | Clear board | Wipes the board in one step, which Undo brings back. | 5. Talk while you draw. Jim hears you live and is sent the whole board a moment diff --git a/tests/browser/whiteboard-shortcuts.test.js b/tests/browser/whiteboard-shortcuts.test.js new file mode 100644 index 00000000..c261c300 --- /dev/null +++ b/tests/browser/whiteboard-shortcuts.test.js @@ -0,0 +1,774 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_RUNTIME_CONFIG, + launchChromium, + read, + startStaticServer, +} from "./source.js"; + +let browser; +let server; +let base; + +// Only the served copy exposes state setup. Pointer, key, paint, debounce, +// stream and replay handlers remain the production ones; no live room is used. +const setup = ` +globalThis.boardTest = { + uploads: [], + start() { + nodes.audioCheck.hidden = true; + state.phase = "live"; + state.interviewId = "shortcut-test"; + state.connected = true; + state.room = { localParticipant: { streamBytes: async (options) => { + const upload = { options, bytes: 0, chunks: [], closed: false }; + this.uploads.push(upload); + return { + write: async (bytes) => { + upload.bytes += bytes.length; + upload.chunks.push(bytes.slice()); + }, + close: async () => { upload.closed = true; }, + }; + } } }; + board.ready = true; + paintBoard(); + }, + inspect() { + return { + strokes: board.model.strokes(), + drawing: board.model.isDrawing(), + canRedo: board.model.canRedo(), + focus: document.activeElement?.id, + image: nodes.board.toDataURL(), + uploads: this.uploads.map(({ chunks, ...upload }) => upload), + }; + }, + settling() { + return Boolean(board.settle); + }, + waitUploads() { + return board.publishing; + }, + jpeg() { + return nodes.board.toDataURL("image/jpeg", BOARD_JPEG_QUALITY); + }, + lock(kind) { + board.ready = kind !== "not-ready"; + state.phase = kind === "before-start" ? "preflight" : kind === "ended" ? "report" : "live"; + state.paused = kind === "paused"; + frameworkRound = kind === "behavioral" ? "behavioral" : "coding"; + paintBoard(); + }, +}; +`; + +before(async () => { + browser = await launchChromium(); + if (!browser) return; + ({ server, base } = await startStaticServer({ + runtimeConfig: DEFAULT_RUNTIME_CONFIG.replace( + "CODETRIAL_RECORDING_ENABLED = false", + "CODETRIAL_RECORDING_ENABLED = true", + ), + handle(request, response, url) { + if (url.pathname === "/interview.js") { + response.setHeader("content-type", "text/javascript"); + response.end(read("web/interview.js") + setup); + return true; + } + if (url.pathname === "/api/session") { + response.setHeader("content-type", "application/json"); + response.end('{"signedIn":false}'); + return true; + } + return false; + }, + })); +}); + +after(async () => { + await browser?.close(); + await new Promise((resolve) => (server ? server.close(resolve) : resolve())); +}); + +async function boardPage(t, mac = false) { + if (!browser) { + t.skip("playwright chromium unavailable"); + return null; + } + const page = await browser.newPage({ + viewport: { width: 1440, height: 1000 }, + }); + t.after(() => page.close()); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + t.after(() => assert.deepEqual(errors, [])); + await page.addInitScript((mac) => { + Object.defineProperty(navigator, "platform", { + value: mac ? "MacIntel" : "Linux x86_64", + }); + }, mac); + const events = []; + await page.route("**/*", async (route) => { + const url = route.request().url(); + if (url === `${base}/api/interviews/shortcut-test/events`) { + events.push(...route.request().postDataJSON().events); + await page.evaluate((count) => { + globalThis.recordedBoardOps = count; + }, ops(events).length); + return route.fulfill({ status: 200, body: "{}" }); + } + return url.startsWith(base + "/") ? route.continue() : route.abort(); + }); + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&mode=whiteboard`, + ); + await page.waitForFunction(() => Boolean(globalThis.boardTest)); + await page.evaluate(() => { + globalThis.boardTest.start(); + window.addEventListener("keydown", (event) => { + globalThis.keyTaken = event.defaultPrevented; + }); + }); + return { page, events }; +} + +const inspect = (page) => page.evaluate(() => globalThis.boardTest.inspect()); +const scrollPosition = (page) => + page.evaluate(() => { + const panel = document.querySelector("#board-panel"); + const surface = document.querySelector(".board-surface"); + return [ + window.scrollX, + window.scrollY, + panel.scrollLeft, + panel.scrollTop, + surface.scrollLeft, + surface.scrollTop, + ]; + }); +const ops = (events) => + events + .filter((event) => event.kind === "board") + .flatMap((event) => event.payload.ops); + +async function draw(page, offset = 0, dot = false) { + const canvas = page.locator("#board"); + await canvas.scrollIntoViewIfNeeded(); + const box = await canvas.boundingBox(); + await page.mouse.move(box.x + 40 + offset, box.y + 40); + await page.mouse.down(); + if (!dot) + await page.mouse.move(box.x + 90 + offset, box.y + 70, { steps: 3 }); + await page.mouse.up(); +} + +async function expectBoardUpload(page, previous, strokes) { + await page.waitForFunction((previous) => { + const test = globalThis.boardTest; + return ( + !test.settling() && + test.uploads.length > previous && + test.uploads.slice(previous).every((upload) => upload.closed) + ); + }, previous); + await page.evaluate(() => globalThis.boardTest.waitUploads()); + const sent = await page.evaluate(async () => { + const upload = globalThis.boardTest.uploads.at(-1); + const image = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(new Blob(upload.chunks, { type: "image/jpeg" })); + }); + return { + count: globalThis.boardTest.uploads.length, + options: upload.options, + bytes: upload.bytes, + image, + expected: globalThis.boardTest.jpeg(), + }; + }); + assert.ok(sent.bytes > 0); + assert.equal(sent.options.totalSize, sent.bytes); + assert.equal(sent.options.topic, "board_image"); + assert.equal(sent.options.mimeType, "image/jpeg"); + assert.equal(sent.options.attributes.strokes, String(strokes)); + assert.equal(sent.image, sent.expected); + return sent.count; +} + +for (const mac of [false, true]) { + for (const redo of mac + ? ["Meta+Shift+z"] + : ["Control+y", "Control+Shift+z"]) { + test(`${mac ? "Cmd" : "Ctrl"} shortcuts undo and redo through paint, sync and replay (${redo})`, async (t) => { + const ctx = await boardPage(t, mac); + if (!ctx) return; + const { page, events } = ctx; + await draw(page); + await draw(page, 80, true); + const drawn = await inspect(page); + assert.equal(drawn.strokes.length, 2); + let uploads = await expectBoardUpload(page, 0, 2); + await page.waitForFunction(() => globalThis.recordedBoardOps >= 2); + assert.deepEqual( + ops(events).map((op) => op.op), + ["stroke", "stroke"], + ); + // Focus separately so a missing drawing-focus change cannot hide a + // missing keyboard binding in the pre-implementation failure. + await page.locator("#board-undo").focus(); + await page.keyboard.press(mac ? "Meta+z" : "Control+z"); + const undone = await inspect(page); + assert.equal(undone.strokes.length, 1); + assert.notEqual(undone.image, drawn.image); + assert.equal(undone.focus, "board-undo"); + assert.equal(await page.evaluate(() => globalThis.keyTaken), true); + uploads = await expectBoardUpload(page, uploads, 1); + await page.waitForFunction(() => globalThis.recordedBoardOps >= 3); + assert.deepEqual( + ops(events).map((op) => op.op), + ["stroke", "stroke", "undo"], + ); + await page.keyboard.press(redo); + assert.deepEqual((await inspect(page)).strokes, drawn.strokes); + assert.equal((await inspect(page)).image, drawn.image); + await expectBoardUpload(page, uploads, 2); + // Observe the batch itself: a request wait attached after a slow draw + // could miss a batch that already arrived on a loaded CI runner. + await page.waitForFunction(() => globalThis.recordedBoardOps >= 4); + assert.deepEqual( + ops(events).map((op) => op.op), + ["stroke", "stroke", "undo", "redo"], + ); + const replay = await page.evaluate(async (operations) => { + const { createBoard, applyOp } = await import("/whiteboard.js"); + const model = createBoard(); + for (const op of operations) applyOp(model, op); + return model.strokes(); + }, ops(events)); + assert.deepEqual(replay, drawn.strokes); + for (const [id, chord] of [ + ["undo", mac ? "Meta+Z" : "Control+Z"], + ["redo", mac ? "Meta+Shift+Z" : "Control+Y Control+Shift+Z"], + ]) { + assert.equal( + await page.locator(`#board-${id}`).getAttribute("aria-keyshortcuts"), + chord, + ); + assert.ok( + (await page.locator(`#board-${id}`).getAttribute("title")).includes( + mac ? "Cmd" : "Ctrl", + ), + ); + if (id === "redo" && !mac) { + assert.ok( + (await page.locator("#board-redo").getAttribute("title")).includes( + "Ctrl+Shift+Z", + ), + ); + } + } + }); + } + test(`${mac ? "Cmd" : "Ctrl"} shortcuts preserve Latin keys and fall back for non-Latin layouts`, async (t) => { + const ctx = await boardPage(t, mac); + if (!ctx) return; + const { page, events } = ctx; + await draw(page); + await draw(page, 80); + const drawn = (await inspect(page)).strokes; + const dispatch = (extra) => + page.evaluate( + ({ mac, extra }) => { + const { target = "#board", handled, ...options } = extra; + const event = new KeyboardEvent("keydown", { + key: "\u044f", + code: "KeyZ", + ctrlKey: !mac, + metaKey: mac, + bubbles: true, + cancelable: true, + ...options, + }); + if (handled) event.preventDefault(); + document.querySelector(target).dispatchEvent(event); + return event.defaultPrevented; + }, + { mac, extra }, + ); + assert.equal(await dispatch({ key: "w", code: "KeyZ" }), false); + assert.deepEqual((await inspect(page)).strokes, drawn); + assert.equal(await dispatch({ key: "Z", code: "KeyW" }), true); + assert.equal((await inspect(page)).strokes.length, 1); + assert.equal( + await dispatch({ + key: mac ? "Z" : "Y", + code: mac ? "KeyY" : "KeyZ", + shiftKey: mac, + }), + true, + ); + assert.deepEqual((await inspect(page)).strokes, drawn); + if (!mac) { + assert.equal(await dispatch({}), true); + assert.equal( + await dispatch({ key: "\u042f", code: "KeyZ", shiftKey: true }), + true, + ); + assert.deepEqual((await inspect(page)).strokes, drawn); + } + assert.equal(await dispatch({}), true); + assert.equal((await inspect(page)).strokes.length, 1); + assert.equal( + await dispatch({ + key: mac ? "\u042f" : "\u043d", + code: mac ? "KeyZ" : "KeyY", + shiftKey: mac, + }), + true, + ); + assert.deepEqual((await inspect(page)).strokes, drawn); + for (const extra of [ + { repeat: true }, + { isComposing: true }, + { keyCode: 229 }, + { altKey: true }, + { [mac ? "ctrlKey" : "metaKey"]: true }, + { handled: true }, + { code: "KeyX" }, + { code: "" }, + ]) { + assert.equal(await dispatch(extra), Boolean(extra.handled)); + assert.deepEqual((await inspect(page)).strokes, drawn); + } + await page.evaluate(() => { + const field = document.createElement("input"); + field.id = "layout-text"; + document.querySelector(".board-toolbar").append(field); + field.focus(); + }); + assert.equal(await dispatch({ target: "#layout-text" }), false); + await page.locator("#end").focus(); + assert.equal(await dispatch({ target: "#end" }), false); + assert.deepEqual((await inspect(page)).strokes, drawn); + await page.waitForFunction( + (count) => globalThis.recordedBoardOps >= count, + mac ? 6 : 8, + ); + assert.deepEqual( + ops(events).map((op) => op.op), + [ + "stroke", + "stroke", + "undo", + "redo", + "undo", + "redo", + ...(!mac ? ["undo", "redo"] : []), + ], + ); + }); + + test(`${mac ? "Cmd" : "Ctrl"} shortcuts ignore ASCII punctuation and named keys`, async (t) => { + const ctx = await boardPage(t, mac); + if (!ctx) return; + const { page, events } = ctx; + await draw(page); + await draw(page, 80); + await page.keyboard.press(mac ? "Meta+z" : "Control+z"); + const before = await inspect(page); + assert.equal(before.strokes.length, 1); + assert.equal(before.canRedo, true); + + const keys = [ + ...Array.from({ length: 95 }, (_, i) => String.fromCharCode(0x20 + i)), + "Dead", + "Unidentified", + "Enter", + "F1", + "", + "\x00", + "\x1f", + "\x7f", + ].filter((key) => !/^[zy]$/i.test(key)); + for (const key of keys) { + for (const [redo, shift] of mac + ? [ + [false, false], + [true, true], + ] + : [ + [false, false], + [true, false], + [true, true], + ]) { + const taken = await page.evaluate( + ({ key, mac, redo, shift }) => { + const event = new KeyboardEvent("keydown", { + key, + code: redo && !mac && !shift ? "KeyY" : "KeyZ", + ctrlKey: !mac, + metaKey: mac, + shiftKey: shift, + bubbles: true, + cancelable: true, + }); + // Observe the board handler without running unrelated page chords. + document + .querySelector("#board-panel") + .addEventListener("keydown", (event) => event.stopPropagation(), { + once: true, + }); + document.querySelector("#board").dispatchEvent(event); + return event.defaultPrevented; + }, + { key, mac, redo, shift }, + ); + assert.equal(taken, false, JSON.stringify({ key, redo })); + const after = await inspect(page); + assert.deepEqual(after.strokes, before.strokes); + assert.equal(after.canRedo, before.canRedo); + assert.equal(after.image, before.image); + assert.equal(after.focus, before.focus); + } + } + await page.waitForFunction(() => globalThis.recordedBoardOps >= 3); + assert.deepEqual( + ops(events).map((op) => op.op), + ["stroke", "stroke", "undo"], + ); + }); +} + +test("an accepted drawing start focuses the canvas without scrolling", async (t) => { + const ctx = await boardPage(t); + if (!ctx) return; + const { page } = ctx; + // Keep the canvas partially visible, with its center outside the surface, + // so ordinary focus would scroll it even though the pointer can reach it. + await page.addStyleTag({ + content: ` + .board-surface { flex: none; width: 240px; height: 180px; } + #board { + min-width: 0; width: 200px; height: 125px; + margin-left: 180px; margin-top: 140px; + } + `, + }); + await page.locator(".board-toolbar button").first().focus(); + const surface = page.locator(".board-surface"); + await surface.scrollIntoViewIfNeeded(); + await surface.evaluate((node) => node.scrollTo(40, 40)); + const box = await page.locator("#board").boundingBox(); + await page.mouse.move(box.x + 20, box.y + 20); + const before = await scrollPosition(page); + try { + await page.mouse.down(); + const started = await inspect(page); + assert.equal(started.drawing, true); + assert.equal(started.focus, "board"); + await page.evaluate(() => new Promise(requestAnimationFrame)); + assert.deepEqual(await scrollPosition(page), before); + } finally { + await page.mouse.up(); + } +}); + +for (const mac of [false, true]) { + test(`${mac ? "Cmd" : "Ctrl"} toolbar edits retain focus until their button is disabled`, async (t) => { + const ctx = await boardPage(t, mac); + if (!ctx) return; + const { page } = ctx; + for (const offset of [0, 40, 80]) await draw(page, offset, true); + + for (const action of ["undo", "redo"]) { + const button = page.locator(`#board-${action}`); + await button.focus(); + const before = await scrollPosition(page); + for (let press = 1; press <= 3; press++) { + await page.keyboard.press("Enter"); + const edited = await inspect(page); + assert.equal( + edited.strokes.length, + action === "undo" ? 3 - press : press, + ); + assert.equal(edited.focus, press === 3 ? "board" : `board-${action}`); + assert.equal(await button.isDisabled(), press === 3); + assert.deepEqual(await scrollPosition(page), before); + } + } + + await page.locator("#board-clear").focus(); + const before = await scrollPosition(page); + await page.keyboard.press("Enter"); + assert.equal((await inspect(page)).strokes.length, 0); + assert.equal((await inspect(page)).focus, "board"); + assert.equal(await page.locator("#board-clear").isDisabled(), true); + assert.deepEqual(await scrollPosition(page), before); + + await page.locator("#board-undo").focus(); + await page.keyboard.press(mac ? "Meta+z" : "Control+z"); + assert.equal((await inspect(page)).strokes.length, 3); + assert.equal((await inspect(page)).focus, "board-undo"); + await page.keyboard.press(mac ? "Meta+Shift+z" : "Control+y"); + assert.equal((await inspect(page)).strokes.length, 0); + assert.equal((await inspect(page)).focus, "board-undo"); + assert.deepEqual(await scrollPosition(page), before); + }); +} + +test("Tab focuses the canvas; keyboard and button edits keep it usable", async (t) => { + const ctx = await boardPage(t); + if (!ctx) return; + const { page } = ctx; + await draw(page); + assert.equal((await inspect(page)).focus, "board"); + await page.locator("#board-clear").focus(); + await page.keyboard.press("Tab"); + assert.equal((await inspect(page)).focus, "board"); + assert.equal( + await page + .locator("#board") + .evaluate( + (canvas) => + canvas.matches(":focus-visible") && + getComputedStyle(canvas).outlineStyle !== "none", + ), + true, + ); + const before = await scrollPosition(page); + await page.keyboard.press("Control+z"); + assert.deepEqual(await scrollPosition(page), before); + assert.equal((await inspect(page)).focus, "board"); + assert.equal(await page.locator("#board-undo").isDisabled(), true); + await page.keyboard.press("Control+y"); + for (const id of ["clear", "undo", "redo", "undo"]) { + await page.locator(`#board-${id}`).click(); + const disabled = await page.locator(`#board-${id}`).isDisabled(); + assert.equal( + (await inspect(page)).focus, + disabled ? "board" : `board-${id}`, + id, + ); + } + assert.equal((await inspect(page)).strokes.length, 1); +}); + +test("eraser gestures, undoable clear and new strokes preserve history semantics", async (t) => { + const ctx = await boardPage(t); + if (!ctx) return; + const { page } = ctx; + await draw(page); + await page.locator("#board-eraser").click(); + await draw(page); + const erased = await inspect(page); + assert.equal(erased.strokes.length, 2); + await page.keyboard.press("Control+z"); + assert.equal((await inspect(page)).strokes.length, 1); + await page.keyboard.press("Control+y"); + assert.equal((await inspect(page)).image, erased.image); + await page.locator("#board-clear").click(); + assert.equal((await inspect(page)).strokes.length, 0); + await page.keyboard.press("Control+z"); + assert.deepEqual((await inspect(page)).strokes, erased.strokes); + await page.keyboard.press("Control+y"); + assert.equal((await inspect(page)).strokes.length, 0); + await page.keyboard.press("Control+z"); + await page.keyboard.press("Control+z"); + await draw(page, 140); + assert.equal((await inspect(page)).canRedo, false); + const before = await inspect(page); + await page.keyboard.press("Control+y"); + assert.deepEqual((await inspect(page)).strokes, before.strokes); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false); +}); + +for (const mac of [false, true]) { + const chords = mac + ? ["Meta+z", "Meta+Shift+z"] + : ["Control+z", "Control+y", "Control+Shift+z"]; + + test(`${mac ? "Cmd" : "Ctrl"} locked boards and unfinished strokes refuse shortcuts`, async (t) => { + const ctx = await boardPage(t, mac); + if (!ctx) return; + const { page } = ctx; + await draw(page); + await draw(page, 80); + await page.keyboard.press(chords[0]); + const before = (await inspect(page)).strokes; + assert.equal(before.length, 1); + assert.equal((await inspect(page)).canRedo, true); + for (const kind of [ + "not-ready", + "before-start", + "paused", + "behavioral", + "ended", + ]) { + await page.evaluate((kind) => globalThis.boardTest.lock(kind), kind); + await page.locator("#board").focus(); + for (const chord of chords) { + await page.keyboard.press(chord); + assert.deepEqual( + (await inspect(page)).strokes, + before, + `${kind}: ${chord}`, + ); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false); + } + } + await page.evaluate(() => globalThis.boardTest.lock("live")); + const box = await page.locator("#board").boundingBox(); + await page.mouse.move(box.x + 80, box.y + 80); + await page.mouse.down(); + const drawing = (await inspect(page)).strokes; + for (const chord of chords) { + await page.keyboard.press(chord); + assert.equal((await inspect(page)).drawing, true); + assert.deepEqual((await inspect(page)).strokes, drawing); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false); + } + await page.mouse.up(); + assert.equal((await inspect(page)).strokes.length, 2, "no queued edit"); + }); + + test(`${mac ? "Cmd" : "Ctrl"} empty history and guarded chords add no operation`, async (t) => { + const ctx = await boardPage(t, mac); + if (!ctx) return; + const { page, events } = ctx; + await page.locator("#board").focus(); + for (const chord of chords) { + await page.keyboard.press(chord); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false); + } + await draw(page); + await draw(page, 80); + await page.keyboard.press(chords[0]); + await page.waitForFunction(() => globalThis.recordedBoardOps >= 3); + assert.deepEqual( + ops(events).map((op) => op.op), + ["stroke", "stroke", "undo"], + ); + const before = (await inspect(page)).strokes; + assert.equal(before.length, 1); + assert.equal((await inspect(page)).canRedo, true); + const bindings = mac + ? [ + { key: "z", shiftKey: false }, + { key: "z", shiftKey: true }, + ] + : [ + { key: "z", shiftKey: false }, + { key: "y", shiftKey: false }, + { key: "z", shiftKey: true }, + ]; + for (const binding of bindings) { + for (const extra of [ + { repeat: true }, + { isComposing: true }, + { keyCode: 229 }, + { altKey: true }, + { [mac ? "ctrlKey" : "metaKey"]: true }, + { key: "x" }, + { handled: true }, + ]) { + const taken = await page.evaluate( + ({ mac, binding, extra }) => { + const { handled, ...options } = extra; + const event = new KeyboardEvent("keydown", { + ctrlKey: !mac, + metaKey: mac, + bubbles: true, + cancelable: true, + ...binding, + ...options, + }); + if (handled) event.preventDefault(); + document.querySelector("#board").dispatchEvent(event); + return event.defaultPrevented; + }, + { mac, binding, extra }, + ); + assert.equal(taken, Boolean(extra.handled)); + assert.deepEqual((await inspect(page)).strokes, before); + assert.equal((await inspect(page)).canRedo, true); + } + } + for (const chord of mac + ? ["Meta+y", "Meta+Shift+y", "Control+z", "Control+Shift+z"] + : ["Control+Shift+y", "Meta+z", "Meta+Shift+z"]) { + await page.keyboard.press(chord); + assert.equal( + await page.evaluate(() => globalThis.keyTaken), + false, + chord, + ); + assert.deepEqual((await inspect(page)).strokes, before, chord); + assert.equal((await inspect(page)).canRedo, true); + } + const modifier = mac ? "Meta" : "Control"; + await page.keyboard.down(modifier); + await page.keyboard.down("z"); + await page.keyboard.down("z"); + await page.keyboard.up("z"); + await page.keyboard.up(modifier); + assert.equal((await inspect(page)).strokes.length, 0); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false); + await page.waitForFunction(() => globalThis.recordedBoardOps >= 4); + assert.deepEqual( + ops(events).map((op) => op.op), + ["stroke", "stroke", "undo", "undo"], + ); + }); +} + +test("text targets and focus outside the drawing toolbar keep their own undo and redo", async (t) => { + const ctx = await boardPage(t); + if (!ctx) return; + const { page } = ctx; + await draw(page); + await draw(page, 80); + await page.keyboard.press("Control+z"); + assert.equal((await inspect(page)).canRedo, true); + for (const kind of ["input", "textarea", "editable"]) { + await page.evaluate((kind) => { + document.querySelector("#text-target")?.remove(); + const field = document.createElement(kind === "editable" ? "div" : kind); + field.id = "text-target"; + if (kind === "editable") field.contentEditable = "true"; + document.querySelector("#board-panel .board-toolbar").append(field); + field.focus(); + }, kind); + await page.keyboard.type("abc"); + await page.keyboard.press("Control+z"); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false, kind); + assert.equal( + await page + .locator("#text-target") + .evaluate((field) => field.value ?? field.textContent), + "", + kind, + ); + await page.keyboard.press("Control+Shift+z"); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false, kind); + assert.equal( + await page + .locator("#text-target") + .evaluate((field) => field.value ?? field.textContent), + "abc", + kind, + ); + assert.equal((await inspect(page)).strokes.length, 1); + } + await page.locator("#end").focus(); + for (const chord of ["Control+z", "Control+y", "Control+Shift+z"]) { + await page.keyboard.press(chord); + assert.equal((await inspect(page)).strokes.length, 1); + assert.equal((await inspect(page)).canRedo, true); + assert.equal(await page.evaluate(() => globalThis.keyTaken), false); + } +}); diff --git a/web/interview.html b/web/interview.html index d4d27726..f8b3c0c8 100644 --- a/web/interview.html +++ b/web/interview.html @@ -411,6 +411,7 @@

Loading interview...

- applyBoardEdit(board.model.undo()), - ); - nodes.boardRedo.addEventListener("click", () => - applyBoardEdit(board.model.redo()), - ); - nodes.boardClear.addEventListener("click", () => - applyBoardEdit(board.model.clear()), - ); + const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform); + for (const [button, action, shortcut, hint] of [ + [ + nodes.boardUndo, + "undo", + mac ? "Meta+Z" : "Control+Z", + mac ? "Cmd+Z" : "Ctrl+Z", + ], + [ + nodes.boardRedo, + "redo", + mac ? "Meta+Shift+Z" : "Control+Y Control+Shift+Z", + mac ? "Cmd+Shift+Z" : "Ctrl+Y / Ctrl+Shift+Z", + ], + [nodes.boardClear, "clear"], + ]) { + button.addEventListener("click", () => editBoard(action)); + if (shortcut) { + button.setAttribute("aria-keyshortcuts", shortcut); + button.title = `${button.textContent.trim()} (${hint})`; + } + } + nodes.boardPanel.addEventListener("keydown", (event) => { + if ( + event.defaultPrevented || + event.repeat || + event.isComposing || + event.keyCode === 229 || + event.altKey || + (mac ? !event.metaKey || event.ctrlKey : !event.ctrlKey || event.metaKey) + ) + return; + const target = event.target; + if ( + !(target instanceof HTMLElement) || + target.isContentEditable || + target.closest('input, textarea, select, [role="textbox"]') || + (target !== nodes.board && !target.closest(".board-toolbar")) + ) + return; + const printable = /^[\x20-\x7e]$/.test(event.key); + if (!printable && !/^[^\x00-\x7f]$/u.test(event.key)) return; + const key = printable + ? event.key.toLowerCase() + : event.code.replace(/^Key/, "").toLowerCase(); + let action = ""; + if (key === "z" && !event.shiftKey) action = "undo"; + else if ( + (key === "z" && event.shiftKey) || + (!mac && key === "y" && !event.shiftKey) + ) + action = "redo"; + if (action && editBoard(action)) event.preventDefault(); + }); bindBoardPointer(); paintBoard(); } @@ -4147,6 +4192,7 @@ function bindBoardPointer() { board.settle = null; nodes.board.setPointerCapture(event.pointerId); event.preventDefault(); + nodes.board.focus({ preventScroll: true }); paintBoard(); }); nodes.board.addEventListener("pointermove", (event) => { @@ -4227,15 +4273,26 @@ function paintBoardCursor() { nodes.board.style.cursor = `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${Math.round(centre)} ${Math.round(centre)}, crosshair`; } +/// Button and keyboard edits share the pointer's editing restrictions. +function editBoard(action) { + if (boardLocked() || board.model.isDrawing()) return false; + return applyBoardEdit(board.model[action]()); +} + /// Repaints after an edit that changed something, and sends the board on. /// /// Undo, redo and clear all go through here because all three change what the /// interviewer should be looking at. A clear that was not published leaves /// them asking about a diagram that is no longer on the board. function applyBoardEdit(changed) { - if (!changed) return; + if (!changed) return false; + // A button the repaint disables would drop focus to the page. + const owner = document.activeElement; paintBoard(); + if (owner === document.body || owner?.disabled) + nodes.board.focus({ preventScroll: true }); scheduleBoardPublish(); + return true; } function paintBoard() {