diff --git a/src/cubing/twisty/views/TwistyAlgEditor/TwistyAlgEditor.ts b/src/cubing/twisty/views/TwistyAlgEditor/TwistyAlgEditor.ts index 27dd6711d..6b82c9bf0 100644 --- a/src/cubing/twisty/views/TwistyAlgEditor/TwistyAlgEditor.ts +++ b/src/cubing/twisty/views/TwistyAlgEditor/TwistyAlgEditor.ts @@ -24,6 +24,7 @@ import { ManagedCustomElement } from "../ManagedCustomElement"; import { customElementsShim } from "../node-custom-element-shims"; import { TwistyPlayer } from "../TwistyPlayer"; import { type HighlightInfo, TwistyAlgEditorModel } from "./model"; +import { pasteIntoTextAreaForTesting } from "./paste"; import { twistyAlgEditorCSS } from "./TwistyAlgEditor.css"; const ATTRIBUTE_FOR_TWISTY_PLAYER = "for-twisty-player"; @@ -115,6 +116,17 @@ export class TwistyAlgEditor extends ManagedCustomElement { } } + connectedCallback(): void { + this.#textarea.addEventListener("paste", (e) => { + const text = e.clipboardData?.getData("text"); + if (text) { + pasteIntoTextAreaForTesting(this.#textarea, text); + e.preventDefault(); + this.onInput(); + } + }); + } + // TODO set algString(s: string) { this.#textarea.value = s; diff --git a/src/cubing/twisty/views/TwistyAlgEditor/paste.spec.ts b/src/cubing/twisty/views/TwistyAlgEditor/paste.spec.ts new file mode 100644 index 000000000..bfbee5079 --- /dev/null +++ b/src/cubing/twisty/views/TwistyAlgEditor/paste.spec.ts @@ -0,0 +1,76 @@ +import { expect } from "../../../../test/chai-workarounds"; + +import { pasteIntoTextAreaForTesting } from "./paste"; + +function createMockTextArea( + originalValue: string, + selectionStart: number, + selectionEnd: number, +): HTMLTextAreaElement { + const mockObject = { + value: originalValue, + selectionStart, + selectionEnd, + newValue: null, + } as any as HTMLTextAreaElement; + mockObject.setRangeText = ( + replacement: string, + start?: number, + end?: number, + selectionMode?: SelectionMode, + ) => { + expect(start).to.equal(selectionStart); + expect(end).to.equal(selectionEnd); + expect(selectionMode).to.equal("end"); + mockObject.value = + originalValue.slice(0, selectionStart) + + replacement + + originalValue.slice(selectionEnd); + }; + return mockObject; +} + +function mockPaste( + originalValue: string, + selectionStart: number, + selectionEnd: number, + pastedText: string, +): string { + const mockTextArea = createMockTextArea( + originalValue, + selectionStart, + selectionEnd, + ); + pasteIntoTextAreaForTesting(mockTextArea, pastedText); + return mockTextArea.value; +} + +describe("pasteIntoTextArea", () => { + it("handles basic spacing", () => { + expect(mockPaste("R L2", 2, 2, "U'")).to.equal("R U' L2"); + expect(mockPaste("R L2", 2, 2, "U'")).to.equal("R U' L2"); + expect(mockPaste("R L2", 1, 1, "2-5u'")).to.equal("R 2-5u' L2"); + expect(mockPaste("R L2", 1, 2, "2-5Uw'")).to.equal("R 2-5Uw' L2"); + expect(mockPaste("R L2", 0, 2, "U'")).to.equal("U' L2"); + expect(mockPaste("R D", 2, 2, "U L\n")).to.equal("R U L\nD"); + expect(mockPaste("R D", 1, 1, "U L\n")).to.equal("R U L\n D"); + expect(mockPaste("R U ", 4, 4, "L")).to.equal("R U L"); + expect(mockPaste("D B", 0, 0, "L")).to.equal("L D B"); + }); + it("does smart quote correction", () => { + expect(mockPaste("R L2 // '’", 2, 2, "U’")).to.equal("R U' L2 // '’"); + expect(mockPaste("R L2 // '’", 2, 2, "U’")).to.equal("R U' L2 // '’"); + expect(mockPaste("R L2 // '’", 1, 1, "U’")).to.equal("R U' L2 // '’"); + expect(mockPaste("R L2 // '’", 1, 2, "U’")).to.equal("R U' L2 // '’"); + expect(mockPaste("R L2", 1, 2, "U’ // It’s fancy!\n")).to.equal( + "R U' // It’s fancy!\nL2", + ); + }); + it("handles non-semantic paste", () => { + expect(mockPaste("R L2'", 2, 3, "U")).to.equal("R U2'"); + expect(mockPaste("R D2", 1, 3, " R U R’ D2")).to.equal("R R U R' D22"); + expect(mockPaste("(3, 4) /", 8, 8, "/ comment’")).to.equal( + "(3, 4) // comment’", + ); + }); +}); diff --git a/src/cubing/twisty/views/TwistyAlgEditor/paste.ts b/src/cubing/twisty/views/TwistyAlgEditor/paste.ts new file mode 100644 index 000000000..8c2c80cb5 --- /dev/null +++ b/src/cubing/twisty/views/TwistyAlgEditor/paste.ts @@ -0,0 +1,112 @@ +import { Alg } from "../../../alg"; + +const COMMENT_DELIMITER = "//"; + +function maybeParse(str: string): Alg | null { + try { + return Alg.fromString(str); + } catch { + return null; + } +} + +// If there is no occurence, the original string is returned in the first entry. +// If there is, the delimiter is included at the start of the second entry. +function splitBeforeOnFirstOccurrence( + str: string, + delimiter: string, +): [before: string, after: string] { + const idx = str.indexOf(delimiter); + if (idx === -1) { + return [str, ""]; + } + return [str.slice(0, idx), str.slice(idx)]; +} + +function replaceSmartQuotesOutsideComments(str: string): string { + const linesOut = []; + for (const line of str.split("\n")) { + let [before, after] = splitBeforeOnFirstOccurrence(line, COMMENT_DELIMITER); + before = before.replaceAll("’", "'"); + linesOut.push(before + after); + } + return linesOut.join("\n"); +} + +// Returns whether the paste was successful. +function pasteIntoTextArea( + textArea: HTMLTextAreaElement, + pastedText: string, +): void { + const { value: oldValue } = textArea; + const { selectionStart, selectionEnd } = textArea; + + const textPrecedingSelection = oldValue.slice(0, selectionStart); + const textFollowingSelection = oldValue.slice(selectionEnd); + + // Does the last line end in a comment? + // Note that we want the match to include "R U R'\n//hello there" but not "// hello there\nR U R'" + const selectionStartsInExistingComment = + textPrecedingSelection.match(/\/\/[^\n]*$/); + const pasteCreatesStartingComment = + oldValue[selectionStart - 1] === "/" && pastedText[0] === "/"; // Pasting "/ This is “weird”." at the end of "R U R' /" + const pasteStartsWithCommentText = + selectionStartsInExistingComment || pasteCreatesStartingComment; + + const pasteEndsWithComment = pastedText.match(/\/\/[^\n]*$/); + + // Replace smart quotes that are not in comments. + let replacement = pastedText; + if (pasteStartsWithCommentText) { + const [before, after] = splitBeforeOnFirstOccurrence(pastedText, "\n"); + replacement = before + replaceSmartQuotesOutsideComments(after); + } else { + replacement = replaceSmartQuotesOutsideComments(pastedText); + } + + /** Note: at this point, we would want to test which of the following produces + * a valid alg: + * + * - No changes to `correctedPastedText`. + * - Add a space prefix. + * - Add a space suffix. + * + * However, the puzzle is not synchronously available to us, so we can't tell + * whether pasting "U" before "R" should create "UR" (Megaminx) or "U R" + * (3x3x3). So we optimistically assume the pasted alg is self-contained + * (which is the case if it's a fully valid alg on its own) and try to perform + * the latter directly. + */ + + const tryAddSpaceBefore = + !pasteStartsWithCommentText && + selectionStart !== 0 && // Not at text start + !["\n", " "].includes(replacement[0]) && + !["\n", " "].includes(oldValue[selectionStart - 1]); // Not at line start, no space before + const tryAddSpaceAfter = + !pasteEndsWithComment && + selectionEnd !== oldValue.length && // Not at text end + !(["\n", " "] as (string | undefined)[]).includes(replacement.at(-1)) && + !["\n", " "].includes(oldValue[selectionEnd]); // Not at line end, no space after + + function adoptSpacingIfValid(prefix: string, suffix: string): boolean { + const candidate = prefix + replacement + suffix; + const valid = !!maybeParse( + textPrecedingSelection + candidate + textFollowingSelection, + ); + if (valid) { + replacement = candidate; + } + return valid; + } + + // Here, we try possible space insertions. We use `||` for short-circuit logic, to adopt the first valid one. + (tryAddSpaceBefore && tryAddSpaceAfter && adoptSpacingIfValid(" ", " ")) || // Paste "R U R' U'" over " " in "F F'" to create "F R U R' U' F'" + (tryAddSpaceBefore && adoptSpacingIfValid(" ", "")) || // Paste "U" after "R'" in "R' L'" to create "R' U L'" + (tryAddSpaceAfter && adoptSpacingIfValid("", " ")); // Paste "U" before "L'" in "R' L'" to create "R' U L'" + + // TODO: use "select" or "preserve" (https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setRangeText#selectmode) + textArea.setRangeText(replacement, selectionStart, selectionEnd, "end"); +} + +export const pasteIntoTextAreaForTesting = pasteIntoTextArea;