Skip to content

Commit

Permalink
Merge branch 'fix-smart-quotes-on-paste'
Browse files Browse the repository at this point in the history
  • Loading branch information
lgarron committed Jul 13, 2023
2 parents 6886a17 + 8fbb1d9 commit 695d369
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 0 deletions.
12 changes: 12 additions & 0 deletions src/cubing/twisty/views/TwistyAlgEditor/TwistyAlgEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
76 changes: 76 additions & 0 deletions src/cubing/twisty/views/TwistyAlgEditor/paste.spec.ts
Original file line number Diff line number Diff line change
@@ -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’",
);
});
});
112 changes: 112 additions & 0 deletions src/cubing/twisty/views/TwistyAlgEditor/paste.ts
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 695d369

Please sign in to comment.