-
Notifications
You must be signed in to change notification settings - Fork 48
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'fix-smart-quotes-on-paste'
- Loading branch information
Showing
3 changed files
with
200 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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’", | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |