Skip to content

Commit 70b5820

Browse files
committed
Add whiteboard keyboard shortcuts
Allow users to switch between pen, eraser, and text tools using keyboard shortcuts (P, E, and T), reducing reliance on toolbar clicks and improving interaction efficiency. Repeated P presses cycle through pen colors, while repeated E presses cycle through eraser sizes. Pressing T toggles between the text tool and the previously selected pen or eraser. Add always-visible shortcut labels beside the corresponding toolbar controls to improve discoverability. Ignore shortcuts while typing in text inputs or editing text labels, on repeated keydown events, and when the whiteboard is inactive to prevent unintended tool changes. Change-Id: I58c3d5051267641f928724d0d722ed997f16beff
1 parent 628237f commit 70b5820

5 files changed

Lines changed: 216 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,9 @@ what you can draw, say aloud and defend by tracing an example by hand.
240240

241241
| Control | What it does |
242242
|---|---|
243-
| Pen colors | Black, red, blue and green. Write in black and mark up in the others. |
244-
| 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. |
245-
| Text | Click or drag on the board to create a text draft. Enter inserts a newline; Ctrl+Enter or Confirm places the label; Escape or Cancel discards it. Committed labels cannot be edited, moved or resized. |
243+
| Pen colors | Black, red, blue and green. Write in black and mark up in the others. With focus on the canvas or drawing toolbar, press P to select the pen, or press P while the pen is selected to cycle through colors. |
244+
| 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. With focus on the canvas or drawing toolbar, press E to select the eraser, or press E while the eraser is selected to cycle through sizes. Pick a pen color, or press Eraser again, to draw again. |
245+
| Text | With focus on the canvas or drawing toolbar, press T to toggle the text tool and the previously selected pen or eraser. Click or drag on the board to create a text draft. Enter inserts a newline; Ctrl+Enter or Confirm places the label; Escape or Cancel discards it. Committed labels cannot be edited, moved or resized. |
246246
| Undo, Redo | Step back and forward one stroke, erasure or confirmed text label 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 reaches the drawing toolbar; the canvas is not a Tab stop. |
247247
| Clear board | Wipes the board in one step, which Undo brings back. |
248248

‎tests/browser/whiteboard-shortcuts.test.js‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,3 +844,156 @@ test("text targets and focus outside the drawing toolbar keep their own undo and
844844
assert.equal(await page.evaluate(() => globalThis.keyTaken), false);
845845
}
846846
});
847+
848+
test("P, E, and T shortcuts respect text, lock, and drawing states", async (t) => {
849+
const ctx = await boardPage(t);
850+
if (!ctx) return;
851+
const { page } = ctx;
852+
const board = page.locator("#board");
853+
const pressed = (name) =>
854+
page
855+
.getByRole("button", { name, exact: true })
856+
.getAttribute("aria-pressed");
857+
858+
await board.focus();
859+
860+
for (const [key, name] of [
861+
["p", "Red pen"],
862+
["p", "Blue pen"],
863+
["p", "Green pen"],
864+
["p", "Black pen"],
865+
]) {
866+
await page.keyboard.press(key);
867+
assert.equal(await pressed(name), "true", `${key} selects ${name}`);
868+
}
869+
870+
await page.evaluate(() => {
871+
document.querySelector("#board").dispatchEvent(
872+
new KeyboardEvent("keydown", {
873+
key: "p",
874+
repeat: true,
875+
bubbles: true,
876+
cancelable: true,
877+
}),
878+
);
879+
});
880+
assert.equal(
881+
await pressed("Black pen"),
882+
"true",
883+
"held keys do not cycle colors",
884+
);
885+
886+
const modifiedShortcutHandled = await page.evaluate(() => {
887+
const event = new KeyboardEvent("keydown", {
888+
key: "p",
889+
ctrlKey: true,
890+
bubbles: true,
891+
cancelable: true,
892+
});
893+
document.querySelector("#board").dispatchEvent(event);
894+
return event.defaultPrevented;
895+
});
896+
assert.equal(
897+
modifiedShortcutHandled,
898+
false,
899+
"modified keys remain available to the browser",
900+
);
901+
assert.equal(await pressed("Black pen"), "true");
902+
903+
await page.getByRole("button", { name: "Eraser", exact: true }).focus();
904+
await page.keyboard.press("p");
905+
assert.equal(
906+
await pressed("Red pen"),
907+
"true",
908+
"shortcuts also work from the toolbar",
909+
);
910+
for (const name of ["Blue pen", "Green pen", "Black pen"]) {
911+
await page.keyboard.press("p");
912+
assert.equal(
913+
await pressed(name),
914+
"true",
915+
`P selects ${name} from the toolbar`,
916+
);
917+
}
918+
919+
await page.keyboard.press("e");
920+
assert.equal(await pressed("Eraser"), "true");
921+
assert.equal(await pressed("Medium eraser"), "true");
922+
for (const name of ["Large eraser", "Small eraser", "Medium eraser"]) {
923+
await page.keyboard.press("e");
924+
assert.equal(await pressed(name), "true", `E selects ${name}`);
925+
}
926+
927+
await page.keyboard.press("t");
928+
assert.equal(await pressed("Text"), "true");
929+
await page.keyboard.press("t");
930+
assert.equal(
931+
await pressed("Eraser"),
932+
"true",
933+
"T returns to the previous tool",
934+
);
935+
936+
await page.keyboard.press("p");
937+
assert.equal(await pressed("Black pen"), "true");
938+
await page.keyboard.press("t");
939+
assert.equal(await pressed("Text"), "true");
940+
await page.keyboard.press("t");
941+
assert.equal(
942+
await pressed("Black pen"),
943+
"true",
944+
"T returns to the previous pen",
945+
);
946+
947+
await page.keyboard.press("t");
948+
const box = await board.boundingBox();
949+
await page.mouse.click(box.x + 100, box.y + 100);
950+
const input = page.locator(".board-text-input");
951+
await input.waitFor();
952+
await page.keyboard.type("pet");
953+
assert.equal(await input.inputValue(), "pet");
954+
assert.equal(await pressed("Text"), "true", "typing does not switch tools");
955+
956+
await page.locator(".board-text-cancel").click();
957+
await page.evaluate(() => globalThis.boardTest.lock("ended"));
958+
await board.focus();
959+
for (const key of ["p", "e", "t"]) {
960+
await page.keyboard.press(key);
961+
assert.equal(
962+
await pressed("Text"),
963+
"true",
964+
`${key} is ignored while locked`,
965+
);
966+
assert.equal(
967+
await page.evaluate(() => globalThis.keyTaken),
968+
false,
969+
`${key} is not consumed while locked`,
970+
);
971+
}
972+
973+
await page.evaluate(() => globalThis.boardTest.lock("live"));
974+
await page.getByRole("button", { name: "Black pen", exact: true }).click();
975+
await board.focus();
976+
const boardBox = await board.boundingBox();
977+
await page.mouse.move(boardBox.x + 80, boardBox.y + 80);
978+
await page.mouse.down();
979+
for (const key of ["p", "e", "t"]) {
980+
await page.keyboard.press(key);
981+
assert.equal(
982+
await pressed("Black pen"),
983+
"true",
984+
`${key} is ignored while drawing`,
985+
);
986+
assert.equal(
987+
await pressed("Eraser"),
988+
"false",
989+
`${key} does not select eraser`,
990+
);
991+
assert.equal(await pressed("Text"), "false", `${key} does not select text`);
992+
assert.equal(
993+
await page.evaluate(() => globalThis.keyTaken),
994+
false,
995+
`${key} is not consumed while drawing`,
996+
);
997+
}
998+
await page.mouse.up();
999+
});

‎web/interview.html‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,14 +483,17 @@ <h1 id="problem-title">Loading interview...</h1>
483483
class="board-pens"
484484
role="group"
485485
aria-label="Pen colour"
486+
aria-keyshortcuts="P"
486487
></div>
488+
<kbd class="board-shortcut-key" aria-hidden="true">P</kbd>
487489
<button
488490
id="board-eraser"
489491
class="board-tool"
490492
type="button"
491493
aria-pressed="false"
494+
aria-keyshortcuts="E"
492495
>
493-
Eraser
496+
Eraser <kbd class="board-shortcut-key" aria-hidden="true">E</kbd>
494497
</button>
495498
<!-- Filled from ERASER_WIDTHS like the pens are from PEN_COLORS.
496499
Picking a size picks the eraser too: nobody sizes an eraser they
@@ -507,8 +510,9 @@ <h1 id="problem-title">Loading interview...</h1>
507510
type="button"
508511
aria-pressed="false"
509512
title="Drag on the board to create a text label"
513+
aria-keyshortcuts="T"
510514
>
511-
Text
515+
Text <kbd class="board-shortcut-key" aria-hidden="true">T</kbd>
512516
</button>
513517
<button id="board-undo" class="board-tool" type="button">Undo</button>
514518
<button id="board-redo" class="board-tool" type="button">Redo</button>

‎web/interview.js‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ const board = {
408408
}),
409409
color: PEN_COLORS[0].color,
410410
tool: "pen",
411+
previousNonTextTool: "pen",
411412
eraserWidth: ERASER_WIDTH,
412413
/// Whether the board takes edits yet; see `boardLocked`.
413414
ready: false,
@@ -5506,9 +5507,7 @@ function initWhiteboard() {
55065507
event.defaultPrevented ||
55075508
event.repeat ||
55085509
event.isComposing ||
5509-
event.keyCode === 229 ||
5510-
event.altKey ||
5511-
(mac ? !event.metaKey || event.ctrlKey : !event.ctrlKey || event.metaKey)
5510+
event.keyCode === 229
55125511
)
55135512
return;
55145513
const target = event.target;
@@ -5519,11 +5518,45 @@ function initWhiteboard() {
55195518
(target !== nodes.board && !target.closest(".board-toolbar"))
55205519
)
55215520
return;
5521+
55225522
const printable = /^[\x20-\x7e]$/.test(event.key);
55235523
if (!printable && !/^[^\x00-\x7f]$/u.test(event.key)) return;
55245524
const key = printable
55255525
? event.key.toLowerCase()
55265526
: event.code.replace(/^Key/, "").toLowerCase();
5527+
5528+
if (!event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
5529+
if (boardLocked() || board.model.isDrawing()) return;
5530+
if (key === "p") {
5531+
if (board.tool !== "pen") {
5532+
selectPen(board.color);
5533+
} else {
5534+
const index = PEN_COLORS.findIndex(
5535+
({ color }) => color === board.color,
5536+
);
5537+
selectPen(PEN_COLORS[(index + 1) % PEN_COLORS.length].color);
5538+
}
5539+
} else if (key === "e") {
5540+
if (board.tool !== "eraser") {
5541+
selectTool("eraser");
5542+
} else {
5543+
const index = ERASER_WIDTHS.indexOf(board.eraserWidth);
5544+
selectEraserWidth(ERASER_WIDTHS[(index + 1) % ERASER_WIDTHS.length]);
5545+
}
5546+
} else if (key === "t") {
5547+
selectTool(board.tool === "text" ? board.previousNonTextTool : "text");
5548+
} else {
5549+
return;
5550+
}
5551+
event.preventDefault();
5552+
return;
5553+
}
5554+
5555+
if (
5556+
event.altKey ||
5557+
(mac ? !event.metaKey || event.ctrlKey : !event.ctrlKey || event.metaKey)
5558+
)
5559+
return;
55275560
let action = "";
55285561
if (key === "z" && !event.shiftKey) action = "undo";
55295562
else if (
@@ -5604,19 +5637,18 @@ function bindBoardPointer() {
56045637

56055638
function selectPen(color) {
56065639
board.color = color;
5607-
board.tool = "pen";
5608-
paintToolState();
5640+
selectTool("pen");
56095641
}
56105642

56115643
function selectTool(tool) {
5644+
if (tool !== "text") board.previousNonTextTool = tool;
56125645
board.tool = tool;
56135646
paintToolState();
56145647
}
56155648

56165649
function selectEraserWidth(width) {
56175650
board.eraserWidth = width;
5618-
board.tool = "eraser";
5619-
paintToolState();
5651+
selectTool("eraser");
56205652
}
56215653

56225654
/// The toolbar and the cursor, from `board` alone, so no selector has to

‎web/styles.css‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2420,6 +2420,21 @@ table.phase-scores {
24202420
margin-left: auto;
24212421
}
24222422

2423+
.board-shortcut-key {
2424+
display: inline-grid;
2425+
place-items: center;
2426+
margin-left: 0.35rem;
2427+
min-width: 1.25rem;
2428+
height: 1.25rem;
2429+
padding: 0 0.2rem;
2430+
border: 1px solid var(--hairline);
2431+
border-radius: 0.25rem;
2432+
color: var(--sub);
2433+
font: inherit;
2434+
font-size: 0.7rem;
2435+
line-height: 1;
2436+
}
2437+
24232438
/* The pens sit on a strip of the board's own white, so each swatch looks like
24242439
the ink it draws. On the dark toolbar the black pen was a dark disc on a dark
24252440
ground and could not be found. */

0 commit comments

Comments
 (0)