fix: keep short composer pastes as text
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
export const MAX_PLAIN_TEXT_PASTE_CHARS = 50;
|
||||
export const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES = 3;
|
||||
|
||||
export type ComposerPastePresentation = "text" | "chip";
|
||||
|
||||
export interface ComposerPasteMeasurement {
|
||||
charCount: number;
|
||||
logicalLineCount: number;
|
||||
presentation: ComposerPastePresentation;
|
||||
}
|
||||
|
||||
export interface ComposerPasteEvent {
|
||||
clipboardData: { getData(format: string): string } | null;
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count user-visible Unicode scalar values rather than UTF-16 code units.
|
||||
* JavaScript string iteration combines a valid surrogate pair into one value.
|
||||
*/
|
||||
export function unicodeScalarCount(content: string): number {
|
||||
let count = 0;
|
||||
for (const _value of content) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty content has zero logical lines. Otherwise each LF, lone CR, or CRLF
|
||||
* advances one line; CRLF is one break rather than two.
|
||||
*/
|
||||
export function logicalLineCount(content: string): number {
|
||||
if (content.length === 0) return 0;
|
||||
|
||||
let count = 1;
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const codeUnit = content.charCodeAt(index);
|
||||
if (codeUnit === 0x0d) {
|
||||
if (content.charCodeAt(index + 1) === 0x0a) index += 1;
|
||||
count += 1;
|
||||
} else if (codeUnit === 0x0a) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function measureComposerPaste(
|
||||
content: string,
|
||||
): ComposerPasteMeasurement {
|
||||
const charCount = unicodeScalarCount(content);
|
||||
const logicalLineCountValue = logicalLineCount(content);
|
||||
const presentation = charCount <= MAX_PLAIN_TEXT_PASTE_CHARS &&
|
||||
logicalLineCountValue <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||
? "text"
|
||||
: "chip";
|
||||
|
||||
return {
|
||||
charCount,
|
||||
logicalLineCount: logicalLineCountValue,
|
||||
presentation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Browser paste without disrupting native short-text editing.
|
||||
*
|
||||
* Returning false means the caller must leave the event untouched, preserving
|
||||
* the Browser's cursor, selection replacement, and undo behavior. A chip paste
|
||||
* is consumed exactly once and handed to the compact-paste implementation.
|
||||
*/
|
||||
export function handleComposerPaste(
|
||||
event: ComposerPasteEvent,
|
||||
insertCompactPaste: (
|
||||
content: string,
|
||||
measurement: ComposerPasteMeasurement,
|
||||
) => void,
|
||||
): boolean {
|
||||
if (!event.clipboardData) return false;
|
||||
|
||||
const content = event.clipboardData.getData("text/plain");
|
||||
const measurement = measureComposerPaste(content);
|
||||
if (measurement.presentation === "text") return false;
|
||||
|
||||
event.preventDefault();
|
||||
insertCompactPaste(content, measurement);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import fixtureJson from "../../../tests/fixtures/composer-paste-policy.json" with {
|
||||
type: "json",
|
||||
};
|
||||
|
||||
import {
|
||||
type ComposerPasteMeasurement,
|
||||
handleComposerPaste,
|
||||
MAX_PLAIN_TEXT_PASTE_CHARS,
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||
measureComposerPaste,
|
||||
} from "../src/lib/workspace/console/composer-paste.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertEquals(
|
||||
actual: unknown,
|
||||
expected: unknown,
|
||||
message?: string,
|
||||
): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(
|
||||
`${
|
||||
message ? `${message}: ` : ""
|
||||
}expected ${expectedJson}, got ${actualJson}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FixturePart {
|
||||
value: string;
|
||||
repeat: number;
|
||||
}
|
||||
|
||||
interface FixtureCase {
|
||||
name: string;
|
||||
parts: FixturePart[];
|
||||
char_count: number;
|
||||
logical_line_count: number;
|
||||
presentation: "text" | "chip";
|
||||
}
|
||||
|
||||
interface PastePolicyFixture {
|
||||
max_plain_text_chars: number;
|
||||
max_plain_text_logical_lines: number;
|
||||
cases: FixtureCase[];
|
||||
}
|
||||
|
||||
const fixture = fixtureJson as PastePolicyFixture;
|
||||
|
||||
function fixtureContent(testCase: FixtureCase): string {
|
||||
return testCase.parts.map(({ value, repeat }) => value.repeat(repeat)).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("Browser composer follows the shared paste presentation contract", () => {
|
||||
assertEquals(MAX_PLAIN_TEXT_PASTE_CHARS, fixture.max_plain_text_chars);
|
||||
assertEquals(
|
||||
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES,
|
||||
fixture.max_plain_text_logical_lines,
|
||||
);
|
||||
|
||||
for (const testCase of fixture.cases) {
|
||||
assertEquals(
|
||||
measureComposerPaste(fixtureContent(testCase)),
|
||||
{
|
||||
charCount: testCase.char_count,
|
||||
logicalLineCount: testCase.logical_line_count,
|
||||
presentation: testCase.presentation,
|
||||
},
|
||||
testCase.name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("short paste remains a native Browser edit", () => {
|
||||
let prevented = false;
|
||||
let inserted = 0;
|
||||
const handled = handleComposerPaste(
|
||||
{
|
||||
clipboardData: { getData: () => "replace the selection" },
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
},
|
||||
() => {
|
||||
inserted += 1;
|
||||
},
|
||||
);
|
||||
|
||||
assertEquals(handled, false);
|
||||
assertEquals(prevented, false);
|
||||
assertEquals(inserted, 0);
|
||||
});
|
||||
|
||||
Deno.test("chip paste is prevented and routed exactly once", () => {
|
||||
const content = "🦀".repeat(51);
|
||||
let prevented = 0;
|
||||
const inserted: Array<[string, ComposerPasteMeasurement]> = [];
|
||||
const handled = handleComposerPaste(
|
||||
{
|
||||
clipboardData: { getData: () => content },
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
},
|
||||
(paste, measurement) => inserted.push([paste, measurement]),
|
||||
);
|
||||
|
||||
assertEquals(handled, true);
|
||||
assertEquals(prevented, 1);
|
||||
assertEquals(inserted, [[content, {
|
||||
charCount: 51,
|
||||
logicalLineCount: 1,
|
||||
presentation: "chip",
|
||||
}]]);
|
||||
});
|
||||
Reference in New Issue
Block a user