fix: keep short composer pastes as text

This commit is contained in:
2026-09-01 18:23:23 +09:00
parent 2bb661f1cf
commit 47dabd8793
4 changed files with 511 additions and 12 deletions
@@ -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;
}
+121
View File
@@ -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",
}]]);
});