diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index c5c335575..df8115565 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -961,7 +961,6 @@ if (renderFileButton != null) { virtualizer?.setup(globalThis.document); const wrap = getWrapped(); const editor = new Editor({ - clipboard: navigator.clipboard, enabledSelectionAction: true, renderSelectionAction: (ctx) => { const div = document.createElement('div'); diff --git a/packages/diffs/src/editor/command.ts b/packages/diffs/src/editor/command.ts index a7f8ed77c..7ac611f97 100644 --- a/packages/diffs/src/editor/command.ts +++ b/packages/diffs/src/editor/command.ts @@ -17,6 +17,7 @@ export type EditorCommand = | 'copyLineDown' | 'simplifySelection' | 'insertBlankLine' + | 'deleteHardLineForward' | 'toggleComment' | 'toggleBlockComment' | 'moveCursorToDocStart' @@ -38,6 +39,19 @@ export function resolveEditorCommandFromKeyboardEvent( const normalizedKey = key.length === 1 ? key.toLowerCase() : key; + // Safari and Firefox do not report macOS control+k as a beforeinput action, + // so resolve the native delete-to-line-end shortcut from keydown instead. + if ( + isMac && + event.ctrlKey && + !event.metaKey && + !shiftKey && + !altKey && + (normalizedKey === 'k' || event.code === 'KeyK') + ) { + return 'deleteHardLineForward'; + } + if ( !event.metaKey && !event.ctrlKey && diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index d87dd0334..b5d41c477 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -82,6 +82,7 @@ import { getDocumentFullSelection, getSelectedLineBlocks, getSelectionAnchor, + getSelectionClipboardTexts, getSelectionText, isCollapsedSelection, isLineEditable, @@ -110,7 +111,7 @@ import { Metrics, snapTextOffsetToUnicodeBoundary, } from './textMeasure'; -import { EditorTokenizer, renderLineTokens } from './tokenzier'; +import { EditorTokenizer, renderLineTokens } from './tokenizer'; import { addEventListener, clampDomOffset, @@ -171,7 +172,7 @@ export interface EditorOptions { * see https://www.electronjs.org/docs/latest/api/clipboard */ clipboard?: { - readText: () => Promise | string; + readText: (type?: string) => Promise | string; }; /** Render the selection action widget element. */ renderSelectionAction?: ( @@ -202,6 +203,8 @@ export interface EditorOptions { // row per inserted line. A safety bound, not a correctness-critical value. const MAX_EDIT_WIDEN_WINDOW_MULTIPLE = 2; const SELECTION_ACTION_POPOVER_PLACEMENT_KEY = 'selection-action'; +const MULTI_SELECTION_CLIPBOARD_TYPE = + 'application/vnd.pierre.diffs-selections+json'; export class Editor implements DiffsEditor { #options: EditorOptions; @@ -1442,12 +1445,15 @@ export class Editor implements DiffsEditor { // A read-only deleted-text selection lives outside the editor's // document, so #getSelectionText() would be empty. Copy the selected // deleted text instead. - e.clipboardData?.setData( - 'text', - this.#isDeletedTextSelectionActive() - ? this.#deletedTextForClipboard() - : this.#getSelectionText() - ); + if (this.#isDeletedTextSelectionActive()) { + e.clipboardData?.setData('text', this.#deletedTextForClipboard()); + } else { + this.#writeSelectionClipboardData( + e.clipboardData, + this.#getSelectionText(), + this.#getSelectionClipboardTexts() + ); + } }), addEventListener(contentEl, 'cut', (e) => { @@ -1457,12 +1463,16 @@ export class Editor implements DiffsEditor { e.preventDefault(); // Deleted text is read-only and can't be removed, so a cut there copies // the selected deleted text (like the copy handler) without editing. - e.clipboardData?.setData( - 'text', - this.#isDeletedTextSelectionActive() - ? this.#deletedTextForClipboard() - : this.#cutSelectionText() - ); + if (this.#isDeletedTextSelectionActive()) { + e.clipboardData?.setData('text', this.#deletedTextForClipboard()); + } else { + const selectionTexts = this.#getSelectionClipboardTexts(); + this.#writeSelectionClipboardData( + e.clipboardData, + this.#cutSelectionText(), + selectionTexts + ); + } }), addEventListener(contentEl, 'paste', (e) => { @@ -1470,18 +1480,44 @@ export class Editor implements DiffsEditor { return; } e.preventDefault(); - const text = e.clipboardData?.getData('text'); + const clipboardData = e.clipboardData; const textDocument = this.#textDocument; - if (text !== undefined && textDocument !== undefined) { - // Rewrite clipboard line breaks to the document's EOL so a Windows - // clipboard (\r\n or \r) doesn't leave mixed line endings behind. - // TODO(@ije): Add support of multiple selections copy&paste - this.#replaceSelectionText( - textDocument.normalizeEol(text), - undefined, - true + if (clipboardData === null || textDocument === undefined) { + return; + } + + let text: string | string[] = clipboardData.getData('text'); + + const selectionCount = this.#selections?.length ?? 0; + if (selectionCount > 1) { + const multiSelectionText = clipboardData.getData( + MULTI_SELECTION_CLIPBOARD_TYPE ); + if (multiSelectionText !== undefined) { + try { + const selectionTexts = JSON.parse(multiSelectionText); + if ( + Array.isArray(selectionTexts) && + selectionTexts.length === selectionCount + ) { + text = selectionTexts; + } + } catch { + // ignore the invalid custom clipboard data + } + } } + + // Rewrite clipboard line breaks to the document's EOL so a Windows + // clipboard (\r\n or \r) doesn't leave mixed line endings behind. + this.#replaceSelectionText( + Array.isArray(text) + ? text.map((t) => textDocument.normalizeEol(t)) + : textDocument.normalizeEol(text), + undefined, + true, + 'document' + ); }), addEventListener(contentEl, 'beforeinput', (e) => { @@ -1729,13 +1765,33 @@ export class Editor implements DiffsEditor { #handleCustomPasteEvent = async () => { const clipboard = this.#options.clipboard; if (clipboard !== undefined) { - const text = await clipboard.readText(); + let text: string | string[] = await clipboard.readText(); + const selectionCount = this.#selections?.length ?? 0; + if (selectionCount > 1) { + const multiSelectionText = await clipboard.readText( + MULTI_SELECTION_CLIPBOARD_TYPE + ); + try { + const selectionTexts = JSON.parse(multiSelectionText); + if ( + Array.isArray(selectionTexts) && + selectionTexts.length === selectionCount + ) { + text = selectionTexts; + } + } catch { + // Invalid selection metadata falls back to the plain-text value. + } + } const textDocument = this.#textDocument; if (textDocument !== undefined) { this.#replaceSelectionText( - textDocument.normalizeEol(text), + Array.isArray(text) + ? text.map((t) => textDocument.normalizeEol(t)) + : textDocument.normalizeEol(text), undefined, - true + true, + 'document' ); } } @@ -1769,7 +1825,6 @@ export class Editor implements DiffsEditor { return undefined; } - // TODO(@ije): add command registry #runCommand(command: EditorCommand) { const textDocument = this.#textDocument; if (textDocument === undefined) { @@ -1847,6 +1902,10 @@ export class Editor implements DiffsEditor { this.#insertBlankLine(); break; + case 'deleteHardLineForward': + this.#deleteHardLineForward(); + break; + case 'toggleComment': case 'toggleBlockComment': { const selections = this.#selections; @@ -2452,7 +2511,7 @@ export class Editor implements DiffsEditor { return; } - // cancel existing background tokenzier task + // cancel existing background tokenizing task tokenizer.stopBackgroundTokenize(); const t = performance.now(); @@ -2678,11 +2737,6 @@ export class Editor implements DiffsEditor { case 'deleteHardLineBackward': this.#deleteSoftLineBackward(); break; - case 'deleteHardLineForward': - // TODO(@ije): Safari and Firefox does not support this input type - // use command instead - this.#deleteHardLineForward(); - break; case 'deleteWordBackward': this.#deleteWordBackward(); break; @@ -3514,12 +3568,13 @@ export class Editor implements DiffsEditor { const top = this.#getLineY(line) + wrapLine * lineHeight; // Match the corner mask to the line color behind the selection; when // absent (context lines) the CSS falls back to the editor base bg. - const cornerBg = this.#lineBackgroundColor(line); - const css = - `width:${ch}px;transform:translateX(${left}px) translateY(${top}px);` + - (cornerBg !== undefined - ? `--diffs-selection-corner-bg:${cornerBg};` - : ''); + const lineElement = this.#getLineElement(line); + let cornerBg = 'initial'; + if (this.#isDiff && lineElement?.dataset.lineType === 'change-addition') { + cornerBg = + getComputedStyle(lineElement).getPropertyValue('--diffs-line-bg'); + } + const css = `width:${ch}px;transform:translateX(${left}px) translateY(${top}px);--diffs-selection-corner-bg:${cornerBg}`; const dataset = { selectionCorner: '', [radius]: '', @@ -3822,7 +3877,6 @@ export class Editor implements DiffsEditor { this.#renderSearchPanel(mode); } - // TODO(@ije): render search highlight #renderSearchPanel(mode: SearchPanelMode) { // cleanup the existing search panel this.#searchPanel?.cleanup(); @@ -3971,6 +4025,33 @@ export class Editor implements DiffsEditor { return getSelectionText(textDocument, selections); } + #getSelectionClipboardTexts(): string[] { + const textDocument = this.#textDocument; + const selections = this.#selections; + if (textDocument === undefined || selections === undefined) { + return []; + } + return getSelectionClipboardTexts(textDocument, selections); + } + + /** Writes both the portable text and the selection pairing metadata. */ + #writeSelectionClipboardData( + clipboardData: DataTransfer | null, + text: string, + selectionTexts: string[] + ): void { + if (clipboardData === null) { + return; + } + clipboardData.setData('text', text); + if (selectionTexts.length > 1) { + clipboardData.setData( + MULTI_SELECTION_CLIPBOARD_TYPE, + JSON.stringify(selectionTexts) + ); + } + } + #cutSelectionText(): string { const textDocument = this.#textDocument; const selections = this.#selections; @@ -4040,7 +4121,8 @@ export class Editor implements DiffsEditor { #replaceSelectionText( text: string | string[], selections = this.#selections, - undoBoundary = false + undoBoundary = false, + textOrder: 'selection' | 'document' = 'selection' ) { if (selections === undefined) { return; @@ -4057,7 +4139,8 @@ export class Editor implements DiffsEditor { selections, text, this.#lineAnnotations, - undoBoundary + undoBoundary, + textOrder ) : applyTextChangeToSelections( textDocument, @@ -4065,7 +4148,7 @@ export class Editor implements DiffsEditor { { start: textDocument.offsetAt(primarySelection.start), end: textDocument.offsetAt(primarySelection.end), - text: Array.isArray(text) ? text.join('\n') : text, + text: Array.isArray(text) ? text.join(textDocument.eol) : text, }, this.#lineAnnotations, undefined, @@ -4367,30 +4450,6 @@ export class Editor implements DiffsEditor { return undefined; } - // TODO(@ije): remove this - // Painted background color of a line, read from the [data-line]::after layer - // (the line element itself is transparent in edit mode). Returns undefined when - // that layer is transparent (e.g. context lines). - #lineBackgroundColor(line: number): string | undefined { - const lineElement = this.#getLineElement(line); - if (lineElement === undefined) { - return undefined; - } - // testing environment like jsdom doesn't implement the getComputedStyle API - if (navigator.userAgent.includes('jsdom')) { - return undefined; - } - const backgroundColor = getComputedStyle( - lineElement, - '::after' - ).backgroundColor; - return backgroundColor === '' || - backgroundColor === 'transparent' || - backgroundColor === 'rgba(0, 0, 0, 0)' - ? undefined - : backgroundColor; - } - // Returns the first and last document lines that have an editable row in the // current (virtualized) render window, or undefined when none are rendered. // Used by select-all to anchor a native selection: only rendered lines diff --git a/packages/diffs/src/editor/matchBrackets.ts b/packages/diffs/src/editor/matchBrackets.ts index 204a142fd..6b47f8bcb 100644 --- a/packages/diffs/src/editor/matchBrackets.ts +++ b/packages/diffs/src/editor/matchBrackets.ts @@ -1,6 +1,6 @@ import type { Position, Range } from '../types'; import type { TextDocument } from './textDocument'; -import type { EditorTokenizer } from './tokenzier'; +import type { EditorTokenizer } from './tokenizer'; const OPEN_BRACKETS = new Map([ ['(', ')'], diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 5190d3a36..d57b9845b 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -644,14 +644,16 @@ function getNextSelectionOffsetPairAfterReplace( } /** - * Applies a text replace to multiple selections. + * Applies text replacements to multiple selections. Texts pair by selection + * index unless they are explicitly marked as document ordered. */ export function applyTextReplaceToSelections( textDocument: TextDocument, selections: EditorSelection[], texts: string[], lineAnnotations?: DiffLineAnnotation[], - undoBoundary = false + undoBoundary = false, + textOrder: 'selection' | 'document' = 'selection' ): { nextSelections: EditorSelection[]; change?: TextDocumentChange; @@ -672,7 +674,6 @@ export function applyTextReplaceToSelections( index: number; start: number; end: number; - text: string; }> = []; let isAlreadyOrdered = true; for (let index = 0; index < selections.length; index++) { @@ -680,7 +681,6 @@ export function applyTextReplaceToSelections( index, start: selectionOffsets[index * 2], end: selectionOffsets[index * 2 + 1], - text: texts[index], }; const previous = ordered[ordered.length - 1]; if ( @@ -758,14 +758,15 @@ export function applyTextReplaceToSelections( edits = []; let offsetDelta = 0; let previousEditEnd = -1; - for (const entry of ordered) { + for (let index = 0; index < ordered.length; index++) { + const entry = ordered[index]; if (entry.start < previousEditEnd) { throw new Error('Overlapping multi-selection edits are not supported'); } previousEditEnd = entry.end; const newText = expandSingleNewlineInsert( textDocument, - entry.text, + texts[textOrder === 'document' ? index : entry.index], entry.start ); edits.push({ @@ -1714,6 +1715,28 @@ interface ClipboardRegion { end: number; } +/** Resolves the document offset range one selection contributes to a copy. */ +function resolveClipboardRegion( + textDocument: TextDocument, + selection: EditorSelection +): ClipboardRegion { + if (isCollapsedSelection(selection)) { + const line = selection.start.line; + const start = textDocument.offsetAt({ line, character: 0 }); + const end = + line < textDocument.lineCount - 1 + ? textDocument.offsetAt({ line: line + 1, character: 0 }) + : textDocument.offsetAt({ + line, + character: textDocument.getLineLength(line), + }); + return { start, end }; + } + const start = textDocument.offsetAt(selection.start); + const end = textDocument.offsetAt(selection.end); + return start <= end ? { start, end } : { start: end, end: start }; +} + /** * Resolves the document offset range each selection contributes to the * clipboard, ordered by position. A collapsed selection contributes its whole @@ -1725,29 +1748,25 @@ function resolveClipboardRegions( selections: EditorSelection[] ): ClipboardRegion[] { return selections - .map((selection) => { - if (isCollapsedSelection(selection)) { - const line = selection.start.line; - const start = textDocument.offsetAt({ line, character: 0 }); - const end = - line < textDocument.lineCount - 1 - ? textDocument.offsetAt({ line: line + 1, character: 0 }) - : textDocument.offsetAt({ - line, - character: textDocument.getLineLength(line), - }); - return { start, end }; - } - const start = textDocument.offsetAt(selection.start); - const end = textDocument.offsetAt(selection.end); - return start <= end ? { start, end } : { start: end, end: start }; - }) + .map((selection) => resolveClipboardRegion(textDocument, selection)) .sort((a, b) => { const startOrder = a.start - b.start; return startOrder !== 0 ? startOrder : a.end - b.end; }); } +/** + * Gets the text contributed by each selection in document order, preserving + * the pairing needed to paste the values into another set of selections. + */ +export function getSelectionClipboardTexts( + textDocument: TextDocument, + selections: EditorSelection[] +): string[] { + const regions = resolveClipboardRegions(textDocument, selections); + return regions.map(({ start, end }) => textDocument.getTextSlice(start, end)); +} + /** * Get the clipboard text of the selections for the given text document. Used by * both copy and cut so the two stay in sync. Overlapping regions (e.g. several diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 313f5baf9..2c951e0ee 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -69,6 +69,7 @@ export class TextDocument { #version: number; #pieceTable: PieceTable; #editStack: EditStack; + #eol: string; constructor( uri: string, @@ -82,6 +83,19 @@ export class TextDocument { this.#version = version; this.#pieceTable = new PieceTable(text); this.#editStack = editStack; + + // The line ending the document uses, detected once from its first line. Lets + // inserted or pasted text match the rest of the file instead of leaving + // mixed endings behind and keeps that convention stable as the file is + // edited. Defaults to Unix `\n` when the initial text has no line break. + const firstLineBreak = this.#pieceTable.getLineText(0, true); + if (firstLineBreak.endsWith('\r\n')) { + this.#eol = '\r\n'; + } else if (firstLineBreak.endsWith('\r')) { + this.#eol = '\r'; + } else { + this.#eol = '\n'; + } } get uri(): string { @@ -100,22 +114,8 @@ export class TextDocument { return this.#pieceTable.lineCount; } - // The line ending the document uses, detected from its first line. Lets - // inserted or pasted text match the rest of the file instead of leaving - // mixed endings behind. Recognizes Windows `\r\n` and classic-Mac lone `\r` - // breaks (both of which the piece table already splits on), defaulting to - // Unix `\n`. get eol(): string { - if (this.lineCount > 1) { - const firstLineBreak = this.getLineText(0, true); - if (firstLineBreak.endsWith('\r\n')) { - return '\r\n'; - } - if (firstLineBreak.endsWith('\r')) { - return '\r'; - } - } - return '\n'; + return this.#eol; } get canUndo(): boolean { diff --git a/packages/diffs/src/editor/tokenzier.ts b/packages/diffs/src/editor/tokenizer.ts similarity index 100% rename from packages/diffs/src/editor/tokenzier.ts rename to packages/diffs/src/editor/tokenizer.ts diff --git a/packages/diffs/test/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index 4e42cd2fd..434196e97 100644 --- a/packages/diffs/test/editorApplyEdits.test.ts +++ b/packages/diffs/test/editorApplyEdits.test.ts @@ -834,6 +834,39 @@ describe('Editor move line commands', () => { }); describe('Editor editing commands', () => { + test('deletes to the end of the line with macOS control+k', async () => { + const { cleanup, content, editor, window } = + await createEditorFixture('hello world\nnext'); + + try { + editor.setSelections([ + { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + direction: 'none', + }, + ]); + + const keydown = pressKey(window, content, { + key: 'k', + code: 'KeyK', + ctrlKey: true, + }); + + expect(keydown.defaultPrevented).toBe(true); + expect(editor.getText()).toBe('hello\nnext'); + expect(editor.getState().selections).toEqual([ + { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + direction: 0, + }, + ]); + } finally { + cleanup(); + } + }); + test('copies selected lines and keeps the requested copy selected', async () => { const { cleanup, content, editor, window } = await createEditorFixture( 'alpha\nbravo\ncharlie' diff --git a/packages/diffs/test/editorBracketMatch.test.ts b/packages/diffs/test/editorBracketMatch.test.ts index 7c734f74a..ef1f0f9fc 100644 --- a/packages/diffs/test/editorBracketMatch.test.ts +++ b/packages/diffs/test/editorBracketMatch.test.ts @@ -5,7 +5,7 @@ import { DEFAULT_THEMES } from '../src/constants'; import { Editor, type EditorOptions } from '../src/editor/editor'; import { findBracketMatchRanges } from '../src/editor/matchBrackets'; import { TextDocument } from '../src/editor/textDocument'; -import { EditorTokenizer } from '../src/editor/tokenzier'; +import { EditorTokenizer } from '../src/editor/tokenizer'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import type { FileContents } from '../src/types'; import { installDom, wait } from './domHarness'; diff --git a/packages/diffs/test/editorClipboard.test.ts b/packages/diffs/test/editorClipboard.test.ts index 2305ca56f..984ec70a9 100644 --- a/packages/diffs/test/editorClipboard.test.ts +++ b/packages/diffs/test/editorClipboard.test.ts @@ -242,60 +242,78 @@ class TestEditableComponent implements DiffsEditableComponent { } } +const MULTI_SELECTION_CLIPBOARD_TYPE = + 'application/vnd.pierre.diffs-selections+json'; + +class TestClipboardData { + readonly writes: Array<[type: string, text: string]> = []; + readonly #data = new Map(); + + constructor(text?: string) { + if (text !== undefined) { + this.#data.set('text', text); + } + } + + setData(type: string, text: string): void { + this.writes.push([type, text]); + this.#data.set(type, text); + } + + getData(type: string): string { + return this.#data.get(type) ?? ''; + } +} + function dispatchCut(target: HTMLElement): Array<[type: string, text: string]> { - const writes: Array<[type: string, text: string]> = []; + const clipboardData = new TestClipboardData(); const event = new window.Event('cut', { bubbles: true, cancelable: true, composed: true, }); Object.defineProperty(event, 'clipboardData', { - value: { - setData(type: string, text: string) { - writes.push([type, text]); - }, - }, + value: clipboardData, }); target.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); - return writes; + return clipboardData.writes; } function dispatchCopy( target: HTMLElement ): Array<[type: string, text: string]> { - const writes: Array<[type: string, text: string]> = []; + return dispatchCopyData(target).writes; +} + +function dispatchCopyData(target: HTMLElement): TestClipboardData { + const clipboardData = new TestClipboardData(); const event = new window.Event('copy', { bubbles: true, cancelable: true, composed: true, }); Object.defineProperty(event, 'clipboardData', { - value: { - setData(type: string, text: string) { - writes.push([type, text]); - }, - }, + value: clipboardData, }); target.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); - return writes; + return clipboardData; } -function dispatchPaste(target: HTMLElement, text: string): void { +function dispatchPaste( + target: HTMLElement, + data: string | TestClipboardData +): void { const event = new window.Event('paste', { bubbles: true, cancelable: true, composed: true, }); Object.defineProperty(event, 'clipboardData', { - value: { - getData(_type: string) { - return text; - }, - }, + value: typeof data === 'string' ? new TestClipboardData(data) : data, }); target.dispatchEvent(event); @@ -403,7 +421,13 @@ describe('Editor clipboard events', () => { const writes = dispatchCut(component.contentElement); - expect(writes).toEqual([['text', 'alpha\ncharlie\n']]); + expect(writes).toEqual([ + ['text', 'alpha\ncharlie\n'], + [ + MULTI_SELECTION_CLIPBOARD_TYPE, + JSON.stringify(['alpha\n', 'charlie\n']), + ], + ]); expect(editor.getText()).toBe('bravo\ndelta'); expect(editor.getState().selections).toEqual([ { @@ -450,7 +474,10 @@ describe('Editor clipboard events', () => { const writes = dispatchCut(component.contentElement); - expect(writes).toEqual([['text', 'rav\ncharlie\n']]); + expect(writes).toEqual([ + ['text', 'rav\ncharlie\n'], + [MULTI_SELECTION_CLIPBOARD_TYPE, JSON.stringify(['rav', 'charlie\n'])], + ]); expect(editor.getText()).toBe('alpha\nbo\ndelta'); expect(editor.getState().selections).toEqual([ { @@ -497,7 +524,13 @@ describe('Editor clipboard events', () => { const writes = dispatchCut(component.contentElement); - expect(writes).toEqual([['text', 'bravo\n']]); + expect(writes).toEqual([ + ['text', 'bravo\n'], + [ + MULTI_SELECTION_CLIPBOARD_TYPE, + JSON.stringify(['bravo\n', 'bravo\n']), + ], + ]); expect(editor.getText()).toBe('alpha\ncharlie'); expect(editor.getState().selections).toEqual([ { @@ -539,7 +572,10 @@ describe('Editor clipboard events', () => { const writes = dispatchCut(component.contentElement); - expect(writes).toEqual([['text', 'bravo\n']]); + expect(writes).toEqual([ + ['text', 'bravo\n'], + [MULTI_SELECTION_CLIPBOARD_TYPE, JSON.stringify(['br', 'bravo\n'])], + ]); expect(editor.getText()).toBe('alpha\ncharlie'); expect(editor.getState().selections).toEqual([ { @@ -614,6 +650,102 @@ describe('Editor clipboard events', () => { } }); + test('pastes copied selection texts into matching selections', async () => { + const { cleanup } = installDom(); + + const editor = new Editor(); + const component = new TestEditableComponent({ + name: 'example.txt', + contents: 'one two\nthree four\n---\nAA\nBB', + lang: 'text', + }); + + try { + editor.edit(component); + editor.setSelections([ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + direction: 'forward', + }, + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 5 }, + direction: 'forward', + }, + ]); + + const clipboardData = dispatchCopyData(component.contentElement); + expect(clipboardData.writes).toEqual([ + ['text', 'one\nthree'], + [MULTI_SELECTION_CLIPBOARD_TYPE, JSON.stringify(['one', 'three'])], + ]); + + editor.setSelections([ + { + start: { line: 4, character: 0 }, + end: { line: 4, character: 2 }, + direction: 'forward', + }, + { + start: { line: 3, character: 0 }, + end: { line: 3, character: 2 }, + direction: 'forward', + }, + ]); + dispatchPaste(component.contentElement, clipboardData); + await wait(); + + expect(editor.getText()).toBe('one two\nthree four\n---\none\nthree'); + } finally { + editor.cleanUp(); + cleanup(); + } + }); + + test('uses plain text when metadata and selection counts differ', () => { + const { cleanup } = installDom(); + + const editor = new Editor(); + const component = new TestEditableComponent({ + name: 'example.txt', + contents: 'AA\nBB\nCC', + lang: 'text', + }); + + try { + editor.edit(component); + const clipboardData = new TestClipboardData('plain'); + clipboardData.setData( + MULTI_SELECTION_CLIPBOARD_TYPE, + JSON.stringify(['one\n', 'two\n']) + ); + editor.setSelections([ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + direction: 'forward', + }, + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 2 }, + direction: 'forward', + }, + { + start: { line: 2, character: 0 }, + end: { line: 2, character: 2 }, + direction: 'forward', + }, + ]); + dispatchPaste(component.contentElement, clipboardData); + + expect(editor.getText()).toBe('plain\nplain\nplain'); + } finally { + editor.cleanUp(); + cleanup(); + } + }); + test('allows the first browser paste shortcut in a diff and suppresses repeat paste', async () => { const fixture = await createDiffEditorFixture('alpha\nold', 'alpha\nnew'); const { editor, container } = fixture; @@ -735,6 +867,100 @@ describe('Editor clipboard events', () => { } }); + test('reads matching selections from a custom clipboard provider', async () => { + const { cleanup } = installDom(); + const reads: Array = []; + + const editor = new Editor({ + clipboard: { + readText: (type) => { + reads.push(type); + return type === MULTI_SELECTION_CLIPBOARD_TYPE + ? JSON.stringify(['one', 'two']) + : 'one\ntwo'; + }, + }, + }); + const component = new TestEditableComponent({ + name: 'example.txt', + contents: 'AA\nBB', + lang: 'text', + }); + + try { + editor.edit(component); + editor.setSelections([ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + direction: 'forward', + }, + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 2 }, + direction: 'forward', + }, + ]); + + const keydown = dispatchPasteShortcutKeydown(component.contentElement); + await wait(); + + expect(keydown.defaultPrevented).toBe(true); + expect(reads).toEqual([undefined, MULTI_SELECTION_CLIPBOARD_TYPE]); + expect(editor.getText()).toBe('one\ntwo'); + } finally { + editor.cleanUp(); + cleanup(); + } + }); + + test('uses custom clipboard plain text when selection counts differ', async () => { + const { cleanup } = installDom(); + + const editor = new Editor({ + clipboard: { + readText: (type) => + type === MULTI_SELECTION_CLIPBOARD_TYPE + ? JSON.stringify(['one\n', 'two\n']) + : 'plain', + }, + }); + const component = new TestEditableComponent({ + name: 'example.txt', + contents: 'AA\nBB\nCC', + lang: 'text', + }); + + try { + editor.edit(component); + editor.setSelections([ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + direction: 'forward', + }, + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 2 }, + direction: 'forward', + }, + { + start: { line: 2, character: 0 }, + end: { line: 2, character: 2 }, + direction: 'forward', + }, + ]); + + dispatchPasteShortcutKeydown(component.contentElement); + await wait(); + + expect(editor.getText()).toBe('plain\nplain\nplain'); + } finally { + editor.cleanUp(); + cleanup(); + } + }); + test('rewrites Windows clipboard line breaks to the document EOL on paste', () => { const { cleanup } = installDom(); diff --git a/packages/diffs/test/editorCommand.test.ts b/packages/diffs/test/editorCommand.test.ts index 943a470ff..7c13fe262 100644 --- a/packages/diffs/test/editorCommand.test.ts +++ b/packages/diffs/test/editorCommand.test.ts @@ -87,6 +87,14 @@ describe('resolveEditorShortcutCommand', () => { event: { key: 'Enter', metaKey: true }, expected: 'insertBlankLine', }, + { + event: { key: 'k', code: 'KeyK', ctrlKey: true }, + expected: 'deleteHardLineForward', + }, + { + event: { key: 'Unidentified', code: 'KeyK', ctrlKey: true }, + expected: 'deleteHardLineForward', + }, { event: { key: '[', metaKey: true }, expected: 'indentLess' }, { event: { key: ']', metaKey: true }, expected: 'indentMore' }, { event: { key: '/', metaKey: true }, expected: 'toggleComment' }, @@ -139,6 +147,7 @@ describe('resolveEditorShortcutCommand', () => { event: { key: 'Enter', ctrlKey: true }, expected: 'insertBlankLine', }, + { event: { key: 'k', ctrlKey: true }, expected: undefined }, { event: { key: '[', ctrlKey: true }, expected: 'indentLess' }, { event: { key: ']', ctrlKey: true }, expected: 'indentMore' }, { event: { key: '/', ctrlKey: true }, expected: 'toggleComment' }, diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 9b2361bed..a1f996b96 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -883,6 +883,31 @@ describe('getAutoSurroundReplacementTexts', () => { ]); }); + test('keeps auto-surround paired with descending selections', () => { + const textDocument = new TextDocument('inmemory://1', 'foo bar'); + const selections = [ + createSelection(0, 4, 0, 7, DirectionForward), + createSelection(0, 0, 0, 3, DirectionForward), + ]; + const texts = getAutoSurroundReplacementTexts( + textDocument, + selections, + '"' + ); + expect(texts).toEqual(['"bar"', '"foo"']); + const { nextSelections } = applyTextReplaceToSelections( + textDocument, + selections, + texts! + ); + + expect(textDocument.getText()).toBe('"foo" "bar"'); + expect(nextSelections).toEqual([ + createSelection(0, 7, 0, 10, DirectionForward), + createSelection(0, 1, 0, 4, DirectionForward), + ]); + }); + test('reselects wrapped text after auto-surround', () => { const textDocument = new TextDocument('inmemory://1', 'hello world'); const selections = [createSelection(0, 0, 0, 11, DirectionForward)]; @@ -2358,6 +2383,49 @@ describe('applyTextReplaceToSelections', () => { ]); }); + test('matches pasted text by document order for descending selections', () => { + const textDocument = new TextDocument('inmemory://1', 'x\ny\nz'); + const selections = [ + createSelection(2, 1, 2, 1), + createSelection(1, 1, 1, 1), + createSelection(0, 1, 0, 1), + ]; + const { nextSelections } = applyTextReplaceToSelections( + textDocument, + selections, + ['a', 'b', 'c'], + undefined, + false, + 'document' + ); + + expect(textDocument.getText()).toBe('xa\nyb\nzc'); + expect(nextSelections).toEqual([ + createSelection(2, 2, 2, 2), + createSelection(1, 2, 1, 2), + createSelection(0, 2, 0, 2), + ]); + }); + + test('keeps different-length replacement texts paired with unordered selections', () => { + const textDocument = new TextDocument('inmemory://1', 'abcdef'); + const selections = [ + createSelection(0, 4, 0, 6, DirectionForward), + createSelection(0, 0, 0, 1, DirectionForward), + ]; + const { nextSelections } = applyTextReplaceToSelections( + textDocument, + selections, + ['Z', 'long'] + ); + + expect(textDocument.getText()).toBe('longbcdZ'); + expect(nextSelections).toEqual([ + createSelection(0, 8, 0, 8), + createSelection(0, 4, 0, 4), + ]); + }); + test('throws when replacement count does not match selections', () => { const textDocument = new TextDocument('inmemory://1', 'x\ny'); const selections = [ diff --git a/packages/diffs/test/editorTextDocument.test.ts b/packages/diffs/test/editorTextDocument.test.ts index d08c4369b..106ac7db3 100644 --- a/packages/diffs/test/editorTextDocument.test.ts +++ b/packages/diffs/test/editorTextDocument.test.ts @@ -233,12 +233,24 @@ describe('TextDocument', () => { expect(d.findNextNonOverlappingSubstring('foo', [[0, 3]])).toBe(4); }); - test('eol reports the document line ending', () => { + test('eol reports and caches the document line ending', () => { expect(doc('a\nb').eol).toBe('\n'); expect(doc('a\r\nb').eol).toBe('\r\n'); expect(doc('a\rb').eol).toBe('\r'); // A single-line document has no break to detect and defaults to \n. expect(doc('abc').eol).toBe('\n'); + + const d = doc('a\r\nb'); + d.applyEdits([ + { + range: { + start: { line: 0, character: 1 }, + end: { line: 1, character: 0 }, + }, + newText: '\n', + }, + ]); + expect(d.eol).toBe('\r\n'); }); test('normalizeEol rewrites mixed line endings to the document EOL', () => { diff --git a/packages/diffs/test/editorTokenizer.test.ts b/packages/diffs/test/editorTokenizer.test.ts index 3597e6708..e7b831823 100644 --- a/packages/diffs/test/editorTokenizer.test.ts +++ b/packages/diffs/test/editorTokenizer.test.ts @@ -5,7 +5,7 @@ import { TextDocument, type TextDocumentChange, } from '../src/editor/textDocument'; -import { EditorTokenizer } from '../src/editor/tokenzier'; +import { EditorTokenizer } from '../src/editor/tokenizer'; import type { DiffsHighlighter, HighlightedToken } from '../src/types'; const noopSetStyle = () => {};