Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/app/(diffs)/_edit/EditReference.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; Alt/Option-drag starts a fresh column selection; edits apply to every range and overlaps merge.',
},
{
term: 'Smart indentation',
Expand Down
5 changes: 3 additions & 2 deletions apps/docs/app/(diffs)/docs/Edit/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
221 changes: 181 additions & 40 deletions packages/diffs/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
resolveSelectionCut,
selectionIntersects,
shiftSelectionLines,
snapCharacterToGraphemeBoundary,
} from './selection';
import {
type SelectionActionContext,
Expand Down Expand Up @@ -188,6 +189,15 @@ interface ViewportInputWatch {
dispose(): void;
}

interface AltColumnDrag {
pointerId: number;
startClientX: number;
clientX: number;
startScrollLeft: number;
focusLine?: number;
renderedGoal?: Position;
}

export interface EditorOptions<LAnnotation> {
/** The maximum number of entries to keep in the undo stack. */
historyMaxEntries?: number;
Expand Down Expand Up @@ -378,6 +388,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
#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,
Expand Down Expand Up @@ -772,8 +783,6 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
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;

Expand Down Expand Up @@ -1471,12 +1480,11 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
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.#scrollingToLine = undefined;
this.#markerRenderer?.cleanup();
this.#markerRenderer = undefined;
Expand Down Expand Up @@ -1616,6 +1624,29 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
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,
Expand Down Expand Up @@ -1743,6 +1774,13 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
}
}

if (this.#altColumnDrag !== undefined) {
this.#altColumnDrag.focusLine = getCaretPosition(selection).line;
if (this.#updateAltColumnSelections()) {
return;
}
}

if (this.#reservedSelections !== undefined) {
this.#updateSelections([
...this.#reservedSelections.filter(
Expand All @@ -1758,40 +1796,12 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
{ 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.#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,
Expand All @@ -1817,6 +1827,21 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
];
}

// 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
Expand Down Expand Up @@ -1905,6 +1930,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
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
Expand All @@ -1923,6 +1949,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {

// 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 &&
Expand All @@ -1942,16 +1969,52 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
}),
])
.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();
},
{ 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) {
Expand Down Expand Up @@ -4094,6 +4157,84 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
});
}

// 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(): 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);
Comment on lines +4183 to +4187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Snap Alt-drag offsets to grapheme boundaries

When Alt/Option-drag synthesizes focusCharacter from the pointer delta, it stores that raw UTF-16 offset directly in every generated selection. For lines containing emoji or combining-character graphemes, the rounded offset can land inside a grapheme; subsequent typing, delete, copy/cut, or replacement uses textDocument.offsetAt() on that unsnapped position and can split/corrupt the character. The vertical cursor path already snaps real landings out of graphemes, so this rectangular-selection path should do the same per affected line before calling #updateSelections.

Useful? React with 👍 / 👎.

@ije ije Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alt-drag now snaps both synthesized endpoints per line before creating selections, using the shared grapheme-boundary helper.

const goal = { line: drag.focusLine, character: focusCharacter };
if (
drag.renderedGoal !== undefined &&
comparePosition(drag.renderedGoal, goal) === 0
) {
return true;
}
drag.renderedGoal = goal;

const selections: EditorSelection[] = [];
const step = goal.line < anchor.line ? -1 : 1;
for (let line = anchor.line; ; line += step) {
if (this.#isLineRenderable(line)) {
// 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,
character: Math.min(anchorCharacter, lineFocusCharacter),
},
end: {
line,
character: Math.max(anchorCharacter, lineFocusCharacter),
},
direction:
anchorCharacter === lineFocusCharacter
? DirectionNone
: anchorCharacter < lineFocusCharacter
? DirectionForward
: DirectionBackward,
});
}
if (line === goal.line) {
break;
}
}
this.#updateSelections(selections);
return true;
}

#updateSelections(selections: EditorSelection[]) {
this.__postponeBgTokenizeToNextFrame();

Expand Down
6 changes: 3 additions & 3 deletions packages/diffs/src/editor/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading