From 9c758828ae834bf1c64d5f15709bdfcaa53b2329 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 11 Aug 2026 01:14:47 +0800 Subject: [PATCH 1/3] [diffs/edit] add Alt-drag column cursors --- apps/docs/app/(diffs)/_edit/EditReference.tsx | 2 +- apps/docs/app/(diffs)/docs/Edit/content.mdx | 5 +- packages/diffs/src/editor/editor.ts | 126 +++++++++++++++++- packages/diffs/test/e2e/multi-edit.pw.ts | 115 ++++++++++++++++ packages/diffs/test/editorSelection.test.ts | 107 ++++++++++++++- 5 files changed, 349 insertions(+), 6 deletions(-) diff --git a/apps/docs/app/(diffs)/_edit/EditReference.tsx b/apps/docs/app/(diffs)/_edit/EditReference.tsx index afa7be7b8..2853e56c3 100644 --- a/apps/docs/app/(diffs)/_edit/EditReference.tsx +++ b/apps/docs/app/(diffs)/_edit/EditReference.tsx @@ -42,7 +42,7 @@ const CAPABILITY_GROUPS: ReferenceGroup[] = [ { term: 'Multiple cursors', description: - 'Cmd/Ctrl-click adds carets; one edit applies to every selection and overlapping ranges merge.', + 'Cmd/Ctrl-click adds carets, while Alt/Option-drag starts a fresh set of column-aligned cursors; one edit applies to every selection.', }, { term: 'Smart indentation', diff --git a/apps/docs/app/(diffs)/docs/Edit/content.mdx b/apps/docs/app/(diffs)/docs/Edit/content.mdx index 94b9d2f2f..9317fa06b 100644 --- a/apps/docs/app/(diffs)/docs/Edit/content.mdx +++ b/apps/docs/app/(diffs)/docs/Edit/content.mdx @@ -54,8 +54,9 @@ You render the surface first, then attach editing: expect. Clipboard shortcuts are handled by the browser; edit mode commands handle structure-aware actions like indent and undo. 3. **Selections** β€” Carets and ranges are tracked with the native `Selection` - API. Multiple non-overlapping selections are supported; Cmd/Ctrl-click adds - another cursor without clearing existing ones. + API. Multiple non-overlapping selections are supported; Cmd/Ctrl-click adds a + cursor, while Alt/Option-drag adds column-aligned cursors or rectangular + selections after clearing existing ones. 4. **Updates** β€” Keystrokes update an internal `TextDocument`, then changed lines are re-highlighted through the same tokenizer pipeline as read-only mode. Your `onChange` handler receives updated `FileContents`, optional diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 13a37f3b5..c23677f6d 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -188,6 +188,15 @@ interface ViewportInputWatch { dispose(): void; } +interface AltColumnDrag { + pointerId: number; + startClientX: number; + clientX: number; + startScrollLeft: number; + focusLine?: number; + focusCharacter?: number; +} + export interface EditorOptions { /** The maximum number of entries to keep in the undo stack. */ historyMaxEntries?: number; @@ -378,6 +387,7 @@ export class Editor implements DiffsEditor { #isComposing = false; #isGutterMouseDown = false; #isContentMouseDown = false; + #altColumnDrag?: AltColumnDrag; #shiftKeyPressed = false; #selectionStart: EditorSelection | undefined; // The full text of a read-only deleted-line selection built from the gutter, @@ -1477,6 +1487,7 @@ export class Editor implements DiffsEditor { this.#overlayElements = undefined; this.#selections = undefined; this.#reservedSelections = undefined; + this.#altColumnDrag = undefined; this.#scrollingToLine = undefined; this.#markerRenderer?.cleanup(); this.#markerRenderer = undefined; @@ -1743,6 +1754,13 @@ export class Editor implements DiffsEditor { } } + if (this.#altColumnDrag !== undefined) { + this.#altColumnDrag.focusLine = getCaretPosition(selection).line; + if (this.#updateAltColumnSelections(true)) { + return; + } + } + if (this.#reservedSelections !== undefined) { this.#updateSelections([ ...this.#reservedSelections.filter( @@ -1775,6 +1793,7 @@ export class Editor implements DiffsEditor { } this.#shouldIgnoreSelectionChange = false; this.#isContentMouseDown = false; + this.#altColumnDrag = undefined; this.#shiftKeyPressed = false; this.#selectionStart = undefined; this.#reservedSelections = undefined; @@ -1905,6 +1924,7 @@ export class Editor implements DiffsEditor { if (e.pointerType !== 'mouse') { return; } + this.#altColumnDrag = undefined; // A click on a read-only deleted line (unified view) selects it // natively. Hand the selection to the deleted text and drop the @@ -1923,6 +1943,7 @@ export class Editor implements DiffsEditor { // this is a workaround for the selection rendering glitch // happens when selecting content in shadow DOM on Safari + const selectEventDisposes: (() => void)[] = []; if ( isSafari() && this.#lineAnnotations !== undefined && @@ -1942,16 +1963,52 @@ export class Editor implements DiffsEditor { }), ]) .flat(); - this.#replaceSelectEventListeners(annotationDisposes); + selectEventDisposes.push(...annotationDisposes); } this.#isContentMouseDown = true; + const isAltColumnDrag = + e.button === 0 && + e.altKey && + !e.ctrlKey && + !e.metaKey && + !e.shiftKey; this.#selectionStart = undefined; - if (e.button === 0 && isPrimaryModifier(e)) { + if (isAltColumnDrag) { + this.#altColumnDrag = { + pointerId: e.pointerId, + startClientX: e.clientX, + clientX: e.clientX, + startScrollLeft: contentEl.parentElement?.scrollLeft ?? 0, + }; + this.#reservedSelections = undefined; + this.#selections = undefined; + this.#updateSelections([]); + selectEventDisposes.push( + addEventListener( + document, + 'pointermove', + (moveEvent) => { + const drag = this.#altColumnDrag; + if ( + drag === undefined || + moveEvent.pointerType !== 'mouse' || + moveEvent.pointerId !== drag.pointerId + ) { + return; + } + drag.clientX = moveEvent.clientX; + this.#updateAltColumnSelections(false); + }, + { capture: true, passive: true } + ) + ); + } else if (e.button === 0 && isPrimaryModifier(e)) { this.#reservedSelections = this.#selections?.map((selection) => ({ ...selection, })); } + this.#replaceSelectEventListeners(selectEventDisposes); if (e.shiftKey) { const primarySelection = this.#selections?.at(-1); if (primarySelection !== undefined) { @@ -4094,6 +4151,71 @@ export class Editor implements DiffsEditor { }); } + // Keep the drag's horizontal goal in pointer space so a native caret that + // briefly clamps to a short line cannot move every generated selection. + #updateAltColumnSelections(force: boolean): boolean { + const drag = this.#altColumnDrag; + const selectionStart = this.#selectionStart; + const textDocument = this.#textDocument; + if ( + drag === undefined || + drag.focusLine === undefined || + selectionStart === undefined || + textDocument === undefined + ) { + return false; + } + + const anchor = + selectionStart.direction === DirectionBackward + ? selectionStart.end + : selectionStart.start; + const scrollLeft = this.#contentElement?.parentElement?.scrollLeft ?? 0; + const characterDeltaRaw = + (drag.clientX - drag.startClientX + scrollLeft - drag.startScrollLeft) / + this.#metrics.ch; + const characterDelta = + characterDeltaRaw < 0 + ? -Math.round(-characterDeltaRaw) + : Math.round(characterDeltaRaw); + const focusCharacter = Math.max(0, anchor.character + characterDelta); + if (!force && focusCharacter === drag.focusCharacter) { + return true; + } + drag.focusCharacter = focusCharacter; + + const selections: EditorSelection[] = []; + const step = drag.focusLine < anchor.line ? -1 : 1; + for (let line = anchor.line; ; line += step) { + if (this.#isLineRenderable(line)) { + const lineLength = textDocument.getLineLength(line); + const anchorCharacter = Math.min(anchor.character, lineLength); + const lineFocusCharacter = Math.min(focusCharacter, lineLength); + selections.push({ + start: { + line, + character: Math.min(anchorCharacter, lineFocusCharacter), + }, + end: { + line, + character: Math.max(anchorCharacter, lineFocusCharacter), + }, + direction: + anchorCharacter === lineFocusCharacter + ? DirectionNone + : anchorCharacter < lineFocusCharacter + ? DirectionForward + : DirectionBackward, + }); + } + if (line === drag.focusLine) { + break; + } + } + this.#updateSelections(selections); + return true; + } + #updateSelections(selections: EditorSelection[]) { this.__postponeBgTokenizeToNextFrame(); diff --git a/packages/diffs/test/e2e/multi-edit.pw.ts b/packages/diffs/test/e2e/multi-edit.pw.ts index 118c43756..f431a303f 100644 --- a/packages/diffs/test/e2e/multi-edit.pw.ts +++ b/packages/diffs/test/e2e/multi-edit.pw.ts @@ -10,6 +10,18 @@ async function openFixture(page: Page): Promise { const contents = (page: Page): Promise => page.evaluate(() => window.__editor?.getText() ?? ''); +const selectionTuples = (page: Page): Promise => + page.evaluate(() => + window.__editor + ?.getState() + .selections?.map((selection) => [ + selection.start.line, + selection.start.character, + selection.end.line, + selection.end.character, + ]) + ); + test.describe('multi-cursor and indentation', () => { // Adding a caret with a modifier-click can't be simulated in the pinned // headless Chromium: selectionchange fires before pointerdown there, so the @@ -48,6 +60,109 @@ test.describe('multi-cursor and indentation', () => { .toBe(2); }); + test('Alt-drag keeps its goal column across short and empty lines', async ({ + page, + }) => { + await openFixture(page); + + const content = page.locator(CONTENT); + await content.click(); + await content.evaluate((element) => { + const history: number[][][] = []; + let recording = false; + Reflect.set(window, '__altDragSelectionHistory', history); + element.addEventListener('pointerdown', (event) => { + if (event instanceof PointerEvent && event.altKey) { + history.length = 0; + recording = true; + } + }); + document.addEventListener('selectionchange', () => { + if (!recording) { + return; + } + const selections = window.__editor + ?.getState() + .selections?.map((selection) => [ + selection.start.line, + selection.start.character, + selection.end.line, + selection.end.character, + ]); + if (selections !== undefined) { + history.push(selections); + } + }); + document.addEventListener('pointerup', () => { + recording = false; + }); + }); + + const points = await content.evaluate((element) => { + const rect = (selector: string): DOMRect => { + const target = element.querySelector(selector); + if (target == null) { + throw new Error(`column selection target missing: ${selector}`); + } + return target.getBoundingClientRect(); + }; + const start = rect('[data-line="2"]'); + const short = rect('[data-line="3"]'); + const empty = rect('[data-line="4"]'); + const token = rect('[data-line="2"] [data-char="18"]'); + return { + x: token.left + token.width / 2, + startY: start.top + start.height / 2, + shortY: short.top + short.height / 2, + emptyY: empty.top + empty.height / 2, + }; + }); + await page.keyboard.down('Alt'); + await page.mouse.move(points.x, points.startY); + await page.mouse.down(); + await page.mouse.move(points.x, points.shortY, { steps: 4 }); + + await expect + .poll(() => + page.evaluate(() => { + const selections = window.__editor?.getState().selections; + return ( + selections?.length === 2 && + selections[0].start.line === 1 && + selections[0].start.character > 16 && + selections[0].end.character === selections[0].start.character && + selections[1].start.line === 2 && + selections[1].start.character === 16 && + selections[1].end.character === 16 + ); + }) + ) + .toBe(true); + const anchor = (await selectionTuples(page))?.[0]?.[1] ?? -1; + + await page.mouse.move(points.x, points.emptyY, { steps: 4 }); + const selections = [ + [1, anchor, 1, anchor], + [2, 16, 2, 16], + [3, 0, 3, 0], + ]; + await expect.poll(() => selectionTuples(page)).toEqual(selections); + + await page.mouse.up(); + await page.keyboard.up('Alt'); + + await expect.poll(() => selectionTuples(page)).toEqual(selections); + + const history = await page.evaluate( + () => Reflect.get(window, '__altDragSelectionHistory') as number[][][] + ); + expect(history.some((snapshot) => snapshot.length === 2)).toBe(true); + expect(history.some((snapshot) => snapshot.length === 3)).toBe(true); + for (const snapshot of history) { + expect(snapshot).toEqual(selections.slice(0, snapshot.length)); + } + }); + test('Tab indents selected lines and Shift+Tab outdents them', async ({ page, }) => { diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 34f419782..47f051040 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -3902,6 +3902,7 @@ async function waitForEditableContent( interface EditorFixture { cleanup(): void; + content: HTMLElement; editor: Editor; } @@ -3919,7 +3920,7 @@ async function createEditorFixture(contents: string): Promise { file.render({ file: initialFile, fileContainer, forceRender: true }); editor.edit(file); - await waitForEditableContent(fileContainer); + const content = await waitForEditableContent(fileContainer); return { cleanup() { @@ -3927,10 +3928,114 @@ async function createEditorFixture(contents: string): Promise { file.cleanUp(); dom.cleanup(); }, + content, editor, }; } +describe('Editor Alt-drag column selection', () => { + test('clears existing selections and clamps each crossed line independently', async () => { + const { cleanup, content, editor } = await createEditorFixture( + 'keep\nalpha\nx\n\nbravo' + ); + const originalGetSelection = document.getSelection.bind(document); + let nativeRange: StaticRange; + + try { + content.dispatchEvent(new Event('focus')); + editor.setSelections([ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + direction: 'forward', + }, + ]); + await wait(0); + await wait(0); + + const lines = [...content.querySelectorAll('[data-line]')]; + const [startContainer, startOffset] = getSelectionAnchor(lines[1], 2); + const rangeTo = (endLine: number, endCharacter: number): StaticRange => { + const [endContainer, endOffset] = getSelectionAnchor( + lines[endLine], + endCharacter + ); + return composedRange( + startContainer, + startOffset, + endContainer, + endOffset + ); + }; + + const selections = [caret(1, 2), caret(2, 1), caret(3, 0), caret(4, 2)]; + + nativeRange = rangeTo(1, 2); + document.getSelection = (() => ({ + getComposedRanges: () => [nativeRange], + })) as unknown as typeof document.getSelection; + content.dispatchEvent( + new PointerEvent('pointerdown', { + altKey: true, + bubbles: true, + button: 0, + clientX: 100, + pointerType: 'mouse', + }) + ); + expect(editor.getState().selections).toBeUndefined(); + document.dispatchEvent(new Event('selectionchange')); + + nativeRange = rangeTo(2, 1); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections.slice(0, 2)); + + nativeRange = rangeTo(3, 0); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections.slice(0, 3)); + + // An empty focus line may not produce another native selectionchange, so + // pointer movement must update the rectangle on its own. + document.dispatchEvent( + new PointerEvent('pointermove', { + clientX: 116, + pointerType: 'mouse', + }) + ); + expect(editor.getState().selections).toEqual([ + createSelection(1, 2, 1, 4, DirectionForward), + caret(2, 1), + caret(3, 0), + ]); + + document.dispatchEvent( + new PointerEvent('pointermove', { + clientX: 100, + pointerType: 'mouse', + }) + ); + expect(editor.getState().selections).toEqual(selections.slice(0, 3)); + + nativeRange = rangeTo(4, 0); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections); + + nativeRange = rangeTo(4, 2); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections); + + document.dispatchEvent( + new PointerEvent('pointerup', { pointerType: 'mouse' }) + ); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections); + } finally { + document.getSelection = originalGetSelection; + cleanup(); + } + }); +}); + describe('Editor.setSelections position clamping', () => { test('positions past a line length or past the last line clamp instead of throwing', async () => { // DIVERGENCE: a stricter contract would reject out-of-range selections From 648ce794b444a97fd8bd424f1f989e968be3e712 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 11 Aug 2026 01:42:43 +0800 Subject: [PATCH 2/3] clean up --- apps/docs/app/(diffs)/_edit/EditReference.tsx | 2 +- packages/diffs/src/editor/editor.ts | 105 ++++++++++-------- packages/diffs/test/e2e/multi-edit.pw.ts | 25 +---- packages/diffs/test/editorSelection.test.ts | 10 +- 4 files changed, 73 insertions(+), 69 deletions(-) diff --git a/apps/docs/app/(diffs)/_edit/EditReference.tsx b/apps/docs/app/(diffs)/_edit/EditReference.tsx index 2853e56c3..a30312377 100644 --- a/apps/docs/app/(diffs)/_edit/EditReference.tsx +++ b/apps/docs/app/(diffs)/_edit/EditReference.tsx @@ -42,7 +42,7 @@ const CAPABILITY_GROUPS: ReferenceGroup[] = [ { term: 'Multiple cursors', description: - 'Cmd/Ctrl-click adds carets, while Alt/Option-drag starts a fresh set of column-aligned cursors; one edit applies to every selection.', + 'Cmd/Ctrl-click adds carets; Alt/Option-drag starts a fresh column selection; edits apply to every range and overlaps merge.', }, { term: 'Smart indentation', diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index c23677f6d..bb37366f2 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -194,7 +194,7 @@ interface AltColumnDrag { clientX: number; startScrollLeft: number; focusLine?: number; - focusCharacter?: number; + renderedGoal?: Position; } export interface EditorOptions { @@ -782,8 +782,6 @@ export class Editor implements DiffsEditor { this.#globalEventDisposes = undefined; this.#editorEventDisposes?.forEach((dispose) => dispose()); this.#editorEventDisposes = undefined; - this.#selectEventDisposes?.forEach((dispose) => dispose()); - this.#selectEventDisposes = undefined; this.#detach?.(recycle); this.#detach = undefined; @@ -1481,13 +1479,11 @@ export class Editor implements DiffsEditor { this.#setEditorActiveLineSafe(null); this.#gutterWidthCache = undefined; this.#contentWidthCache = undefined; - this.#shouldIgnoreSelectionChange = false; + this.#resetSelectionGesture(); this.#suppressNativeSelectionSync = false; this.#overlayElements?.forEach((el) => el.remove()); this.#overlayElements = undefined; this.#selections = undefined; - this.#reservedSelections = undefined; - this.#altColumnDrag = undefined; this.#scrollingToLine = undefined; this.#markerRenderer?.cleanup(); this.#markerRenderer = undefined; @@ -1627,6 +1623,29 @@ export class Editor implements DiffsEditor { this.#contentHasFocus = false; this.#shouldIgnoreSelectionChange = false; }; + const finishMouseSelection = (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + return; + } + + const refocusEditor = this.#isGutterMouseDown; + this.#resetSelectionGesture(); + if (refocusEditor) { + this.#focus(); + } + + // The popover is suppressed while the mouse is down so it doesn't + // flicker under the cursor mid-drag. Once settled, re-run the overlay + // pass so a ranged selection reveals it. + if ( + this.#options.enabledSelectionAction === true && + this.#selections !== undefined && + this.#selections.length > 0 && + !isCollapsedSelection(this.#selections.at(-1)!) + ) { + this.#updateSelections(this.#selections); + } + }; this.#globalEventDisposes = [ addEventListener( document, @@ -1756,7 +1775,7 @@ export class Editor implements DiffsEditor { if (this.#altColumnDrag !== undefined) { this.#altColumnDrag.focusLine = getCaretPosition(selection).line; - if (this.#updateAltColumnSelections(true)) { + if (this.#updateAltColumnSelections()) { return; } } @@ -1776,41 +1795,12 @@ export class Editor implements DiffsEditor { { passive: true } ), - addEventListener( - document, - 'pointerup', - (e) => { - if (e.pointerType !== 'mouse') { - return; - } - - this.#selectEventDisposes?.forEach((dispose) => dispose()); - this.#selectEventDisposes = undefined; - - if (this.#isGutterMouseDown) { - this.#isGutterMouseDown = false; - this.#focus(); - } - this.#shouldIgnoreSelectionChange = false; - this.#isContentMouseDown = false; - this.#altColumnDrag = undefined; - this.#shiftKeyPressed = false; - this.#selectionStart = undefined; - this.#reservedSelections = undefined; - // The popover is suppressed while the mouse is down so it doesn't - // flicker under the cursor mid-drag. Now that the drag has ended, - // re-run the overlay pass so a settled ranged selection reveals it. - if ( - this.#options.enabledSelectionAction === true && - this.#selections !== undefined && - this.#selections.length > 0 && - !isCollapsedSelection(this.#selections.at(-1)!) - ) { - this.#updateSelections(this.#selections); - } - }, - { passive: true } - ), + addEventListener(document, 'pointerup', finishMouseSelection, { + passive: true, + }), + addEventListener(document, 'pointercancel', finishMouseSelection, { + passive: true, + }), addEventListener( document, @@ -1836,6 +1826,21 @@ export class Editor implements DiffsEditor { ]; } + // End any in-flight text or gutter selection and release its document-level + // listeners. Pointer completion, cancellation, and editor resets share this + // path so none can strand gesture state. + #resetSelectionGesture(): void { + this.#selectEventDisposes?.forEach((dispose) => dispose()); + this.#selectEventDisposes = undefined; + this.#shouldIgnoreSelectionChange = false; + this.#isGutterMouseDown = false; + this.#isContentMouseDown = false; + this.#altColumnDrag = undefined; + this.#shiftKeyPressed = false; + this.#selectionStart = undefined; + this.#reservedSelections = undefined; + } + // Swaps in a new batch of transient "select" listeners β€” gutter drag // tracking or the Safari annotation-hover workaround β€” disposing the // previous batch first. Routing every reassignment through here keeps the @@ -1998,7 +2003,7 @@ export class Editor implements DiffsEditor { return; } drag.clientX = moveEvent.clientX; - this.#updateAltColumnSelections(false); + this.#updateAltColumnSelections(); }, { capture: true, passive: true } ) @@ -4153,7 +4158,7 @@ export class Editor implements DiffsEditor { // Keep the drag's horizontal goal in pointer space so a native caret that // briefly clamps to a short line cannot move every generated selection. - #updateAltColumnSelections(force: boolean): boolean { + #updateAltColumnSelections(): boolean { const drag = this.#altColumnDrag; const selectionStart = this.#selectionStart; const textDocument = this.#textDocument; @@ -4179,13 +4184,17 @@ export class Editor implements DiffsEditor { ? -Math.round(-characterDeltaRaw) : Math.round(characterDeltaRaw); const focusCharacter = Math.max(0, anchor.character + characterDelta); - if (!force && focusCharacter === drag.focusCharacter) { + const goal = { line: drag.focusLine, character: focusCharacter }; + if ( + drag.renderedGoal !== undefined && + comparePosition(drag.renderedGoal, goal) === 0 + ) { return true; } - drag.focusCharacter = focusCharacter; + drag.renderedGoal = goal; const selections: EditorSelection[] = []; - const step = drag.focusLine < anchor.line ? -1 : 1; + const step = goal.line < anchor.line ? -1 : 1; for (let line = anchor.line; ; line += step) { if (this.#isLineRenderable(line)) { const lineLength = textDocument.getLineLength(line); @@ -4208,7 +4217,7 @@ export class Editor implements DiffsEditor { : DirectionBackward, }); } - if (line === drag.focusLine) { + if (line === goal.line) { break; } } diff --git a/packages/diffs/test/e2e/multi-edit.pw.ts b/packages/diffs/test/e2e/multi-edit.pw.ts index f431a303f..983bc52ea 100644 --- a/packages/diffs/test/e2e/multi-edit.pw.ts +++ b/packages/diffs/test/e2e/multi-edit.pw.ts @@ -72,9 +72,9 @@ test.describe('multi-cursor and indentation', () => { let recording = false; Reflect.set(window, '__altDragSelectionHistory', history); element.addEventListener('pointerdown', (event) => { - if (event instanceof PointerEvent && event.altKey) { + recording = event instanceof PointerEvent && event.altKey; + if (recording) { history.length = 0; - recording = true; } }); document.addEventListener('selectionchange', () => { @@ -106,13 +106,12 @@ test.describe('multi-cursor and indentation', () => { } return target.getBoundingClientRect(); }; - const start = rect('[data-line="2"]'); const short = rect('[data-line="3"]'); const empty = rect('[data-line="4"]'); const token = rect('[data-line="2"] [data-char="18"]'); return { x: token.left + token.width / 2, - startY: start.top + start.height / 2, + startY: token.top + token.height / 2, shortY: short.top + short.height / 2, emptyY: empty.top + empty.height / 2, }; @@ -123,22 +122,10 @@ test.describe('multi-cursor and indentation', () => { await page.mouse.move(points.x, points.shortY, { steps: 4 }); await expect - .poll(() => - page.evaluate(() => { - const selections = window.__editor?.getState().selections; - return ( - selections?.length === 2 && - selections[0].start.line === 1 && - selections[0].start.character > 16 && - selections[0].end.character === selections[0].start.character && - selections[1].start.line === 2 && - selections[1].start.character === 16 && - selections[1].end.character === 16 - ); - }) - ) - .toBe(true); + .poll(async () => (await selectionTuples(page))?.length) + .toBe(2); const anchor = (await selectionTuples(page))?.[0]?.[1] ?? -1; + expect(anchor).toBeGreaterThan(16); await page.mouse.move(points.x, points.emptyY, { steps: 4 }); const selections = [ diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 47f051040..6f875f5ee 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -4024,9 +4024,17 @@ describe('Editor Alt-drag column selection', () => { document.dispatchEvent(new Event('selectionchange')); expect(editor.getState().selections).toEqual(selections); + // Cancellation tears down both native-selection and pointer tracking. document.dispatchEvent( - new PointerEvent('pointerup', { pointerType: 'mouse' }) + new PointerEvent('pointercancel', { pointerType: 'mouse' }) ); + document.dispatchEvent( + new PointerEvent('pointermove', { + clientX: 116, + pointerType: 'mouse', + }) + ); + nativeRange = rangeTo(1, 0); document.dispatchEvent(new Event('selectionchange')); expect(editor.getState().selections).toEqual(selections); } finally { From 0c6876e5567ad767d794107869c4bcdfde14e18e Mon Sep 17 00:00:00 2001 From: Je Xia Date: Tue, 11 Aug 2026 02:04:48 +0800 Subject: [PATCH 3/3] fix --- packages/diffs/src/editor/editor.ts | 16 +++++++-- packages/diffs/src/editor/selection.ts | 6 ++-- packages/diffs/test/editorSelection.test.ts | 37 +++++++++++++++++---- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index bb37366f2..2f6d58ca9 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -101,6 +101,7 @@ import { resolveSelectionCut, selectionIntersects, shiftSelectionLines, + snapCharacterToGraphemeBoundary, } from './selection'; import { type SelectionActionContext, @@ -4197,9 +4198,18 @@ export class Editor implements DiffsEditor { const step = goal.line < anchor.line ? -1 : 1; for (let line = anchor.line; ; line += step) { if (this.#isLineRenderable(line)) { - const lineLength = textDocument.getLineLength(line); - const anchorCharacter = Math.min(anchor.character, lineLength); - const lineFocusCharacter = Math.min(focusCharacter, lineLength); + // Projected UTF-16 offsets must not split a grapheme on this line. + const lineText = textDocument.getLineText(line); + const anchorOffset = Math.min(anchor.character, lineText.length); + const focusOffset = Math.min(focusCharacter, lineText.length); + const anchorCharacter = snapCharacterToGraphemeBoundary( + lineText, + anchorOffset + ); + const lineFocusCharacter = + focusOffset === anchorOffset + ? anchorCharacter + : snapCharacterToGraphemeBoundary(lineText, focusOffset); selections.push({ start: { line, diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index cb83b2e15..c62562f30 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -342,9 +342,9 @@ function moveBySoftLine( }; } -// Snaps a vertical landing to the end of its grapheme so edits cannot split a -// user-visible character. Stop once the containing cluster is resolved. -function snapCharacterToGraphemeBoundary( +// Snaps an editable position to the end of its grapheme so edits cannot split +// a user-visible character. Stop once the containing cluster is resolved. +export function snapCharacterToGraphemeBoundary( lineText: string, character: number ): number { diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 6f875f5ee..a15d4ae9e 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -3934,9 +3934,9 @@ async function createEditorFixture(contents: string): Promise { } describe('Editor Alt-drag column selection', () => { - test('clears existing selections and clamps each crossed line independently', async () => { + test('normalizes short, empty, and Unicode lines independently', async () => { const { cleanup, content, editor } = await createEditorFixture( - 'keep\nalpha\nx\n\nbravo' + 'keep\nalpha\nx\naπŸ˜€\nae\u0301\n\u1112\u1161\u11ab\n\nbravo' ); const originalGetSelection = document.getSelection.bind(document); let nativeRange: StaticRange; @@ -3968,7 +3968,15 @@ describe('Editor Alt-drag column selection', () => { ); }; - const selections = [caret(1, 2), caret(2, 1), caret(3, 0), caret(4, 2)]; + const selections = [ + caret(1, 2), + caret(2, 1), + caret(3, 3), + caret(4, 3), + caret(5, 3), + caret(6, 0), + caret(7, 2), + ]; nativeRange = rangeTo(1, 2); document.getSelection = (() => ({ @@ -3994,6 +4002,18 @@ describe('Editor Alt-drag column selection', () => { document.dispatchEvent(new Event('selectionchange')); expect(editor.getState().selections).toEqual(selections.slice(0, 3)); + nativeRange = rangeTo(4, 0); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections.slice(0, 4)); + + nativeRange = rangeTo(5, 0); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections.slice(0, 5)); + + nativeRange = rangeTo(6, 0); + document.dispatchEvent(new Event('selectionchange')); + expect(editor.getState().selections).toEqual(selections.slice(0, 6)); + // An empty focus line may not produce another native selectionchange, so // pointer movement must update the rectangle on its own. document.dispatchEvent( @@ -4005,7 +4025,10 @@ describe('Editor Alt-drag column selection', () => { expect(editor.getState().selections).toEqual([ createSelection(1, 2, 1, 4, DirectionForward), caret(2, 1), - caret(3, 0), + caret(3, 3), + caret(4, 3), + caret(5, 3), + caret(6, 0), ]); document.dispatchEvent( @@ -4014,13 +4037,13 @@ describe('Editor Alt-drag column selection', () => { pointerType: 'mouse', }) ); - expect(editor.getState().selections).toEqual(selections.slice(0, 3)); + expect(editor.getState().selections).toEqual(selections.slice(0, 6)); - nativeRange = rangeTo(4, 0); + nativeRange = rangeTo(7, 0); document.dispatchEvent(new Event('selectionchange')); expect(editor.getState().selections).toEqual(selections); - nativeRange = rangeTo(4, 2); + nativeRange = rangeTo(7, 2); document.dispatchEvent(new Event('selectionchange')); expect(editor.getState().selections).toEqual(selections);