fix: keep short composer pastes as text
This commit is contained in:
+220
-12
@@ -15,6 +15,64 @@ use ratatui::style::{Color, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
pub const MAX_PLAIN_TEXT_PASTE_CHARS: usize = 50;
|
||||||
|
pub const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES: usize = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct PasteMeasurement {
|
||||||
|
pub chars: usize,
|
||||||
|
pub logical_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasteMeasurement {
|
||||||
|
pub fn presentation(self) -> PastePresentation {
|
||||||
|
if self.chars <= MAX_PLAIN_TEXT_PASTE_CHARS
|
||||||
|
&& self.logical_lines <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||||
|
{
|
||||||
|
PastePresentation::Text
|
||||||
|
} else {
|
||||||
|
PastePresentation::Chip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PastePresentation {
|
||||||
|
Text,
|
||||||
|
Chip,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn measure_paste(content: &str) -> PasteMeasurement {
|
||||||
|
PasteMeasurement {
|
||||||
|
chars: content.chars().count(),
|
||||||
|
logical_lines: logical_line_count(content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty content has zero logical lines. Otherwise LF, lone CR, and CRLF each
|
||||||
|
/// advance one line; a CRLF pair is one break rather than two.
|
||||||
|
pub fn logical_line_count(content: &str) -> usize {
|
||||||
|
if content.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = 1;
|
||||||
|
let mut chars = content.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
match ch {
|
||||||
|
'\r' => {
|
||||||
|
if chars.peek() == Some(&'\n') {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
lines += 1;
|
||||||
|
}
|
||||||
|
'\n' => lines += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PasteRef {
|
pub struct PasteRef {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -262,16 +320,21 @@ impl InputBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_paste(&mut self, content: String) {
|
pub fn insert_paste(&mut self, content: String) {
|
||||||
|
let measurement = measure_paste(&content);
|
||||||
|
if measurement.presentation() == PastePresentation::Text {
|
||||||
|
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
|
||||||
|
self.insert_str(&normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let id = self.next_paste_id;
|
let id = self.next_paste_id;
|
||||||
self.next_paste_id = self.next_paste_id.wrapping_add(1);
|
self.next_paste_id = self.next_paste_id.wrapping_add(1);
|
||||||
let chars = content.chars().count();
|
|
||||||
let lines = content.lines().count().max(1);
|
|
||||||
self.atoms.insert(
|
self.atoms.insert(
|
||||||
self.cursor,
|
self.cursor,
|
||||||
Atom::Paste(PasteRef {
|
Atom::Paste(PasteRef {
|
||||||
id,
|
id,
|
||||||
chars,
|
chars: measurement.chars,
|
||||||
lines,
|
lines: measurement.logical_lines,
|
||||||
content,
|
content,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -879,6 +942,136 @@ mod render_viewport_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod paste_policy_tests {
|
||||||
|
use super::*;
|
||||||
|
use protocol::Segment;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Fixture {
|
||||||
|
max_plain_text_chars: usize,
|
||||||
|
max_plain_text_logical_lines: usize,
|
||||||
|
cases: Vec<FixtureCase>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FixtureCase {
|
||||||
|
name: String,
|
||||||
|
parts: Vec<FixturePart>,
|
||||||
|
char_count: usize,
|
||||||
|
logical_line_count: usize,
|
||||||
|
presentation: FixturePresentation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FixturePart {
|
||||||
|
value: String,
|
||||||
|
repeat: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum FixturePresentation {
|
||||||
|
Text,
|
||||||
|
Chip,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
serde_json::from_str(include_str!(
|
||||||
|
"../../../tests/fixtures/composer-paste-policy.json"
|
||||||
|
))
|
||||||
|
.expect("shared composer paste policy fixture must be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_content(case: &FixtureCase) -> String {
|
||||||
|
case.parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| part.value.repeat(part.repeat))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_follows_shared_paste_presentation_contract() {
|
||||||
|
let fixture = fixture();
|
||||||
|
assert_eq!(fixture.max_plain_text_chars, MAX_PLAIN_TEXT_PASTE_CHARS);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.max_plain_text_logical_lines,
|
||||||
|
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
|
||||||
|
);
|
||||||
|
|
||||||
|
for case in fixture.cases {
|
||||||
|
let content = fixture_content(&case);
|
||||||
|
let measurement = measure_paste(&content);
|
||||||
|
let expected_presentation = match case.presentation {
|
||||||
|
FixturePresentation::Text => PastePresentation::Text,
|
||||||
|
FixturePresentation::Chip => PastePresentation::Chip,
|
||||||
|
};
|
||||||
|
assert_eq!(measurement.chars, case.char_count, "{} chars", case.name);
|
||||||
|
assert_eq!(
|
||||||
|
measurement.logical_lines, case.logical_line_count,
|
||||||
|
"{} logical lines",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
measurement.presentation(),
|
||||||
|
expected_presentation,
|
||||||
|
"{} presentation",
|
||||||
|
case.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_paste_is_editable_text_at_the_cursor() {
|
||||||
|
let mut buffer = InputBuffer::new();
|
||||||
|
buffer.insert_str("ac");
|
||||||
|
buffer.move_left();
|
||||||
|
buffer.insert_paste("b".to_owned());
|
||||||
|
|
||||||
|
assert_eq!(buffer.plain_text(), "abc");
|
||||||
|
assert!(
|
||||||
|
buffer
|
||||||
|
.atoms
|
||||||
|
.iter()
|
||||||
|
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffer.submit_segments(),
|
||||||
|
vec![Segment::text("abc".to_owned())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_multiline_paste_normalizes_line_endings_as_text() {
|
||||||
|
let mut buffer = InputBuffer::new();
|
||||||
|
buffer.insert_paste("a\r\nb\rc".to_owned());
|
||||||
|
|
||||||
|
assert_eq!(buffer.plain_text(), "a\nb\nc");
|
||||||
|
assert!(
|
||||||
|
buffer
|
||||||
|
.atoms
|
||||||
|
.iter()
|
||||||
|
.all(|atom| matches!(atom, Atom::Char(_)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
buffer.submit_segments(),
|
||||||
|
vec![Segment::text("a\nb\nc".to_owned())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_paste_is_a_noop() {
|
||||||
|
let mut buffer = InputBuffer::new();
|
||||||
|
buffer.insert_str("unchanged");
|
||||||
|
let paste_id = buffer.next_paste_id;
|
||||||
|
buffer.insert_paste(String::new());
|
||||||
|
|
||||||
|
assert_eq!(buffer.plain_text(), "unchanged");
|
||||||
|
assert_eq!(buffer.next_paste_id, paste_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod submit_segments_tests {
|
mod submit_segments_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -904,7 +1097,8 @@ mod submit_segments_tests {
|
|||||||
for c in "see ".chars() {
|
for c in "see ".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
buf.insert_paste("line1\nline2".into());
|
let pasted = "line1\nline2\nline3\nline4";
|
||||||
|
buf.insert_paste(pasted.into());
|
||||||
for c in " end".chars() {
|
for c in " end".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
@@ -921,9 +1115,9 @@ mod submit_segments_tests {
|
|||||||
content,
|
content,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(content, "line1\nline2");
|
assert_eq!(content, pasted);
|
||||||
assert_eq!(*chars, "line1\nline2".chars().count() as u32);
|
assert_eq!(*chars, pasted.chars().count() as u32);
|
||||||
assert_eq!(*lines, 2);
|
assert_eq!(*lines, 4);
|
||||||
}
|
}
|
||||||
other => panic!("expected Paste, got {other:?}"),
|
other => panic!("expected Paste, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -933,6 +1127,20 @@ mod submit_segments_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restored_direct_paste_remains_a_typed_segment_without_reclassification() {
|
||||||
|
let original = Segment::Paste {
|
||||||
|
id: 7,
|
||||||
|
chars: 1,
|
||||||
|
lines: 1,
|
||||||
|
content: "x".to_owned(),
|
||||||
|
};
|
||||||
|
let mut buf = InputBuffer::new();
|
||||||
|
buf.replace_with_segments(std::slice::from_ref(&original));
|
||||||
|
|
||||||
|
assert_eq!(buf.submit_segments(), vec![original]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restored_paste_artifact_remains_a_typed_segment() {
|
fn restored_paste_artifact_remains_a_typed_segment() {
|
||||||
let artifact = protocol::PasteArtifactRef {
|
let artifact = protocol::PasteArtifactRef {
|
||||||
@@ -967,7 +1175,7 @@ mod submit_segments_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn leading_paste_does_not_emit_empty_text() {
|
fn leading_paste_does_not_emit_empty_text() {
|
||||||
let mut buf = InputBuffer::new();
|
let mut buf = InputBuffer::new();
|
||||||
buf.insert_paste("X".into());
|
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
let segs = buf.submit_segments();
|
let segs = buf.submit_segments();
|
||||||
assert_eq!(segs.len(), 1);
|
assert_eq!(segs.len(), 1);
|
||||||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
assert!(matches!(segs[0], Segment::Paste { .. }));
|
||||||
@@ -1067,7 +1275,7 @@ mod completion_prefix_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn trigger_after_chip_atom() {
|
fn trigger_after_chip_atom() {
|
||||||
let mut buf = InputBuffer::new();
|
let mut buf = InputBuffer::new();
|
||||||
buf.insert_paste("X".into());
|
buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
for c in "@sr".chars() {
|
for c in "@sr".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
@@ -1176,7 +1384,7 @@ mod word_motion_tests {
|
|||||||
for c in "foo ".chars() {
|
for c in "foo ".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
buf.insert_paste("anything".into());
|
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
for c in " bar".chars() {
|
for c in " bar".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
@@ -1335,7 +1543,7 @@ mod word_motion_tests {
|
|||||||
for c in "foo ".chars() {
|
for c in "foo ".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
buf.insert_paste("anything".into());
|
buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
|
||||||
for c in " bar".chars() {
|
for c in " bar".chars() {
|
||||||
buf.insert_char(c);
|
buf.insert_char(c);
|
||||||
}
|
}
|
||||||
|
|||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"max_plain_text_chars": 50,
|
||||||
|
"max_plain_text_logical_lines": 3,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "empty",
|
||||||
|
"parts": [],
|
||||||
|
"char_count": 0,
|
||||||
|
"logical_line_count": 0,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ascii_50",
|
||||||
|
"parts": [{ "value": "a", "repeat": 50 }],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ascii_51",
|
||||||
|
"parts": [{ "value": "a", "repeat": 51 }],
|
||||||
|
"char_count": 51,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unicode_scalar_50",
|
||||||
|
"parts": [{ "value": "🦀", "repeat": 50 }],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unicode_scalar_51",
|
||||||
|
"parts": [{ "value": "🦀", "repeat": 51 }],
|
||||||
|
"char_count": 51,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "three_lf_lines",
|
||||||
|
"parts": [{ "value": "alpha\nbeta\ngamma", "repeat": 1 }],
|
||||||
|
"char_count": 16,
|
||||||
|
"logical_line_count": 3,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "four_lf_lines",
|
||||||
|
"parts": [{ "value": "a\nb\nc\nd", "repeat": 1 }],
|
||||||
|
"char_count": 7,
|
||||||
|
"logical_line_count": 4,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "three_crlf_lines",
|
||||||
|
"parts": [{ "value": "a\r\nb\r\nc", "repeat": 1 }],
|
||||||
|
"char_count": 7,
|
||||||
|
"logical_line_count": 3,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mixed_line_endings",
|
||||||
|
"parts": [{ "value": "a\r\nb\rc\nd", "repeat": 1 }],
|
||||||
|
"char_count": 8,
|
||||||
|
"logical_line_count": 4,
|
||||||
|
"presentation": "chip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trailing_newline",
|
||||||
|
"parts": [{ "value": "a\n", "repeat": 1 }],
|
||||||
|
"char_count": 2,
|
||||||
|
"logical_line_count": 2,
|
||||||
|
"presentation": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "whitespace_rich_50",
|
||||||
|
"parts": [{ "value": " \t", "repeat": 25 }],
|
||||||
|
"char_count": 50,
|
||||||
|
"logical_line_count": 1,
|
||||||
|
"presentation": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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