From 88ce69c7f87ff79c2e46a41d52fd1f052c3cbcb1 Mon Sep 17 00:00:00 2001 From: Chingyong905 Date: Sat, 24 Oct 2026 23:54:10 +0800 Subject: [PATCH] Keep undo history separate for each language Keep editor history per language so Undo cannot replay another buffer. --- tests/browser/captions.test.js | 2 + tests/browser/comment-shortcut.test.js | 6 +- tests/browser/dom-contract.test.js | 4 +- tests/browser/editor-history.test.js | 168 ++++ tests/browser/editor-language-history.test.js | 917 ++++++++++++++++++ tests/browser/editor-pipeline.test.js | 4 +- tests/browser/editor.test.js | 39 +- tests/browser/restore-starter.test.js | 719 ++++---------- web/editor-history.js | 83 ++ web/editor.js | 20 +- web/interview.html | 32 +- web/interview.js | 445 +++++---- web/styles.css | 28 +- 13 files changed, 1699 insertions(+), 768 deletions(-) create mode 100644 tests/browser/editor-history.test.js create mode 100644 tests/browser/editor-language-history.test.js create mode 100644 web/editor-history.js diff --git a/tests/browser/captions.test.js b/tests/browser/captions.test.js index 46a8281f5..db02bea5b 100644 --- a/tests/browser/captions.test.js +++ b/tests/browser/captions.test.js @@ -386,6 +386,8 @@ test("only changed editor text dismisses Jim", async (t) => { nodes, state, dismissCaptions: captions.dismissCaptions, + // History is covered separately; this fixture isolates input side effects. + replayingEditorHistory: true, drawingTurn: null, flushPendingLanguagePublish: () => events.push("language"), scheduleEditorPaint: () => events.push("paint"), diff --git a/tests/browser/comment-shortcut.test.js b/tests/browser/comment-shortcut.test.js index 349486819..ee98848cd 100644 --- a/tests/browser/comment-shortcut.test.js +++ b/tests/browser/comment-shortcut.test.js @@ -181,8 +181,6 @@ test("the comment marker follows the language tab", async (t) => { }); // One toggle over several lines is one edit to take back, not one per line. -// Asked of the browser's own undo stack, which is where a toggle written by -// assigning the textarea's value would have left nothing at all. test("one undo takes a toggled block back", async (t) => { const ctx = await interviewPage(t); if (!ctx) return; @@ -192,7 +190,7 @@ test("one undo takes a toggled block back", async (t) => { await setBuffer(page, code, 0, code.length); await page.keyboard.press("Control+/"); assert.equal(await buffer(page), "# a = 1\n# b = 2\n# c = 3"); - await page.evaluate(() => document.execCommand("undo")); + await page.keyboard.press("ControlOrMeta+Z"); assert.equal(await buffer(page), code); assert.deepEqual(errors, []); } finally { @@ -217,7 +215,7 @@ test("a held chord toggles once", async (t) => { await page.keyboard.up("/"); await page.keyboard.up("Control"); assert.equal(await buffer(page), "# a = 1"); - await page.evaluate(() => document.execCommand("undo")); + await page.keyboard.press("ControlOrMeta+Z"); assert.equal(await buffer(page), "a = 1"); assert.deepEqual(errors, []); } finally { diff --git a/tests/browser/dom-contract.test.js b/tests/browser/dom-contract.test.js index 82e39a0ae..b980ae3b0 100644 --- a/tests/browser/dom-contract.test.js +++ b/tests/browser/dom-contract.test.js @@ -433,9 +433,7 @@ test("rapid language selections publish only the final tab", () => { // is the regression this test exists for. const flushAt = setLanguage.indexOf("flushPendingEditorPublish()"); const languageAt = setLanguage.indexOf("state.language = language;"); - const bufferAt = setLanguage.indexOf( - "nodes.editor.value = state.codeByLanguage[language];", - ); + const bufferAt = setLanguage.indexOf("nodes.editor.value = next.value;"); assert.ok(flushAt >= 0, "setLanguage flushes a pending editor publish"); assert.ok( languageAt > flushAt, diff --git a/tests/browser/editor-history.test.js b/tests/browser/editor-history.test.js new file mode 100644 index 000000000..7c8d5d525 --- /dev/null +++ b/tests/browser/editor-history.test.js @@ -0,0 +1,168 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { loadInterview } from "./source.js"; +import { + createEditorHistory, + editorSnapshot, +} from "../../web/editor-history.js"; + +const snapshot = ( + value, + start = value.length, + end = start, + direction = "none", +) => ({ value, start, end, direction }); + +test("interleaved deletions undo and redo within their own language", () => { + const history = createEditorHistory(); + const python = snapshot("AAA\nBBB"); + const javascript = snapshot("111\n222"); + const pythonDeleted = snapshot("AAA\n"); + const javascriptDeleted = snapshot("111\n"); + history.select("python", python); + history.select("javascript", javascript, python); + history.record(javascript, javascriptDeleted); + history.select("python", python, javascriptDeleted); + history.record(python, pythonDeleted); + history.select("javascript", javascript, pythonDeleted); + assert.deepEqual(history.undo(), javascript); + history.select("python", python, javascript); + assert.deepEqual(history.undo(), python); + history.select("javascript", javascript, python); + assert.deepEqual(history.redo(), javascriptDeleted); + history.select("python", python, javascriptDeleted); + assert.deepEqual(history.redo(), pythonDeleted); +}); + +test("switching without editing neither creates an entry nor loses redo", () => { + const history = createEditorHistory(); + const before = snapshot("before"); + const after = snapshot("after"); + history.select("python", before); + history.record(before, after); + assert.deepEqual(history.undo(), before); + history.select("javascript", snapshot("js"), before); + assert.equal(history.undo(), null); + history.select("python", before, snapshot("js")); + assert.deepEqual(history.redo(), after); +}); + +test("a new edit discards only that language's redo branch", () => { + const history = createEditorHistory(); + history.select("python", snapshot("a")); + history.record(snapshot("a"), snapshot("b")); + history.undo(); + history.select("javascript", snapshot("1"), snapshot("a")); + history.record(snapshot("1"), snapshot("2")); + history.undo(); + history.select("python", snapshot("a"), snapshot("1")); + history.record(snapshot("a"), snapshot("c")); + assert.equal(history.redo(), null); + history.select("javascript", snapshot("1"), snapshot("c")); + assert.deepEqual(history.redo(), snapshot("2")); +}); + +test("undo and redo restore distinct selection bounds and direction", () => { + const history = createEditorHistory(); + const before = snapshot("one\ntwo", 0, 7, "backward"); + const after = snapshot("# one\n# two", 2, 11, "backward"); + history.select("python", before); + history.record(before, after); + assert.deepEqual(history.undo(), before); + assert.deepEqual(history.redo(), after); +}); + +test("each language has its own bounded history", () => { + const history = createEditorHistory({ limit: 2 }); + history.select("python", snapshot("0")); + for (let i = 1; i <= 3; i++) + history.record(snapshot(String(i - 1)), snapshot(String(i))); + history.select("javascript", snapshot("js"), snapshot("3")); + history.record(snapshot("js"), snapshot("changed")); + history.select("python", snapshot("3"), snapshot("changed")); + assert.deepEqual(history.undo(), snapshot("2")); + assert.deepEqual(history.undo(), snapshot("1")); + assert.equal(history.undo(), null); + history.select("javascript", snapshot("changed"), snapshot("1")); + assert.deepEqual(history.undo(), snapshot("js")); +}); + +test("adjacent typing groups but cursor movement and pauses split entries", () => { + const history = createEditorHistory({ groupDelay: 100 }); + history.select("python", snapshot("")); + history.record(snapshot(""), snapshot("a"), "insertText", 0); + history.record(snapshot("a"), snapshot("ab"), "insertText", 50); + history.record(snapshot("ab"), snapshot("abc"), "insertText", 200); + assert.deepEqual(history.undo(), snapshot("ab")); + assert.deepEqual(history.undo(), snapshot("")); + history.redo(); + history.breakGroup(); + history.record(snapshot("ab"), snapshot("abd"), "insertText", 220); + assert.deepEqual(history.undo(), snapshot("ab")); +}); + +test("selection-only edits preserve redo and create no text history", () => { + const history = createEditorHistory(); + history.select("python", snapshot("a")); + history.record(snapshot("a"), snapshot("b")); + history.undo(); + history.record(snapshot("a"), snapshot("a", 0)); + assert.deepEqual(history.redo(), snapshot("b")); +}); + +test("snapshot objects cannot mutate stored history", () => { + const history = createEditorHistory(); + const before = snapshot("before"); + history.select("python", before); + history.record(before, snapshot("after")); + before.value = "mutated"; + const restored = history.undo(); + assert.equal(restored.value, "before"); + restored.value = "mutated again"; + history.redo(); + assert.equal(history.undo().value, "before"); +}); + +test("starter buffer replacement through applyEditorEdit is one undoable edit", () => { + const before = snapshot("candidate solution\n", 0, 9, "backward"); + const starter = "starter\n"; + const inputs = []; + const editor = { + value: before.value, + selectionStart: before.start, + selectionEnd: before.end, + selectionDirection: before.direction, + setSelectionRange(start, end, direction = "none") { + this.selectionStart = start; + this.selectionEnd = end; + this.selectionDirection = direction; + }, + dispatchEvent() { + inputs.push(this.value); + }, + }; + const editorHistory = createEditorHistory(); + editorHistory.select("python", before); + const scope = { + nodes: { editor }, + editorHistory, + editorSnapshot, + editorComposition: null, + editorDrag: null, + compositionTimer: null, + pendingEditorEdit: null, + observedEditor: before, + applyingProgrammaticEdit: false, + clearTimeout, + Event, + }; + const { applyEditorEdit } = loadInterview( + ["applyEditorEdit", "finishEditorComposition", "finishEditorDrag"], + scope, + ); + applyEditorEdit({ value: starter, start: 0, end: 0 }); + assert.deepEqual(inputs, [starter]); + assert.deepEqual(editorHistory.undo(), before); + assert.equal(editorHistory.undo(), null); + assert.deepEqual(editorHistory.redo(), snapshot(starter, 0)); +}); diff --git a/tests/browser/editor-language-history.test.js b/tests/browser/editor-language-history.test.js new file mode 100644 index 000000000..8d976441e --- /dev/null +++ b/tests/browser/editor-language-history.test.js @@ -0,0 +1,917 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { + closeSetupCheck, + DEFAULT_RUNTIME_CONFIG, + launchChromium, + startStaticServer, +} from "./source.js"; + +const LANGUAGES = ["python", "javascript", "c", "cpp", "java"]; + +let browser; +let server; +let base; + +before(async () => { + browser = await launchChromium(); + if (!browser) return; + ({ server, base } = await startStaticServer({ + // Offer compiled languages without running code or contacting a compiler. + runtimeConfig: DEFAULT_RUNTIME_CONFIG.replace( + "COMPILER_EXPLORER_ENABLED = false", + "COMPILER_EXPLORER_ENABLED = true", + ).replace( + 'COMPILER_EXPLORER_BASE_URL = ""', + 'COMPILER_EXPLORER_BASE_URL = "https://compiler.invalid"', + ), + })); +}); + +after(async () => { + await browser?.close(); + if (server) await new Promise((resolve) => server.close(resolve)); +}); + +async function editorPage(t, platform) { + if (!browser) { + t.skip("playwright chromium unavailable"); + return null; + } + const page = await browser.newPage(); + t.after(() => page.close()); + if (platform) { + await page.addInitScript((platform) => { + Object.defineProperty(navigator, "platform", { value: platform }); + }, platform); + } + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + t.after(() => assert.deepEqual(errors, [])); + await page.goto(`${base}/interview.html?problem=chargeback-pair-match`, { + waitUntil: "domcontentloaded", + }); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + const editor = page.locator("#editor"); + const language = page.locator("#language-select"); + const select = async (value) => { + await language.focus(); + await language.selectOption(value); + assert.equal(await language.inputValue(), value); + }; + const append = async (value) => { + await editor.focus(); + await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.insertText(value); + }; + const deleteLast = async () => { + await editor.focus(); + await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.press("Home"); + await page.keyboard.press("Shift+End"); + await page.keyboard.press("Backspace"); + }; + const undo = async (chord = "ControlOrMeta+Z") => { + await editor.focus(); + await page.keyboard.press(chord); + }; + const redo = () => undo("ControlOrMeta+Shift+Z"); + const value = () => editor.inputValue(); + const selection = () => + editor.evaluate((node) => [ + node.selectionStart, + node.selectionEnd, + node.selectionDirection, + ]); + return { + page, + editor, + select, + append, + deleteLast, + undo, + redo, + value, + selection, + }; +} + +test("interleaved deletions undo and redo only in the current language", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { select, append, deleteLast, undo, redo, value } = ctx; + const pythonStarter = await value(); + await append("\nAAA\nBBB"); + const pythonFull = `${pythonStarter}\nAAA\nBBB`; + assert.equal(await value(), pythonFull); + await select("javascript"); + const javascriptStarter = await value(); + await append("\n111\n222"); + const javascriptFull = `${javascriptStarter}\n111\n222`; + assert.equal(await value(), javascriptFull); + await deleteLast(); + const javascriptDeleted = `${javascriptStarter}\n111\n`; + assert.equal(await value(), javascriptDeleted); + await select("python"); + assert.equal(await value(), pythonFull); + await deleteLast(); + const pythonDeleted = `${pythonStarter}\nAAA\n`; + assert.equal(await value(), pythonDeleted); + await select("javascript"); + await undo(); + const javascriptUndo = await value(); + await select("python"); + await undo(); + assert.deepEqual( + { javascript: javascriptUndo, python: await value() }, + { javascript: javascriptFull, python: pythonFull }, + ); + await redo(); + assert.equal(await value(), pythonDeleted); + await select("javascript"); + await redo(); + assert.equal(await value(), javascriptDeleted); +}); + +test("the original five-step deletion remains undoable after switching", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { append, deleteLast, undo, redo, value, select } = ctx; + await append("\nAAA\nBBB"); + const full = await value(); + await deleteLast(); + const deleted = await value(); + await undo(); + assert.equal(await value(), full); + await deleteLast(); + await select("javascript"); + await select("python"); + await undo(); + assert.equal(await value(), full); + await redo(); + assert.equal(await value(), deleted); +}); + +// Platform overrides exercise shortcut routing, not native Cocoa or IME behavior. +for (const platform of ["Win32", "Linux x86_64", "MacIntel"]) { + test(`${platform} history shortcuts preserve platform editing commands`, async (t) => { + const ctx = await editorPage(t, platform); + if (!ctx) return; + const mac = platform === "MacIntel"; + const modifier = mac ? { metaKey: true } : { ctrlKey: true }; + const key = (key, extra = {}) => + ctx.editor.evaluate( + (node, args) => { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...args, + }); + node.dispatchEvent(event); + return event.defaultPrevented; + }, + { key, code: `Key${key.toUpperCase()}`, ...modifier, ...extra }, + ); + const before = await ctx.value(); + await ctx.append("\nplatform edit"); + const after = await ctx.value(); + const selection = await ctx.selection(); + for (const extra of [ + { isComposing: true }, + { isComposing: false, keyCode: 229 }, + { ctrlKey: true, metaKey: true }, + { altKey: true }, + mac + ? { ctrlKey: true, metaKey: false } + : { ctrlKey: false, metaKey: true }, + ]) { + assert.equal(await key("z", extra), false, JSON.stringify(extra)); + assert.equal(await ctx.value(), after); + assert.deepEqual(await ctx.selection(), selection); + } + assert.equal(await key("z"), true); + assert.equal(await ctx.value(), before); + const undoneSelection = await ctx.selection(); + if (mac) { + for (const extra of [ + { ctrlKey: true, metaKey: false }, + { metaKey: true }, + ]) { + assert.equal( + await key("y", extra), + false, + "leave Cocoa yank and Cmd+Y alone", + ); + assert.equal(await ctx.value(), before); + assert.deepEqual(await ctx.selection(), undoneSelection); + } + } + for (const command of mac ? ["z"] : ["z", "y"]) { + assert.equal( + await key(command, { shiftKey: command === "z", keyCode: 229 }), + false, + ); + assert.equal(await ctx.value(), before); + assert.deepEqual(await ctx.selection(), undoneSelection); + assert.equal(await key(command, { shiftKey: command === "z" }), true); + assert.equal( + await ctx.value(), + after, + "ignored commands must preserve redo", + ); + assert.equal(await key("z"), true); + assert.equal(await ctx.value(), before); + } + }); +} + +test("Mac history leaves Ctrl+Y and Cmd+Y available without consuming redo", async (t) => { + const ctx = await editorPage(t, "MacIntel"); + if (!ctx) return; + const before = await ctx.value(); + await ctx.append("\nkeep redo"); + const after = await ctx.value(); + await ctx.page.keyboard.press("Meta+Z"); + assert.equal(await ctx.value(), before); + const selection = await ctx.selection(); + for (const modifier of [{ ctrlKey: true }, { metaKey: true }]) { + const canceled = await ctx.editor.evaluate((node, modifier) => { + const event = new KeyboardEvent("keydown", { + key: "y", + code: "KeyY", + bubbles: true, + cancelable: true, + ...modifier, + }); + node.dispatchEvent(event); + return event.defaultPrevented; + }, modifier); + assert.equal(canceled, false); + assert.equal(await ctx.value(), before); + assert.deepEqual(await ctx.selection(), selection); + } + await ctx.page.keyboard.press("Meta+Shift+Z"); + assert.equal(await ctx.value(), after); +}); + +for (const chord of ["Tab", "Shift+Tab", "Control+/"]) { + for (const direction of ["forward", "backward"]) { + test(`${chord} undoes and redoes with a ${direction} selection`, async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { page, editor, append, undo, redo, value, selection } = ctx; + const start = (await value()).length; + await append("\n alpha\n beta"); + const end = (await value()).length; + await editor.evaluate( + (node, [start, end, direction]) => { + node.setSelectionRange(start + 1, end, direction); + }, + [start, end, direction], + ); + const before = { value: await value(), selection: await selection() }; + await page.keyboard.press(chord); + const after = { value: await value(), selection: await selection() }; + assert.notEqual(after.value, before.value); + assert.equal(after.selection[2], direction); + await undo(); + assert.deepEqual( + { value: await value(), selection: await selection() }, + before, + ); + await redo(); + assert.deepEqual( + { value: await value(), selection: await selection() }, + after, + ); + }); + } +} + +test("typing groups until the caret moves", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\n"); + const before = await ctx.value(); + await ctx.page.keyboard.type("abc"); + await ctx.undo(); + assert.equal(await ctx.value(), before); + await ctx.redo(); + assert.equal(await ctx.value(), `${before}abc`); + await ctx.page.keyboard.press("ArrowLeft"); + await ctx.page.keyboard.type("X"); + await ctx.undo(); + assert.equal(await ctx.value(), `${before}abc`); +}); + +test("paste and cut each undo and redo as one edit", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { page, editor, append, value, undo, redo } = ctx; + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await append("\n"); + const before = await value(); + await page.evaluate(() => navigator.clipboard.writeText("pasted\ntext")); + await page.keyboard.press("ControlOrMeta+V"); + assert.equal(await value(), `${before}pasted\ntext`); + await undo(); + assert.equal(await value(), before); + await redo(); + assert.equal(await value(), `${before}pasted\ntext`); + await editor.evaluate( + (node, start) => node.setSelectionRange(start, node.value.length), + before.length, + ); + await page.keyboard.press("ControlOrMeta+X"); + assert.equal(await value(), before); + await undo(); + assert.equal(await value(), `${before}pasted\ntext`); + await redo(); + assert.equal(await value(), before); +}); + +test("a composition transaction records its committed text once", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\n"); + const before = await ctx.value(); + // Synthetic composition events check transaction boundaries. A platform IME + // still needs a manual check; these events do not emulate its candidate UI. + await ctx.editor.evaluate((node) => { + node.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + for (const data of ["n", "ni", "你"]) { + node.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + inputType: "insertCompositionText", + data, + isComposing: true, + }), + ); + node.setRangeText( + data, + node.value.lastIndexOf("\n") + 1, + node.value.length, + "end", + ); + node.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType: "insertCompositionText", + data, + isComposing: true, + }), + ); + } + node.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "你" }), + ); + }); + assert.equal(await ctx.value(), `${before}你`); + await ctx.undo(); + assert.equal(await ctx.value(), before); + await ctx.redo(); + assert.equal(await ctx.value(), `${before}你`); +}); + +for (const inputType of ["historyUndo", "historyRedo"]) { + test(`${inputType} during composition stays with the input method`, async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\n"); + const before = await ctx.value(); + const canceled = await ctx.editor.evaluate((node, inputType) => { + const start = node.value.length; + const compose = (data, type = "insertCompositionText") => { + node.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: type, + isComposing: true, + data, + }), + ); + node.setRangeText(data, start, node.value.length, "end"); + node.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType: type, + isComposing: true, + data, + }), + ); + }; + node.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + compose("n"); + const event = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType, + isComposing: true, + }); + node.dispatchEvent(event); + node.setRangeText("ni", start, node.value.length, "end"); + node.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType, + isComposing: true, + }), + ); + return event.defaultPrevented; + }, inputType); + assert.equal(canceled, false); + assert.equal(await ctx.value(), `${before}ni`); + await ctx.editor.evaluate((node) => { + node.setRangeText("你", node.value.length - 2, node.value.length, "end"); + node.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType: "insertCompositionText", + isComposing: true, + }), + ); + node.dispatchEvent( + new CompositionEvent("compositionend", { + bubbles: true, + data: "你", + }), + ); + }); + await ctx.undo(); + assert.equal( + await ctx.value(), + before, + "the whole composition is one edit", + ); + await ctx.redo(); + assert.equal(await ctx.value(), `${before}你`); + }); +} + +for (const separator of [" ", "\n"]) { + test(`typing across ${JSON.stringify(separator)} splits undo groups`, async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\n"); + const before = await ctx.value(); + await ctx.page.keyboard.type(`alpha${separator}beta`); + await ctx.undo(); + assert.equal(await ctx.value(), `${before}alpha${separator}`); + await ctx.undo(); + assert.equal(await ctx.value(), `${before}alpha`); + await ctx.undo(); + assert.equal(await ctx.value(), before); + for (let i = 0; i < 3; i++) await ctx.redo(); + assert.equal(await ctx.value(), `${before}alpha${separator}beta`); + }); +} + +test("history input types are canceled and use the page history", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\nAAA\nBBB"); + const full = await ctx.value(); + await ctx.deleteLast(); + const deleted = await ctx.value(); + for (const [inputType, expected] of [ + ["historyUndo", full], + ["historyRedo", deleted], + ]) { + const canceled = await ctx.editor.evaluate((node, inputType) => { + const event = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType, + }); + node.dispatchEvent(event); + return event.defaultPrevented; + }, inputType); + assert.equal(canceled, true); + assert.equal(await ctx.value(), expected); + } +}); + +for (const inputType of ["historyUndo", "historyRedo"]) { + for (const beforeInput of [false, true]) { + test(`${inputType} boundary repair preserves a moved selection with ${beforeInput ? "non-cancelable beforeinput" : "input only"}`, async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\nPython edit"); + await ctx.select("javascript"); + const starter = await ctx.value(); + await ctx.editor.focus(); + const expected = await ctx.editor.evaluate(async (node, beforeInput) => { + // An input-only notification needs the preceding selection event; + // beforeinput must also work before that event has been delivered. + if (beforeInput) return [2, 7, "backward"]; + const selected = new Promise((resolve) => + node.addEventListener("selectionchange", resolve, { once: true }), + ); + node.setSelectionRange(2, 7, "backward"); + await selected; + return [ + node.selectionStart, + node.selectionEnd, + node.selectionDirection, + ]; + }, beforeInput); + await ctx.editor.evaluate( + (node, { inputType, beforeInput }) => { + if (beforeInput) { + // Notify in the same task, before selectionchange can run. + node.setSelectionRange(2, 7, "backward"); + node.dispatchEvent( + new InputEvent("beforeinput", { inputType, bubbles: true }), + ); + } + // Model an already-applied native history edit. Synthetic input does + // not perform a native undo, so change both text and selection here. + node.value = "native history from another buffer"; + node.dispatchEvent( + new InputEvent("input", { inputType, bubbles: true }), + ); + }, + { inputType, beforeInput }, + ); + assert.equal(await ctx.value(), starter); + assert.deepEqual(await ctx.selection(), expected); + // Repairing a boundary must not create an edit in this language. + await ctx.undo(); + await ctx.redo(); + assert.equal(await ctx.value(), starter); + assert.deepEqual(await ctx.selection(), expected); + }); + } +} + +test("a drop input is recorded with its original selection", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\n"); + const before = await ctx.value(); + // Exercise the drop input path without pretending dispatchEvent performs a + // trusted operating-system drag. Native dragging is a separate manual check. + await ctx.editor.evaluate((node) => { + node.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertFromDrop", + data: "dropped", + }), + ); + node.setRangeText("dropped", node.selectionStart, node.selectionEnd, "end"); + node.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType: "insertFromDrop", + data: "dropped", + }), + ); + }); + assert.equal(await ctx.value(), `${before}dropped`); + await ctx.undo(); + assert.equal(await ctx.value(), before); + await ctx.redo(); + assert.equal(await ctx.value(), `${before}dropped`); +}); + +for (const { name, before, expected, source, target } of [ + { + name: "down", + before: "START\nDRAG\n\n", + expected: "START\n\nDRAG\n", + source: 1, + target: 2, + }, + { + name: "up", + before: "START\n\nDRAG\n", + expected: "START\nDRAG\n\n", + source: 2, + target: 1, + }, + { + name: "from line 11 to 13", + before: "HEAD\n".repeat(10) + "DRAG\n\n\n\n", + expected: "HEAD\n".repeat(10) + "\n\nDRAG\n\n", + source: 10, + target: 12, + }, + { + name: "onto the last empty line", + before: "START\nDRAG\n", + expected: "START\n\nDRAG", + source: 1, + target: 2, + }, +]) { + test(`a native drag ${name} preserves empty lines and undoes and redoes as one action`, async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { page, editor, value, undo, redo } = ctx; + await editor.fill(before); + const points = await editor.evaluate( + (node, { source, target }) => { + const start = node.value.indexOf("DRAG"); + node.focus(); + node.setSelectionRange(start, start + 4); + node.scrollTop = 0; + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + context.font = style.font; + const lineHeight = parseFloat(style.lineHeight); + const x = + rect.left + + parseFloat(style.paddingLeft) + + context.measureText("DR").width; + return { + x, + sourceY: + rect.top + + parseFloat(style.paddingTop) + + (source + 0.5) * lineHeight, + targetY: + rect.top + + parseFloat(style.paddingTop) + + (target + 0.5) * lineHeight, + }; + }, + { source, target }, + ); + await editor.evaluate((node) => { + node.dataset.sawDrop = "false"; + node.addEventListener( + "drop", + () => { + node.dataset.sawDrop = "true"; + }, + { once: true }, + ); + }); + await page.mouse.move(points.x, points.sourceY); + await page.mouse.down(); + await page.mouse.move(points.x + 5, points.sourceY, { steps: 5 }); + await page.mouse.move(points.x, points.targetY, { steps: 20 }); + await page.mouse.up(); + assert.equal(await editor.getAttribute("data-saw-drop"), "true"); + const after = await value(); + assert.equal( + after, + expected, + "the move must preserve every unselected newline", + ); + assert.equal(after.split("\n").length, before.split("\n").length); + await undo(); + assert.equal( + await value(), + before, + "one undo must restore the entire move", + ); + await redo(); + assert.equal(await value(), after, "one redo must repeat the entire move"); + }); +} + +test("history shortcuts support non-Latin keys without stealing printable layout keys", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const before = await ctx.value(); + await ctx.append("\nkeyboard edit"); + const after = await ctx.value(); + const key = (key, code, extra = {}) => + ctx.editor.evaluate( + (node, args) => { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + ...args, + }); + node.dispatchEvent(event); + return event.defaultPrevented; + }, + { key, code, ...extra }, + ); + assert.equal(await key(";", "KeyZ"), false, "Dvorak punctuation is not Undo"); + assert.equal(await ctx.value(), after); + assert.equal(await key("я", "KeyZ"), true); + assert.equal(await ctx.value(), before); + assert.equal(await key("Я", "KeyZ", { shiftKey: true }), true); + assert.equal(await ctx.value(), after); + assert.equal(await key("z", "KeyZ", { altKey: true }), false); + assert.equal(await key("z", "KeyZ", { isComposing: true }), false); + assert.equal(await ctx.value(), after); +}); + +test("history shortcuts and menu events leave disabled and read-only buffers alone", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const before = await ctx.value(); + await ctx.append("\nkeep this"); + const after = await ctx.value(); + for (const attribute of ["disabled", "readonly"]) { + await ctx.editor.evaluate((node, attribute) => { + node.setAttribute(attribute, ""); + for (const inputType of ["historyUndo", "historyRedo"]) { + node.dispatchEvent( + new KeyboardEvent("keydown", { + key: "z", + ctrlKey: true, + shiftKey: inputType === "historyRedo", + bubbles: true, + cancelable: true, + }), + ); + node.dispatchEvent( + new InputEvent("beforeinput", { + inputType, + bubbles: true, + cancelable: true, + }), + ); + } + }, attribute); + assert.equal(await ctx.value(), after); + await ctx.editor.evaluate( + (node, attribute) => node.removeAttribute(attribute), + attribute, + ); + } + await ctx.undo(); + assert.equal( + await ctx.value(), + before, + "blocked commands did not move the history cursor", + ); +}); + +test("undo and redo at a language's history boundaries never fall through", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\nPython edit"); + await ctx.select("javascript"); + const starter = await ctx.value(); + const commands = await ctx.editor.evaluate((node) => { + const results = []; + for (const inputType of ["historyUndo", "historyRedo"]) { + const event = new InputEvent("beforeinput", { + inputType, + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(event); + results.push(event.defaultPrevented); + } + for (const shiftKey of [false, true]) { + const event = new KeyboardEvent("keydown", { + key: "z", + ctrlKey: true, + shiftKey, + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(event); + results.push(event.defaultPrevented); + } + return results; + }); + assert.deepEqual(commands, [true, true, true, true]); + assert.equal(await ctx.value(), starter); +}); + +test("interleaved edits keep Python, JavaScript, C, C++ and Java histories independent", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { page, editor, select, append, undo, redo, value, selection } = ctx; + const buffers = new Map(); + for (const name of LANGUAGES) { + await select(name); + assert.equal( + await page + .locator(`#language-select option[value="${name}"]`) + .isDisabled(), + false, + ); + const marker = name === "python" ? "#" : "//"; + await append(`\n${marker} ${name} kept\n${marker} ${name} deleted`); + buffers.set(name, { full: await value() }); + } + for (const name of ["java", "python", "cpp", "javascript", "c"]) { + await select(name); + const buffer = buffers.get(name); + assert.equal(await value(), buffer.full); + await editor.focus(); + await editor.evaluate((node) => + node.setSelectionRange( + node.value.lastIndexOf("\n") + 1, + node.value.length, + "backward", + ), + ); + buffer.beforeSelection = await selection(); + await page.keyboard.press("Backspace"); + buffer.deleted = await value(); + buffer.afterSelection = await selection(); + assert.equal( + buffer.deleted, + buffer.full.slice(0, buffer.full.lastIndexOf("\n") + 1), + ); + } + for (const name of ["cpp", "java", "c", "python", "javascript"]) { + await select(name); + const buffer = buffers.get(name); + assert.equal(await value(), buffer.deleted); + await undo(); + assert.equal( + await value(), + buffer.full, + `${name} undo restores only its deleted line`, + ); + assert.deepEqual( + await selection(), + buffer.beforeSelection, + `${name} undo restores its selection`, + ); + } + for (const name of ["javascript", "cpp", "python", "c", "java"]) { + await select(name); + const buffer = buffers.get(name); + assert.equal(await value(), buffer.full); + await redo(); + assert.equal( + await value(), + buffer.deleted, + `${name} redo survives other languages' undo and redo`, + ); + assert.deepEqual(await selection(), buffer.afterSelection); + } +}); + +for (const [index, name] of LANGUAGES.entries()) { + test(`${name} indentation and comments remain undoable after a language switch`, async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { page, editor, select, undo, redo, value, selection } = ctx; + await select(name); + const before = `${await value()}\n first\n second`; + const other = LANGUAGES[(index + 1) % LANGUAGES.length]; + for (const chord of ["Tab", "Shift+Tab", "Control+/"]) { + await editor.fill(before); + await editor.evaluate((node) => + node.setSelectionRange( + node.value.lastIndexOf("\n first") + 1, + node.value.length, + "backward", + ), + ); + const beforeSelection = await selection(); + await page.keyboard.press(chord); + const after = await value(); + const afterSelection = await selection(); + assert.notEqual(after, before, `${name} ${chord} edits the buffer`); + assert.equal(afterSelection[2], "backward"); + await select(other); + await select(name); + await undo(); + assert.equal(await value(), before, `${name} ${chord} undo`); + assert.deepEqual(await selection(), beforeSelection); + await redo(); + assert.equal(await value(), after, `${name} ${chord} redo`); + assert.deepEqual(await selection(), afterSelection); + } + }); +} + +test("paired brackets and auto-indented Enter share the language history", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + await ctx.append("\n"); + const initial = await ctx.value(); + await ctx.page.keyboard.type("("); + assert.equal(await ctx.value(), `${initial}()`); + await ctx.page.keyboard.press("Enter"); + const indented = await ctx.value(); + assert.notEqual(indented, `${initial}()`); + await ctx.select("javascript"); + await ctx.select("python"); + await ctx.undo(); + assert.equal(await ctx.value(), `${initial}()`); + await ctx.undo(); + assert.equal(await ctx.value(), initial); + await ctx.redo(); + assert.equal(await ctx.value(), `${initial}()`); + await ctx.redo(); + assert.equal(await ctx.value(), indented); +}); diff --git a/tests/browser/editor-pipeline.test.js b/tests/browser/editor-pipeline.test.js index 63f4413a6..a024287da 100644 --- a/tests/browser/editor-pipeline.test.js +++ b/tests/browser/editor-pipeline.test.js @@ -200,9 +200,7 @@ test("Backspace right after an auto-closed pair removes both characters, in a re if (!ctx) return; const { page, errors } = ctx; try { - // deleteEmptyPair changes the value, so this exercises the other - // execCommand round trip (execCommand("delete")), not just the - // insertText one the test above covers. + // deleteEmptyPair uses the same programmatic edit path as pair insertion. await page.keyboard.type("("); await page.keyboard.press("Backspace"); assert.equal(await page.locator("#editor").inputValue(), ""); diff --git a/tests/browser/editor.test.js b/tests/browser/editor.test.js index 54cee65ec..3474fe633 100644 --- a/tests/browser/editor.test.js +++ b/tests/browser/editor.test.js @@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises"; import { matchingBrackets, + moveSelectedText, indentNewline, indentSelection, insertBracketPair, @@ -664,9 +665,8 @@ test("an opening-bracket beforeinput is a bracket keystroke", () => { } }); -// applyEditorEdit hands execCommand the changed span -- "()" at a bare caret, -// "(value)" around a selection -- never a bare opener, so this excludes -// execCommand's own synthetic beforeinput on content alone. +// Only a single typed opener starts bracket pairing. Other input types and +// multi-character inserts must pass through unchanged. test("a beforeinput that is not plain bracket text is not a bracket keystroke", () => { assert.equal( isBracketOpenerKeystroke({ inputType: "insertLineBreak", data: null }), @@ -1425,3 +1425,36 @@ test("error trees preserve pairing in unfinished Python f-string expressions", a ); } }); + +test("moving text preserves every unselected newline", () => { + for (const [value, start, end, target, expected, selection] of [ + ["DRAG\n\n\n\n", 0, 4, 6, "\n\nDRAG\n\n", [2, 6]], + ["DRAG\n", 0, 4, 5, "\nDRAG", [1, 5]], + ["\n\nDRAG\n", 2, 6, 0, "DRAG\n\n\n", [0, 4]], + ["A\nB\nC\n", 0, 4, 6, "C\nA\nB\n", [2, 6]], + ["甲😀\n乙\n", 1, 3, 6, "甲\n乙\n😀", [4, 6]], + ]) { + const result = moveSelectedText(value, start, end, target); + assert.deepEqual(result, { + value: expected, + start: selection[0], + end: selection[1], + }); + assert.equal(result.value.length, value.length); + assert.equal(result.value.split("\n").length, value.split("\n").length); + } +}); + +test("dropping in the original selection or moving no text leaves it unchanged", () => { + for (const target of [1, 2, 3]) + assert.deepEqual(moveSelectedText("abcd", 1, 3, target), { + value: "abcd", + start: 1, + end: 3, + }); + assert.deepEqual(moveSelectedText("abcd", 2, 2, 4), { + value: "abcd", + start: 2, + end: 2, + }); +}); diff --git a/tests/browser/restore-starter.test.js b/tests/browser/restore-starter.test.js index 0b51cb549..215faeed2 100644 --- a/tests/browser/restore-starter.test.js +++ b/tests/browser/restore-starter.test.js @@ -1,6 +1,5 @@ // A browser-level pin for the Restore starter control in web/interview.js: it -// puts the current language's starter back through the editor's own editing -// command, so native undo survives, and it touches nothing else. +// puts the current language's starter back in one undoable page-history edit. import { after, before, test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; @@ -47,8 +46,8 @@ after(async () => { await new Promise((closed) => (server ? server.close(closed) : closed())); }); -/// Page errors, and every dialog the page opens. The control confirms in the -/// toolbar because a native dialog blocks the page's event loop, so a dialog +/// Page errors, and every dialog the page opens. Restore relies on +/// page history because a native dialog blocks the event loop, so a dialog /// showing up at all is a failure; they are dismissed here only so a test that /// hits one fails instead of hanging. function watch(page) { @@ -96,9 +95,7 @@ window.LivekitClient = { }, };`; -/// A browser that does not offer the editing command, so a restore could not be -/// undone and asks for a second press. Every real browser offers it, which makes -/// this the one way to reach the confirmation. +/// Restore works even when the browser does not offer an editing command. const WITHOUT_EDITING_COMMAND = `document.queryCommandSupported = () => false;`; /// A page that has cleared the real preflight and joined the fake room, in a @@ -225,16 +222,7 @@ async function openPage( const value = (page) => page.locator("#editor").inputValue(); const language = (page, name) => page.selectOption("#language-select", name); const press = (page) => page.click("#editor-restore-starter"); -const armed = async (page) => - (await page.locator("#editor-restore-starter").getAttribute("data-armed")) === - "true"; -/// A restore the way a candidate confirms it: a first press arms the control -/// and a second replaces the code. A buffer that already is the starter never -/// arms, so the second press is skipped. -async function restore(page) { - await press(page); - if (await armed(page)) await press(page); -} +const restore = press; const undo = (page) => page.keyboard.press("ControlOrMeta+Z"); const redo = (page) => page.keyboard.press("ControlOrMeta+Shift+Z"); @@ -246,6 +234,44 @@ async function overwrite(page, text) { else await page.keyboard.insertText(text); } +test("one restore press uses page history without calling native editing commands", async (t) => { + const ctx = await openPage(t, {}, { withoutEditingCommand: true }); + if (!ctx) return; + const { page } = ctx; + try { + await overwrite(page, "candidate work\n\n"); + await page.evaluate(() => { + window.__commands = []; + document.execCommand = (name) => { + window.__commands.push(name); + return false; + }; + document.querySelector("#editor").setSelectionRange(1, 5, "backward"); + }); + await press(page); + assert.equal(await value(page), starters.python); + assert.deepEqual(await page.evaluate(() => window.__commands), []); + await undo(page); + assert.equal(await value(page), "candidate work\n\n"); + assert.deepEqual( + await page + .locator("#editor") + .evaluate((node) => [ + node.selectionStart, + node.selectionEnd, + node.selectionDirection, + ]), + [1, 5, "backward"], + ); + await redo(page); + assert.equal(await value(page), starters.python); + assert.deepEqual(ctx.errors, []); + assert.deepEqual(ctx.dialogs, []); + } finally { + await page.close(); + } +}); + test("every language gets exactly its own starter back", async (t) => { const ctx = await openPage(t); if (!ctx) return; @@ -390,7 +416,7 @@ test("indent, dedent and comment edits share the path and stay exact under undo } }); -test("a browser with the editing command restores on the first press and arms nothing", async (t) => { +test("a browser with the editing command restores on the first press", async (t) => { const ctx = await openPage(t); if (!ctx) return; const { page } = ctx; @@ -403,8 +429,7 @@ test("a browser with the editing command restores on the first press and arms no await overwrite(page, "mine\n"); await press(page); assert.equal(await value(page), starters.python, "one press restores"); - assert.equal(await armed(page), false); - assert.equal(await page.locator("#editor-restore-prompt").isHidden(), true); + assert.equal( await page.locator("#editor-restore-starter").getAttribute("aria-label"), "Restore the starter code", @@ -417,110 +442,33 @@ test("a browser with the editing command restores on the first press and arms no } }); -test("only the restore splits a trailing newline off the span it replaces", async (t) => { - const ctx = await openPage(t); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "a\nb"); - await page.evaluate(() => { - const commands = []; - window.__commands = commands; - const run = document.execCommand.bind(document); - document.execCommand = (name, ...rest) => { - commands.push(name); - return run(name, ...rest); - }; - const editor = document.querySelector("#editor"); - editor.setSelectionRange(0, editor.value.length); - }); - await page.keyboard.press("ControlOrMeta+/"); - assert.equal(await value(page), "# a\n# b", "the comment toggle ran"); - assert.deepEqual( - await page.evaluate(() => window.__commands), - ["insertText"], - "a comment toggle is one command, with no forward delete after it", - ); - assert.deepEqual(ctx.errors, []); - } finally { - await page.close(); - } -}); - -test("a second click confirms even when the browser clears focus on mousedown", async (t) => { +test("a click restores and focuses the editor when mousedown clears focus", async (t) => { const ctx = await openPage(t, {}, { withoutEditingCommand: true }); if (!ctx) return; const { page } = ctx; try { - // Safari does not focus a clicked button and drops the focus that was - // there, unless the page cancels the mousedown. Playwright's WebKit focuses - // buttons on click, so the behaviour is modelled here. - await page.evaluate(() => { - document.addEventListener("mousedown", (event) => { - if (!event.defaultPrevented) document.activeElement?.blur(); - }); - }); - await overwrite(page, "mine\n"); - await press(page); - assert.equal(await armed(page), true, "the first click armed it"); - await press(page); - assert.equal(await value(page), starters.python, "the second confirmed"); - assert.equal(await armed(page), false); - assert.deepEqual(ctx.errors, []); - } finally { - await page.close(); - } -}); - -test("an insert whose forward delete is refused leaves the code and its history as they were", async (t) => { - const problem = NO_TRAILING_NEWLINE[0]; - const ctx = await openPage(t, { problem }); - if (!ctx) return; - const { page } = ctx; - const starter = startersOf(problem).python; - try { - // A buffer ending in a newline over a starter that does not takes the - // insert and then the forward delete; the second is the one that fails. - await overwrite(page, "first\n"); + await page.evaluate(() => + document.addEventListener("mousedown", () => + document.activeElement?.blur(), + ), + ); await overwrite(page, "mine\n"); - await page.evaluate(() => { - const run = document.execCommand.bind(document); - document.execCommand = (name, ...rest) => - name === "forwardDelete" ? false : run(name, ...rest); - document.querySelector("#editor").setSelectionRange(1, 3); - }); await press(page); - assert.equal(await value(page), "mine\n", "the half-done insert is undone"); - assert.equal(await armed(page), true, "asked before assigning the value"); - assert.deepEqual( - await page.evaluate(() => { - const editor = document.querySelector("#editor"); - return [editor.selectionStart, editor.selectionEnd]; - }), - [1, 3], - "the candidate's selection is back", - ); - await page.evaluate(() => document.querySelector("#editor").focus()); - await undo(page); + assert.equal(await value(page), starters.python); assert.equal( - await value(page), - "first\n", - "one undo reaches the earlier edit: the refused restore left no step behind", + await page.evaluate(() => document.activeElement?.id), + "editor", ); - await redo(page); - await press(page); - await press(page); - assert.equal(await value(page), starter, "a second press still restores"); + await undo(page); + assert.equal(await value(page), "mine\n"); assert.deepEqual(ctx.errors, []); } finally { await page.close(); } }); -// Chromium redoes a replaced selection that ends in a newline wrongly, which -// is every buffer ending in a newline restored over a starter that does not. -// The chargeback starter ends in a newline, so it never shows it; these use the -// problems whose starter does not. +// Keep the trailing-newline cases that previously exposed native redo bugs. +// Page-history undo and redo must restore every byte without a workaround. const NO_TRAILING_NEWLINE = Object.entries( JSON.parse(readFileSync(webFile("problem-pages.json"), "utf8")), ) @@ -565,63 +513,53 @@ test("undo and redo are exact over a starter that does not end in a newline", as } }); -test("a double-click arms the control and does not confirm it", async (t) => { +test("a double-click restores once and leaves one undo step", async (t) => { const ctx = await openPage(t, {}, { withoutEditingCommand: true }); if (!ctx) return; const { page } = ctx; try { await overwrite(page, "keep this\n"); await page.dblclick("#editor-restore-starter"); - assert.equal(await armed(page), true, "the first click armed it"); - assert.equal( - await value(page), - "keep this\n", - "the second click of the same gesture did not replace the code", - ); - await press(page); - assert.equal(await value(page), starters.python, "a later press confirms"); + assert.equal(await value(page), starters.python); + await page.locator("#editor").focus(); + await undo(page); + assert.equal(await value(page), "keep this\n"); + await redo(page); + assert.equal(await value(page), starters.python); + assert.deepEqual(ctx.errors, []); } finally { await page.close(); } }); -test("the prompt stays inside the panel and the code under it stays clickable", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); +test("the restore control stays inside narrow panels and leaves the editor clickable", async (t) => { + const ctx = await openPage(t); if (!ctx) return; const { page } = ctx; try { - await overwrite(page, "keep this\n"); for (const width of [320, 390, 600, 1024]) { await page.setViewportSize({ width, height: 900 }); - // Fresh each time: a press on an armed control would confirm it. await overwrite(page, "keep this\n"); await press(page); - assert.equal(await armed(page), true, `${width}: armed`); - const m = await page.evaluate(() => { - const panel = document.querySelector(".editor-panel"); - const box = panel.getBoundingClientRect(); - const prompt = document.querySelector("#editor-restore-prompt"); - const at = prompt.getBoundingClientRect(); - const under = document.elementFromPoint( - at.left + at.width / 2, - at.top + at.height / 2, - ); - return { - inside: at.left >= box.left - 1 && at.right <= box.right + 1, - scrollsSideways: panel.scrollWidth > panel.clientWidth, - visible: prompt.checkVisibility(), - underPrompt: under?.id || under?.tagName, - }; + const inside = await page.evaluate(() => { + const panel = document + .querySelector(".editor-panel") + .getBoundingClientRect(); + const button = document + .querySelector("#editor-restore-starter") + .getBoundingClientRect(); + return button.left >= panel.left && button.right <= panel.right; }); - assert.equal(m.visible, true, `${width}: shown`); - assert.equal(m.inside, true, `${width}: inside the panel`); - assert.equal(m.scrollsSideways, false, `${width}: no sideways scroll`); - assert.notEqual( - m.underPrompt, - "editor-restore-prompt", - `${width}: the prompt takes no clicks`, + assert.equal(inside, true, String(width)); + await page.locator("#editor").click(); + assert.equal( + await page.evaluate(() => document.activeElement?.id), + "editor", ); + await undo(page); + assert.equal(await value(page), "keep this\n"); } + assert.deepEqual(ctx.errors, []); } finally { await page.close(); } @@ -729,65 +667,15 @@ test("the painted overlays follow the restored buffer", async (t) => { } }); -test("the first press arms the control and a second one replaces the code", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - const button = page.locator("#editor-restore-starter"); - const toolbar = page.locator(".editor-toolbar"); - const toolbarHeight = (await toolbar.boundingBox()).height; - await overwrite(page, "mine\n"); - await press(page); - assert.equal(await armed(page), true); - assert.equal(await value(page), "mine\n", "one press replaces nothing"); - assert.equal( - (await page.locator("#editor-restore-prompt").textContent()).trim(), - "Press again to replace your code", - ); - assert.equal( - await button.getAttribute("aria-label"), - "Press again to replace your code with the starter code", - "the visible words are inside the accessible name", - ); - assert.equal( - (await toolbar.boundingBox()).height, - toolbarHeight, - "the prompt hangs below the button, so the toolbar keeps its row", - ); - assert.match( - await page.locator("#editor-restore-status").textContent(), - /Ctrl\+Z \(Cmd\+Z on a Mac\) may not bring it back/, - "the candidate is told how far undo reaches", - ); - await press(page); - assert.equal(await value(page), starters.python); - assert.equal(await armed(page), false); - assert.equal(await page.locator("#editor-restore-prompt").isHidden(), true); - assert.equal( - await button.getAttribute("aria-label"), - "Restore the starter code", - ); - assert.equal( - await page.locator("#editor-restore-status").textContent(), - "Restored the starter code. Ctrl+Z (Cmd+Z on a Mac) may undo this, but not after you switch languages.", - ); - assert.deepEqual(ctx.dialogs, [], "no native dialog, which would block"); - assert.deepEqual(ctx.errors, []); - } finally { - await page.close(); - } -}); - const ALREADY = "The code is already the starter code."; -test("the starter itself arms nothing, and says so", async (t) => { +test("the untouched starter is unchanged and announced", async (t) => { const ctx = await openPage(t); if (!ctx) return; const { page } = ctx; try { await press(page); - assert.equal(await armed(page), false); + assert.equal(await value(page), starters.python); // Said on the next frame, so wait for it: a press that did nothing in // silence looked broken, and told a screen reader nothing. @@ -798,7 +686,6 @@ test("the starter itself arms nothing, and says so", async (t) => { ALREADY, { timeout: 5000 }, ); - assert.equal(await page.locator("#editor-restore-prompt").isHidden(), true); } finally { await page.close(); } @@ -875,285 +762,30 @@ test("an announcement that stopped being true before its frame is not made", asy } }); -test("a late announcement does not overwrite the prompt an arm has just written", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); +test("a late announcement does not overwrite a successful restore", async (t) => { + const ctx = await openPage(t); if (!ctx) return; const { page } = ctx; try { await page.evaluate(() => { const button = document.querySelector("#editor-restore-starter"); - const editor = document.querySelector("#editor"); - // A press on the starter schedules its announcement for the next frame; - // the buffer then changes and a second press arms before that frame. button.click(); - editor.value = "changed\n"; + document.querySelector("#editor").value = "changed\n"; button.click(); }); - await page.waitForTimeout(300); - assert.equal(await armed(page), true); + await page.evaluate(() => new Promise(requestAnimationFrame)); assert.match( await page.locator("#editor-restore-status").textContent(), - /Press again to replace your code/, - "the arm's own words stand", - ); - } finally { - await page.close(); - } -}); - -test("an arm does not run out on its own", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - // Page timers on a fake clock, so a minute passes at once: a screen reader - // needs as long as it needs to read the status, and a prompt that ran out - // first would be armed again by the very press meant to confirm it. - await page.clock.install(); - await overwrite(page, "keep this\n"); - await press(page); - assert.equal(await armed(page), true); - await page.clock.fastForward(60_000); - assert.equal(await armed(page), true, "still armed a minute on"); - assert.equal(await value(page), "keep this\n"); - await press(page); - assert.equal(await value(page), starters.python, "and it confirms"); - } finally { - await page.close(); - } -}); - -// WebKit does not focus a button that is clicked, so a mouse press there arms -// the control with focus still in the editor. A script click is the same: it -// runs the click handler and moves no focus. -const clickWithoutFocus = (page) => - page.evaluate(() => - document.querySelector("#editor-restore-starter").click(), - ); - -test("arming puts focus on the control, so Escape cancels it after a click that did not", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "keep this\n"); - await clickWithoutFocus(page); - assert.equal(await armed(page), true); - assert.equal( - await page.evaluate(() => document.activeElement?.id), - "editor-restore-starter", - "arming focused the button, as a click does in Chromium", - ); - await page.keyboard.press("Escape"); - assert.equal(await armed(page), false, "Escape reached the button"); - await clickWithoutFocus(page); - assert.equal( - await armed(page), - true, - "a new first press, not a confirmation", - ); - assert.equal(await value(page), "keep this\n", "nothing was replaced"); - } finally { - await page.close(); - } -}); - -test("clicking back into the editor cancels an arm taken without focus", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "keep this\n"); - await clickWithoutFocus(page); - assert.equal(await armed(page), true); - // Back into the code without typing: only the button losing focus tells - // the control, and it has focus only because arming gave it. - await page.focus("#editor"); - assert.equal(await armed(page), false); - await clickWithoutFocus(page); - assert.equal(await armed(page), true, "armed afresh"); - assert.equal( - await value(page), - "keep this\n", - "and the code is still there", - ); - } finally { - await page.close(); - } -}); - -test("leaving the button ends the arm", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "keep this\n"); - await press(page); - assert.equal(await armed(page), true); - await page.evaluate(() => document.querySelector("#editor").focus()); - assert.equal(await armed(page), false); - assert.equal(await page.locator("#editor-restore-prompt").isHidden(), true); - assert.equal( - await page.locator("#editor-restore-status").textContent(), - "", - ); - await press(page); - assert.equal(await armed(page), true, "a press now is a first press"); - assert.equal(await value(page), "keep this\n"); - } finally { - await page.close(); - } -}); - -test("an edit ends the arm even while the button keeps focus", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "keep this\n"); - await press(page); - assert.equal(await armed(page), true); - await page.evaluate(() => { - const editor = document.querySelector("#editor"); - editor.value += "more\n"; - editor.dispatchEvent(new Event("input", { bubbles: true })); - }); - assert.equal(await armed(page), false); - assert.equal(await page.locator("#editor-restore-prompt").isHidden(), true); - } finally { - await page.close(); - } -}); - -test("holding Enter on the button arms it and does not confirm it", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "keep this\n"); - await page.focus("#editor-restore-starter"); - // Auto-repeat is a keydown that arrives again with the key still down, and - // the browser clicks a button on each of them. - await page.keyboard.down("Enter"); - for (let i = 0; i < 4; i += 1) await page.keyboard.down("Enter"); - await page.keyboard.up("Enter"); - assert.equal(await armed(page), true, "the first keydown armed it"); - assert.equal( - await value(page), - "keep this\n", - "the repeats did not replace the code", - ); - await page.keyboard.press("Enter"); - assert.equal(await value(page), starters.python, "a second press does"); - } finally { - await page.close(); - } -}); - -test("Escape on the armed control disarms it", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "keep this\n"); - await press(page); - assert.equal(await armed(page), true); - await page.keyboard.press("Escape"); - assert.equal(await armed(page), false); - assert.equal(await value(page), "keep this\n"); - } finally { - await page.close(); - } -}); - -test("an edit or a language change between presses makes the next one a first press", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "first\n"); - await press(page); - await page.evaluate(() => document.querySelector("#editor").focus()); - await page.keyboard.insertText("and more\n"); - await press(page); - assert.equal(await armed(page), true, "armed afresh for the new buffer"); - assert.equal(await value(page), "first\nand more\n"); - - await language(page, "javascript"); - await overwrite(page, "// js\n"); - await press(page); - assert.equal(await armed(page), true); - assert.equal( - await value(page), - "// js\n", - "the other language is untouched", - ); - await language(page, "python"); - assert.equal(await value(page), "first\nand more\n"); - assert.deepEqual(ctx.errors, []); - } finally { - await page.close(); - } -}); - -test("a language round trip drops the arm even though it lands on the same buffer", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "round trip\n"); - await press(page); - assert.equal(await armed(page), true); - await language(page, "javascript"); - assert.equal(await armed(page), false, "switching away disarms"); - await language(page, "python"); - assert.equal( - await value(page), - "round trip\n", - "same language, same buffer", - ); - // The arm was taken before the switch, so this is a first press: it arms - // and replaces nothing, where a stale arm would have replaced at once. - await press(page); - assert.equal(await armed(page), true); - assert.equal(await value(page), "round trip\n"); - assert.deepEqual(ctx.errors, []); - } finally { - await page.close(); - } -}); - -test("an unconfirmed press leaves the buffer, the selection and the undo history alone", async (t) => { - const ctx = await openPage(t, {}, { withoutEditingCommand: true }); - if (!ctx) return; - const { page } = ctx; - try { - await overwrite(page, "keep this\n"); - await page.keyboard.insertText("and this\n"); - await page.evaluate(() => - document.querySelector("#editor").setSelectionRange(3, 8), - ); - await press(page); - assert.equal(await value(page), "keep this\nand this\n"); - const selection = await page.evaluate(() => { - const editor = document.querySelector("#editor"); - return [editor.selectionStart, editor.selectionEnd]; - }); - assert.deepEqual(selection, [3, 8], "the candidate's selection is kept"); - await page.evaluate(() => document.querySelector("#editor").focus()); - await undo(page); - assert.notEqual( - await value(page), - "keep this\nand this\n", - "the candidate's own typing is still undoable", + /^Restored the starter code\./, ); + assert.equal(await value(page), starters.python); assert.deepEqual(ctx.errors, []); } finally { await page.close(); } }); -test("after a language switch the old buffer is gone, as the question says", async (t) => { +test("a restored buffer can be undone and redone after switching languages", async (t) => { const ctx = await openPage(t); if (!ctx) return; const { page } = ctx; @@ -1166,17 +798,20 @@ test("after a language switch the old buffer is gone, as the question says", asy await undo(page); assert.equal( await value(page), - starters.python, - "undo does not cross a switch, which is why the restore asked first", + "my precious work\n", + "undo restores this language's work after a switch", ); + await language(page, "javascript"); + await language(page, "python"); + await page.locator("#editor").focus(); + await redo(page); + assert.equal(await value(page), starters.python); } finally { await page.close(); } }); -// A browser with no editing command, or one that refuses it, would have to -// assign `value` and leave nothing to undo. It is asked for a second press -// first, and the first one leaves the code exactly as it was. +// Native editing command support does not affect page-history restores. for (const [name, disable] of [ [ "a browser that offers the editing command but refuses it", @@ -1192,7 +827,7 @@ for (const [name, disable] of [ }, ], ]) { - test(`${name} keeps the code on the first press and restores on the second`, async (t) => { + test(`${name} restores on the first press`, async (t) => { const ctx = await openPage(t); if (!ctx) return; const { page } = ctx; @@ -1200,19 +835,21 @@ for (const [name, disable] of [ await overwrite(page, "kept only in the tab\n"); await page.evaluate(disable); await press(page); - assert.equal(await value(page), "kept only in the tab\n"); - assert.equal(await armed(page), true, "asked before the history is lost"); - await press(page); assert.equal(await value(page), starters.python); - assert.equal(await armed(page), false); + assert.deepEqual(ctx.dialogs, []); await language(page, "javascript"); await language(page, "python"); assert.equal( await value(page), starters.python, - "the fallback fires the input event, so the buffer is filed", + "the input event saves the restored buffer", ); + await page.locator("#editor").focus(); + await undo(page); + assert.equal(await value(page), "kept only in the tab\n"); + await redo(page); + assert.equal(await value(page), starters.python); assert.deepEqual(ctx.errors, []); } finally { await page.close(); @@ -1311,7 +948,7 @@ test("a buffer of other scripts and a longer one restores whole", async (t) => { } }); -test("Enter and Space on the focused button arm it and then replace the code", async (t) => { +test("Enter and Space on the focused button each restore on the first press", async (t) => { const ctx = await openPage(t, {}, { withoutEditingCommand: true }); if (!ctx) return; const { page } = ctx; @@ -1320,11 +957,11 @@ test("Enter and Space on the focused button arm it and then replace the code", a await overwrite(page, `before ${key}\n`); await page.focus("#editor-restore-starter"); await page.keyboard.press(key); - assert.equal(await armed(page), true, `${key} arms`); + assert.equal(await value(page), starters.python, key); + await undo(page); assert.equal(await value(page), `before ${key}\n`); - await page.keyboard.press(key); - assert.equal(await value(page), starters.python, `${key} replaces`); } + assert.deepEqual(ctx.errors, []); } finally { await page.close(); } @@ -1344,8 +981,7 @@ test("the control is a named button beside the language it acts on", async (t) = glyphHidden: node.firstElementChild.getAttribute("aria-hidden"), label: node.getAttribute("aria-label"), title: node.title, - promptHidden: document.querySelector("#editor-restore-prompt").hidden, - after: node.closest(".restore-control")?.previousElementSibling?.id, + after: node.previousElementSibling?.id, inToolbar: Boolean(node.closest(".editor-toolbar")), inDisplaySettings: Boolean(node.closest(".editor-view-controls")), inEnd: Boolean(node.closest(".toolbar-end")), @@ -1364,7 +1000,6 @@ test("the control is a named button beside the language it acts on", async (t) = glyphHidden: "true", label: "Restore the starter code", title: "Restore the starter code", - promptHidden: true, after: "language-select", inToolbar: true, inDisplaySettings: false, @@ -1395,7 +1030,6 @@ test("pressing it after the interview has ended changes nothing", async (t) => { "final answer\n", "the submitted code is not replaced once the interview is over", ); - assert.equal(await armed(page), false, "and nothing is left armed"); } finally { await page.close(); } @@ -1414,7 +1048,6 @@ test("a disabled editor is not restored", async (t) => { button.click(); }); assert.equal(await value(page), "locked work\n"); - assert.equal(await armed(page), false); } finally { await page.close(); } @@ -1463,8 +1096,7 @@ test("the behavioral round disables the control when the agent opens it", async { timeout: 5000 }, ); - await press(page); - assert.equal(await armed(page), true, "armed before the round opens"); + await button.focus(); // Off the control, so the resting look below is read without a hover. await page.mouse.move(0, 0); @@ -1488,7 +1120,7 @@ test("the behavioral round disables the control when the agent opens it", async () => document.querySelector("#editor").disabled, ); assert.equal(await button.isDisabled(), true, "disabled with the editor"); - assert.equal(await armed(page), false, "and no longer armed"); + assert.equal( await page.evaluate(() => document.activeElement?.id), "language-select", @@ -1595,33 +1227,20 @@ test("a reset goes out as a code_update for the current language", async (t) => } }); -test("nothing is sent until the confirming press, and a dropped arm starts over", async (t) => { +test("the first restore press publishes once and a repeated no-op publishes nothing", async (t) => { const ctx = await connectedPage(t, { withoutEditingCommand: true }); if (!ctx) return; const { page } = ctx; try { await sentBy(page, () => overwrite(page, "mine\n")); - assert.equal( - await sentQuietly(page, () => press(page)), - 0, - "armed only: nothing sent", - ); - // Leaving the button ends the arm, so the next press is not a confirmation. - await page.evaluate(() => document.querySelector("#editor").focus()); - assert.equal(await armed(page), false); - assert.equal( - await sentQuietly(page, () => press(page)), - 0, - "after the arm ended a press arms again and sends nothing", - ); - assert.equal(await armed(page), true); + const before = (await codePackets(page)).length; const packets = await sentBy(page, () => press(page)); - assert.equal(packets.at(-1).code, starters.python, "only this one sends"); - assert.equal( - await sentQuietly(page, () => restore(page)), - 0, - "already the starter: nothing sent", - ); + assert.equal(packets.length, before + 1); + assert.deepEqual(packets.at(-1), { + language: "python", + code: starters.python, + }); + assert.equal(await sentQuietly(page, () => press(page)), 0); assert.deepEqual(ctx.errors, []); } finally { await page.close(); @@ -1639,7 +1258,7 @@ test("a browser that refuses the editing command still sends the reset", async ( }); const packets = await sentBy(page, () => restore(page)); assert.equal(packets.at(-1).code, starters.python); - assert.deepEqual(ctx.dialogs, [], "confirmed in the toolbar, not a dialog"); + assert.deepEqual(ctx.dialogs, [], "restoring opens no blocking dialog"); assert.deepEqual(ctx.errors, []); } finally { await page.close(); @@ -1661,3 +1280,91 @@ test("a whiteboard interview loads without the editor panel and without error", await page.close(); } }); + +test("restore, undo and redo publish the active buffer after language switches", async (t) => { + const ctx = await connectedPage(t); + if (!ctx) return; + const { page } = ctx; + try { + const mine = "candidate solution\n"; + await sentBy(page, () => overwrite(page, mine)); + await page + .locator("#editor") + .evaluate((node) => node.setSelectionRange(2, 9, "backward")); + let packets = await sentBy(page, () => restore(page)); + assert.deepEqual(packets.at(-1), { + language: "python", + code: starters.python, + }); + await sentBy(page, () => language(page, "javascript")); + await sentBy(page, () => overwrite(page, "// JavaScript stays here\n")); + await sentBy(page, () => language(page, "python")); + await page.locator("#editor").focus(); + packets = await sentBy(page, () => undo(page)); + assert.deepEqual(packets.at(-1), { language: "python", code: mine }); + assert.deepEqual( + await page + .locator("#editor") + .evaluate((node) => [ + node.selectionStart, + node.selectionEnd, + node.selectionDirection, + ]), + [2, 9, "backward"], + ); + packets = await sentBy(page, () => redo(page)); + assert.deepEqual(packets.at(-1), { + language: "python", + code: starters.python, + }); + await sentBy(page, () => language(page, "javascript")); + assert.equal(await value(page), "// JavaScript stays here\n"); + assert.deepEqual(ctx.errors, []); + } finally { + await page.close(); + } +}); + +for (const [index, name] of LANGUAGES.entries()) { + test(`${name} starter restore stays undoable and redoable after switching languages`, async (t) => { + const ctx = await openPage(t); + if (!ctx) return; + const { page } = ctx; + try { + await language(page, name); + const marker = name === "python" ? "#" : "//"; + const mine = `${starters[name]}\n${marker} ${name} candidate work\n`; + await overwrite(page, mine); + await page + .locator("#editor") + .evaluate((node) => node.setSelectionRange(2, 7, "backward")); + await restore(page); + assert.equal(await value(page), starters[name]); + const other = LANGUAGES[(index + 1) % LANGUAGES.length]; + await language(page, other); + assert.equal(await value(page), starters[other]); + await language(page, name); + await page.locator("#editor").focus(); + await undo(page); + assert.equal(await value(page), mine); + assert.deepEqual( + await page + .locator("#editor") + .evaluate((node) => [ + node.selectionStart, + node.selectionEnd, + node.selectionDirection, + ]), + [2, 7, "backward"], + ); + await language(page, other); + await language(page, name); + await page.locator("#editor").focus(); + await redo(page); + assert.equal(await value(page), starters[name]); + assert.deepEqual(ctx.errors, []); + } finally { + await page.close(); + } + }); +} diff --git a/web/editor-history.js b/web/editor-history.js new file mode 100644 index 000000000..60c043bcf --- /dev/null +++ b/web/editor-history.js @@ -0,0 +1,83 @@ +// Selection belongs to an edit, not just to the current buffer: moving the +// caret after an edit must not change what undo or redo will restore. +export function editorSnapshot(editor) { + return { + value: editor.value, + start: editor.selectionStart, + end: editor.selectionEnd, + direction: editor.selectionDirection, + }; +} + +export function createEditorHistory({ limit = 200, groupDelay = 750 } = {}) { + if (!Number.isInteger(limit) || limit < 1) + throw new RangeError("History limit must be a positive integer"); + const languages = new Map(); + let active = null; + let group = null; + + const copy = (snapshot) => ({ ...snapshot }); + const same = (left, right) => + left.value === right.value && + left.start === right.start && + left.end === right.end && + left.direction === right.direction; + + function select(language, snapshot, outgoing = null) { + group = null; + if (active && outgoing) active.current = copy(outgoing); + if (!languages.has(language)) + languages.set(language, { + entries: [], + cursor: 0, + current: copy(snapshot), + }); + active = languages.get(language); + return copy(active.current); + } + + function record(before, after, kind = null, now = Date.now()) { + if (!active) throw new Error("Select a language before recording edits"); + active.current = copy(after); + if (before.value === after.value) return; + const previous = active.entries[active.cursor - 1]; + const merge = + kind !== null && + group?.kind === kind && + now - group.time >= 0 && + now - group.time <= groupDelay && + active.cursor === active.entries.length && + previous && + same(previous.after, before); + active.entries.splice(active.cursor); + if (merge) previous.after = copy(after); + else { + active.entries.push({ before: copy(before), after: copy(after) }); + if (active.entries.length > limit) active.entries.shift(); + } + active.cursor = active.entries.length; + group = kind === null ? null : { kind, time: now }; + } + + function undo() { + group = null; + if (!active || active.cursor === 0) return null; + active.current = copy(active.entries[--active.cursor].before); + return copy(active.current); + } + + function redo() { + group = null; + if (!active || active.cursor === active.entries.length) return null; + active.current = copy(active.entries[active.cursor++].after); + return copy(active.current); + } + + return { + select, + record, + undo, + redo, + breakGroup: () => (group = null), + }; +} diff --git a/web/editor.js b/web/editor.js index 3f1ec962c..5b7b8a581 100644 --- a/web/editor.js +++ b/web/editor.js @@ -177,9 +177,8 @@ export function typeOverCloser(value, start, end, closer, language, parse) { } // Same content-check-only shape as the bracket predicates above, and for the -// same reason: interview.js's reentrancy flag is what keeps this from -// recursing into deleteEmptyPair while its own execCommand("delete") is -// still running, not isTrusted. +// same reason: interview.js decides which events belong to an edit; this +// predicate describes the requested deletion, regardless of isTrusted. export function isBackspaceKeystroke(event) { return event.inputType === "deleteContentBackward"; } @@ -331,3 +330,18 @@ export function toggleLineComment(value, start, end, language) { // marker goes in still ends before it. return { value, start: map(start, true), end: map(end, start === end) }; } + +// Move exactly the selected characters; all other text, including empty lines, +// stays intact. The browser's drop position refers to the original buffer. +export function moveSelectedText(value, start, end, target) { + if (start === end || (target >= start && target <= end)) + return { value, start, end }; + const text = value.slice(start, end); + const remaining = value.slice(0, start) + value.slice(end); + const insertion = target > end ? target - text.length : target; + return { + value: remaining.slice(0, insertion) + text + remaining.slice(insertion), + start: insertion, + end: insertion + text.length, + }; +} diff --git a/web/interview.html b/web/interview.html index 7bd9375fd..1279e07ad 100644 --- a/web/interview.html +++ b/web/interview.html @@ -197,27 +197,17 @@

Loading interview...

- - - - - + + head && previous[end - 1] === "\n"; - const selectedEnd = endsInNewline ? end - 1 : end; - editor.setSelectionRange(head, selectedEnd); - let rewritten = true; +} + +// Page history owns programmatic edits too. Assign the complete buffer and +// selection, then use the usual input path for overlays, replay and code_update. +function applyEditorEdit(next) { + finishEditorDrag(); + finishEditorComposition(); + const editor = nodes.editor; + const before = editorSnapshot(editor); + pendingEditorEdit = null; + editorHistory.breakGroup(); + const direction = next.start === next.end ? "none" : before.direction; + if (next.value !== before.value) editor.value = next.value; + editor.setSelectionRange(next.start, next.end, direction); + observedEditor = editorSnapshot(editor); + editorHistory.record(before, observedEditor); + if (next.value === before.value) return; applyingProgrammaticEdit = true; try { - if (inserted) - rewritten = document.execCommand?.("insertText", false, inserted); - else if (selectedEnd > head) rewritten = document.execCommand?.("delete"); - if (rewritten && endsInNewline) - rewritten = document.execCommand?.("forwardDelete"); + editor.dispatchEvent(new Event("input", { bubbles: true })); } finally { applyingProgrammaticEdit = false; } - if (!rewritten && keepUndo) { - if (editor.value !== previous) { - applyingProgrammaticEdit = true; - try { - document.execCommand?.("undo"); - } finally { - applyingProgrammaticEdit = false; - } - } - if (editor.value !== previous) { - editor.value = previous; - editor.dispatchEvent(new Event("input", { bubbles: true })); - } - editor.setSelectionRange(...selection); - return false; - } - if (!rewritten) { - editor.value = next.value; - editor.dispatchEvent(new Event("input", { bubbles: true })); - } - editor.setSelectionRange(next.start, next.end, direction); - return true; } function renderJimVolume() { @@ -1029,38 +996,12 @@ function renderJimVolume() { nodes.jimMutedIndicator.hidden = !jimVolume.muted; } -function disarmRestore() { - restoreArmed = null; - delete nodes.restoreStarter.dataset.armed; - nodes.restorePrompt.hidden = true; - nodes.restoreStarter.setAttribute("aria-label", "Restore the starter code"); +function clearRestoreStatus() { nodes.restoreStatus.textContent = ""; } -function armRestore() { - restoreArmed = { language: state.language, value: nodes.editor.value }; - nodes.restoreStarter.dataset.armed = "true"; - nodes.restorePrompt.hidden = false; - // The visible words stay inside the name, so a voice user can say them. - nodes.restoreStarter.setAttribute( - "aria-label", - "Press again to replace your code with the starter code", - ); - nodes.restoreStatus.textContent = - "Press again to replace your code with the starter code. Ctrl+Z (Cmd+Z on a Mac) may not bring it back."; - // WebKit does not focus a button that is clicked, and the blur and Escape - // that end the arm are heard only by a focused button, so a mouse press - // would arm it with no way left to cancel. Focus is put there to match. - nodes.restoreStarter.focus({ preventScroll: true }); -} - -/// Says something in the control's status region, which the caller has just -/// cleared with `disarmRestore`. A live region reads only a change, so the -/// words are written on the next frame, after the clear has been seen, or a -/// second press would go unheard; and not at all if something else has written -/// there in between, so a late message never overwrites the prompt a press has -/// just armed. `stillTrue` is asked again on the frame, since an edit, a -/// language switch or a pause in between can make the message false. +// A live region needs a change to announce the same message twice. Wait for +// the clear to be seen, and refuse a message made stale by an edit or lock. function announceRestore(message, stillTrue) { requestAnimationFrame(() => { if (nodes.restoreStatus.textContent === "" && stillTrue()) @@ -1068,26 +1009,18 @@ function announceRestore(message, stillTrue) { }); } -/// Puts the current language's starter back. A reset is an edit like any -/// other: the input listener files it in `codeByLanguage` and schedules the -/// usual publish. -/// -/// The first press replaces the buffer through the editing command, so Ctrl+Z -/// brings it back. A browser that has no such command, or refuses it, would -/// have to assign `value`, which leaves nothing to undo, so there the buffer is -/// left alone and a second press confirms. That is done in the toolbar and -/// not with `window.confirm`, which blocks the page's event loop while it is -/// open and stalls the captions, the timer and the data channel with it. +// Restoring is one ordinary, undoable edit in the current language. function restoreStarter() { + clearRestoreStatus(); const starter = problem?.starterCode[state.language]; - if (codingClosed() || nodes.editor.disabled || starter === undefined) { - disarmRestore(); + if ( + codingClosed() || + nodes.editor.disabled || + nodes.editor.readOnly || + starter === undefined + ) return; - } if (nodes.editor.value === starter) { - // Nothing to replace, and a press that does nothing silently looks broken - // and says nothing to a screen reader. - disarmRestore(); announceRestore( "The code is already the starter code.", () => @@ -1096,26 +1029,10 @@ function restoreStarter() { ); return; } - const confirmed = - restoreArmed && - restoreArmed.language === state.language && - restoreArmed.value === nodes.editor.value; - if (!confirmed && !document.queryCommandSupported?.("insertText")) { - armRestore(); - return; - } - disarmRestore(); nodes.editor.focus(); - const restored = applyEditorEdit( - { value: starter, start: 0, end: 0 }, - { splitNewline: true, keepUndo: !confirmed }, - ); - if (!restored) { - armRestore(); - return; - } + applyEditorEdit({ value: starter, start: 0, end: 0 }); nodes.restoreStatus.textContent = - "Restored the starter code. Ctrl+Z (Cmd+Z on a Mac) may undo this, but not after you switch languages."; + "Restored the starter code. Ctrl+Z (Cmd+Z on a Mac) undoes this, including after switching languages."; } function bindEvents() { @@ -1265,28 +1182,7 @@ function bindEvents() { ); }); } - nodes.restoreStarter.addEventListener("click", (event) => { - // The second click of a double-click is one gesture, not a confirmation. - // A keyboard activation reports a detail of 0, so it is not caught here. - if (event.detail > 1) return; - restoreStarter(); - }); - // Safari does not focus a clicked button, and its mousedown clears the focus - // that `armRestore` put there, so the blur below would end the arm before the - // second click could confirm it. Keeping the focus where it is avoids that. - nodes.restoreStarter.addEventListener("mousedown", (event) => - event.preventDefault(), - ); - nodes.restoreStarter.addEventListener("keydown", (event) => { - // Enter clicks a button on every auto-repeated keydown, so holding it would - // arm the control and then confirm it with no second press. - if (event.repeat && (event.key === "Enter" || event.key === " ")) - event.preventDefault(); - else if (event.key === "Escape" && restoreArmed) disarmRestore(); - }); - nodes.restoreStarter.addEventListener("blur", () => { - if (restoreArmed) disarmRestore(); - }); + nodes.restoreStarter.addEventListener("click", restoreStarter); nodes.editorThemeToggle.addEventListener("click", () => { // Off the effective theme, not the attribute: with no explicit choice @@ -1306,17 +1202,85 @@ function bindEvents() { matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => applyEditorTheme(nodes.editorStack.dataset.theme), ); + nodes.editor.addEventListener("dragstart", () => { + finishEditorComposition(); + editorHistory.breakGroup(); + editorDrag = { + before: editorSnapshot(nodes.editor), + localDrop: false, + moving: false, + }; + }); + nodes.editor.addEventListener("drop", () => { + if (editorDrag) editorDrag.localDrop = true; + }); + nodes.editor.addEventListener("dragend", finishEditorDrag); nodes.editor.addEventListener("scroll", syncEditorScroll); document.addEventListener("selectionchange", () => { - if (document.activeElement === nodes.editor) scheduleEditorPaint(); + if (document.activeElement === nodes.editor) { + // Keep input-only history repairs at the latest selection, without + // accepting text that a native history action has already changed. + if (observedEditor?.value === nodes.editor.value) + observedEditor = editorSnapshot(nodes.editor); + scheduleEditorPaint(); + } }); nodes.editor.addEventListener("focus", () => paintEditor()); - nodes.editor.addEventListener("blur", () => paintEditor(false)); + nodes.editor.addEventListener("blur", () => { + finishEditorComposition(); + editorHistory.breakGroup(); + paintEditor(false); + }); + nodes.editor.addEventListener("pointerdown", () => + editorHistory.breakGroup(), + ); + nodes.editor.addEventListener("compositionstart", () => { + finishEditorComposition(); + editorHistory.breakGroup(); + editorComposition = editorSnapshot(nodes.editor); + }); + nodes.editor.addEventListener("compositionend", () => { + // Some engines send the committed input after compositionend. Include it + // before closing the transaction, rather than recording its text twice. + compositionTimer = setTimeout(finishEditorComposition, 0); + }); // Tab indents, so it cannot also move focus. Escape arms the next Tab to do // that instead, or a candidate driving the page from the keyboard is stuck // in the textarea with no way out. let tabLeavesEditor = false; nodes.editor.addEventListener("keydown", (event) => { + const key = /^[\x20-\x7e]$/.test(event.key) + ? event.key.toLowerCase() + : event.key.length === 1 + ? event.code?.replace(/^Key/, "").toLowerCase() + : event.key; + const historyKey = + (mac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey) && + !event.altKey && + (key === "z" || (!mac && key === "y" && !event.shiftKey)); + if (historyKey) { + // Let the input method handle its own candidate text until it commits. + if (event.isComposing || event.keyCode === 229) return; + event.preventDefault(); + if (!nodes.editor.disabled && !nodes.editor.readOnly) + restoreEditorHistory(key === "y" || event.shiftKey); + return; + } + if ( + [ + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", + ].includes(event.key) + ) + editorHistory.breakGroup(); if (event.key === "Escape") { tabLeavesEditor = true; return; @@ -1353,6 +1317,52 @@ function bindEvents() { ); }); nodes.editor.addEventListener("beforeinput", (event) => { + if (["historyUndo", "historyRedo"].includes(event.inputType)) { + if (event.isComposing) return; + if (event.cancelable) { + event.preventDefault(); + if (!nodes.editor.disabled && !nodes.editor.readOnly) + restoreEditorHistory(event.inputType === "historyRedo"); + } else if (!nodes.editor.disabled && !nodes.editor.readOnly) { + // selectionchange may still be queued when this action starts. + observedEditor = editorSnapshot(nodes.editor); + } + return; + } + // Keep the buffer unchanged until the browser supplies the drop position. + // Native deleteByDrag can also remove an unselected trailing newline. + if (editorDrag?.localDrop && event.cancelable) { + if (event.inputType === "deleteByDrag") { + event.preventDefault(); + editorDrag.moving = true; + return; + } + if (event.inputType === "insertFromDrop" && editorDrag.moving) { + event.preventDefault(); + const before = editorDrag.before; + const next = moveSelectedText( + before.value, + before.start, + before.end, + nodes.editor.selectionStart, + ); + editorDrag = null; + nodes.editor.setSelectionRange( + before.start, + before.end, + before.direction, + ); + applyEditorEdit(next); + return; + } + } + if (!applyingProgrammaticEdit && !editorComposition) { + pendingEditorEdit = { + before: editorSnapshot(nodes.editor), + type: event.inputType, + data: event.data, + }; + } if (applyingProgrammaticEdit || event.isComposing || !event.cancelable) return; if (event.inputType === "insertLineBreak") { @@ -1407,7 +1417,34 @@ function bindEvents() { applyEditorEdit(removal); } }); - nodes.editor.addEventListener("input", () => { + nodes.editor.addEventListener("input", (event) => { + if (!replayingEditorHistory && !applyingProgrammaticEdit) { + if ( + !event.isComposing && + ["historyUndo", "historyRedo"].includes(event.inputType) + ) { + // Non-cancelable native edits can notify only after writing. Discard + // that result and replay the current language's edit. + restoreEditorHistory(event.inputType === "historyRedo", true); + return; + } + if (!editorComposition && !editorDrag) { + const before = pendingEditorEdit?.before || observedEditor; + const after = editorSnapshot(nodes.editor); + const type = pendingEditorEdit?.type; + const grouping = + before?.start === before?.end && + ((type === "insertText" && + pendingEditorEdit.data?.length === 1 && + !/\s/.test(pendingEditorEdit.data)) || + ["deleteContentBackward", "deleteContentForward"].includes(type)) + ? type + : null; + if (before) editorHistory.record(before, after, grouping); + observedEditor = after; + pendingEditorEdit = null; + } + } languageChoiceCommitted = true; // Only a changed code buffer counts as the candidate's response. if (nodes.editor.value !== state.codeByLanguage[state.language]) { @@ -1446,7 +1483,7 @@ function bindEvents() { // from the first `nodes.editor` input listener to that one on its own, so a // listener that reads this module's state cannot sit inside that span. nodes.editor.addEventListener("input", () => { - if (restoreArmed) disarmRestore(); + clearRestoreStatus(); }); } @@ -2869,18 +2906,31 @@ function setLanguage(language) { }) .catch(() => {}); } + finishEditorDrag(); + finishEditorComposition(); if (editorInitialized) { // A switch is a coalescer boundary. Flush the old tab before selecting the // new one so a quick click cannot replace an unreported edit with the new // language's starter buffer. flushPendingEditorPublish(); state.codeByLanguage[state.language] = nodes.editor.value; - // A round trip lands on the same language and buffer the arm was taken - // for, so the match in `restoreStarter` would honor it. - disarmRestore(); + clearRestoreStatus(); } + const next = editorHistory.select( + language, + { + value: state.codeByLanguage[language], + start: 0, + end: 0, + direction: "none", + }, + editorInitialized ? editorSnapshot(nodes.editor) : null, + ); state.language = language; - nodes.editor.value = state.codeByLanguage[language]; + nodes.editor.value = next.value; + nodes.editor.setSelectionRange(next.start, next.end, next.direction); + observedEditor = editorSnapshot(nodes.editor); + pendingEditorEdit = null; paintEditor(); editorInitialized = true; nodes.languageSelect.value = language; @@ -3499,12 +3549,12 @@ function removeCandidateCase(index) { function updateRunAvailability() { const restoreOff = codingClosed(); // Disabling the control that has focus drops focus to the body, so a keyboard - // user who armed it is moved first, to the language picker, which this never + // user is moved first, to the language picker, which this never // disables, as the board buttons do. if (restoreOff && document.activeElement === nodes.restoreStarter) nodes.languageSelect.focus({ preventScroll: true }); nodes.restoreStarter.disabled = restoreOff; - if (restoreOff) disarmRestore(); + if (restoreOff) clearRestoreStatus(); nodes.run.disabled = !editorOptions.execution || (strict && !state.connected) || @@ -4569,7 +4619,6 @@ function initWhiteboard() { nodes.board.addEventListener("pointerenter", paintBoardCursor); window.addEventListener("resize", paintBoardCursor); paintToolState(); - const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform); for (const [button, action, shortcut, hint] of [ [ nodes.boardUndo, diff --git a/web/styles.css b/web/styles.css index 74652adc9..9d8d20678 100644 --- a/web/styles.css +++ b/web/styles.css @@ -636,37 +636,11 @@ p { /* The light-theme toggle's label stays fixed (see applyEditorTheme), so sighted users need this instead of changing text to see which theme is current. */ -#editor-theme-toggle[aria-pressed="true"], -#editor-restore-starter[data-armed="true"] { +#editor-theme-toggle[aria-pressed="true"] { border-color: var(--accent); color: var(--ink); } -/* The first press of Restore starter says what a second would do. It hangs - below the toolbar instead of widening it, so arming never moves the toolbar - onto another row, and it is held inside the toolbar's width: anchored to the - button it ran past the panel's edge on a narrow screen and made the panel - scroll sideways. It takes no pointer events, so the code under it stays - clickable. The toolbar is what it hangs from, so it is positioned below. */ -.restore-control { - display: inline-flex; -} - -.restore-prompt { - position: absolute; - pointer-events: none; - top: calc(100% + 0.25rem); - left: 0.75rem; - max-width: calc(100% - 1.5rem); - z-index: 2; - padding: 0.25rem 0.625rem; - border: 1px solid var(--accent); - border-radius: 0.5rem; - background: var(--surface); - color: var(--ink); - font-size: 0.75rem; -} - .history { margin-top: 1rem; padding: 1rem;