Skip to content
Merged
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
5 changes: 3 additions & 2 deletions apps/docs/app/(diffs)/docs/Editor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export const EDITOR_SELECTION_ACTION_EXAMPLE: PreloadFileOptions<undefined> = {

const editor = new Editor({
enabledSelectionAction: true,
// The popover appears automatically on selection (no icon, no extra click).
// The popover appears after a user-created ranged selection.
renderSelectionAction: (context) => {
const container = document.createElement('div');
const button = document.createElement('button');
Expand Down Expand Up @@ -952,7 +952,8 @@ interface EditorOptions<LAnnotation> {
// (default: 'default' — both quotes and brackets)
autoSurround?: 'default' | 'never' | 'brackets' | 'quotes' | 'languageDefined';

// Show the floating Selection Action popover on selection (default: false)
// Show the floating Selection Action popover after a user selection.
// Programmatic setSelections/setState calls do not open it (default: false).
enabledSelectionAction?: boolean;

// Custom clipboard provider.
Expand Down
7 changes: 4 additions & 3 deletions apps/docs/app/(diffs)/docs/Editor/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,10 @@ where editing is rare.
Selection Action is an opt-in edit mode feature for showing custom UI alongside
the current selection—useful for quick transforms, refactor prompts, or other
selection-scoped tools. Set `enabledSelectionAction: true` and return your UI
from `renderSelectionAction`; a floating popover holding your UI appears
automatically, anchored to the active selection. The popover can hold any number
of actions.
from `renderSelectionAction`; a floating popover holding your UI appears after
the user creates a ranged selection. Programmatic `setSelections` and `setState`
calls update the selection without opening the popover. The popover can hold any
number of actions.

<DocsCodeExample {...editorSelectionActionExample} />

Expand Down
15 changes: 13 additions & 2 deletions packages/diffs/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ export interface EditorOptions<LAnnotation> {
/** Per-language comment tokens used by the comment commands. */
languageCommentConfig?: LanguageConfigMap;
/**
* Show a floating selection action popover anchored to the active selection,
* default is disabled.
* Show a floating selection action popover after a user-created selection,
* default is disabled. Programmatic selection updates do not open it.
*/
enabledSelectionAction?: boolean;
/**
Expand Down Expand Up @@ -336,6 +336,8 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
#markerRenderer?: MarkerRenderer;
#searchPanel?: SearchPanelWidget;
#selectionAction?: SelectionActionWidget;
// Programmatic ranges stay passive until the user interacts with the editor.
#canMountSelectionAction = true;
#shouldIgnoreSelectionChange = false;
// Set when the browser's native Selection is temporarily not the source of
// truth for editor text selections. Select-all can only mirror rendered
Expand Down Expand Up @@ -594,6 +596,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
if (this.#fileInstance === undefined || this.#textDocument === undefined) {
throw new Error('Editor is not attached');
}
this.#canMountSelectionAction = false;
this.#updateSelections(selections ?? []);
// When a saved view is present, honor its scroll offsets exactly. Scrolling
// the caret into view afterward would overwrite them whenever the caret
Expand Down Expand Up @@ -640,6 +643,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
}
return { direction, start, end };
});
this.#canMountSelectionAction = false;
this.#updateSelections(resolvedSelections);
const primarySelection = resolvedSelections.at(-1);
if (primarySelection !== undefined) {
Expand Down Expand Up @@ -1365,6 +1369,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
this.#searchPanel = undefined;
this.#selectionAction?.cleanup();
this.#selectionAction = undefined;
this.#canMountSelectionAction = true;
}

#invalidateOnAttach(): void {
Expand Down Expand Up @@ -1688,6 +1693,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
contentEl,
'pointerdown',
(e) => {
this.#canMountSelectionAction = true;
// Any pointer press — mouse, touch, or pen — moves off the select-all
// selection, so let selectionchange sync #selections from the new
// native selection again. This must run before the mouse-only guard
Expand Down Expand Up @@ -1780,6 +1786,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
if (!targetIsContentElement(e)) {
return;
}
this.#canMountSelectionAction = true;
// A keystroke is the user acting on the select-all selection (deleting,
// typing, moving); let selectionchange sync #selections again.
this.#suppressNativeSelectionSync = false;
Expand Down Expand Up @@ -2130,6 +2137,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
return;
}

this.#canMountSelectionAction = true;
this.#markerRenderer?.removePopover();
const selection = this.#spanLineSelection(
lineIndex,
Expand Down Expand Up @@ -4295,6 +4303,9 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
}

if (this.#selectionAction === undefined) {
if (!this.#canMountSelectionAction) {
return;
}
const getActiveSelection = (): EditorSelection =>
this.#selections?.at(-1) ?? primarySelection;
const selectionActionElement = renderSelectionAction({
Expand Down
99 changes: 97 additions & 2 deletions packages/diffs/test/editorSelectionAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import { File } from '../src/components/File';
import { DEFAULT_THEMES } from '../src/constants';
import { Editor, type EditorOptions } from '../src/editor/editor';
import { PopoverManager } from '../src/editor/popover';
import { DirectionBackward, getCaretPosition } from '../src/editor/selection';
import {
DirectionBackward,
DirectionForward,
getCaretPosition,
} from '../src/editor/selection';
import type { SelectionActionContext } from '../src/editor/selectionAction';
import { disposeHighlighter } from '../src/highlighter/shared_highlighter';
import type { FileContents, RenderRange } from '../src/types';
import type { EditorSelection, FileContents, RenderRange } from '../src/types';
import { installDom, wait } from './domHarness';

afterAll(async () => {
Expand Down Expand Up @@ -99,6 +103,17 @@ function findSelectionActionPopover(content: HTMLElement): HTMLElement {
return popover;
}

// setSelections supplies deterministic ranges in jsdom; the pointer gesture
// marks them as user-created so selection-action tests exercise the mount path.
function settleSelectionAction(content: HTMLElement): void {
content.dispatchEvent(
new PointerEvent('pointerdown', { button: 0, pointerType: 'mouse' })
);
document.dispatchEvent(
new PointerEvent('pointerup', { button: 0, pointerType: 'mouse' })
);
}

describe('Editor selection action', () => {
// The popover element is created once when the selection settles and kept open
// across selection changes, so its handlers must read the current primary
Expand Down Expand Up @@ -127,6 +142,7 @@ describe('Editor selection action', () => {
direction: 'forward',
},
]);
settleSelectionAction(content);

// The selection grows on the same line; the popover stays open.
editor.setSelections([
Expand Down Expand Up @@ -173,6 +189,7 @@ describe('Editor selection action', () => {
direction: 'backward',
},
]);
settleSelectionAction(content);

// The selection grows backward on the same line; the popover stays open.
editor.setSelections([
Expand Down Expand Up @@ -217,6 +234,7 @@ describe('Editor selection action', () => {
direction: 'backward',
},
]);
settleSelectionAction(content);

const popover = findSelectionActionPopover(content);
expect(popover.style.getPropertyValue('--popover-y-shift').trim()).toBe(
Expand Down Expand Up @@ -248,6 +266,7 @@ describe('Editor selection action', () => {
direction: 'forward',
},
]);
settleSelectionAction(content);

const popover = findSelectionActionPopover(content);
expect(popover.style.getPropertyValue('--popover-y-shift').trim()).toBe(
Expand Down Expand Up @@ -280,6 +299,7 @@ describe('Editor selection action', () => {
direction: 'backward',
},
]);
settleSelectionAction(content);

const popover = findSelectionActionPopover(content);
expect(popover.style.getPropertyValue('--popover-y-shift').trim()).toBe(
Expand Down Expand Up @@ -312,6 +332,7 @@ describe('Editor selection action', () => {
direction: 'forward',
},
]);
settleSelectionAction(content);

const popover = findSelectionActionPopover(content);
expect(popover.style.getPropertyValue('--popover-y-shift').trim()).toBe(
Expand Down Expand Up @@ -438,6 +459,7 @@ describe('Editor selection action', () => {
direction: 'backward',
},
]);
settleSelectionAction(content);

// Sanity check the fallback candidate's row actually differs from the
// head's, so the assertion below isn't vacuously true.
Expand Down Expand Up @@ -571,6 +593,7 @@ describe('Editor selection action', () => {
direction: 'backward',
},
]);
settleSelectionAction(content);

const popover = findSelectionActionPopover(content);
// The initial 20px height fits above the head row: top is 10px inside
Expand Down Expand Up @@ -639,6 +662,7 @@ describe('Editor selection action', () => {
direction: 'backward',
},
]);
settleSelectionAction(content);

const popover = findSelectionActionPopover(content);
// Preferred (head-anchored, placed above) must win: the fallback's
Expand Down Expand Up @@ -820,6 +844,7 @@ describe('Editor selection action', () => {
direction: 'forward',
},
]);
settleSelectionAction(content);
expect(() => findSelectionActionPopover(content)).not.toThrow();

editor.setSelections([
Expand Down Expand Up @@ -960,6 +985,75 @@ describe('Editor selection action', () => {
});
});

test('setSelections does not mount a selection action', async () => {
let renderCount = 0;
const { cleanup, editor, content } = await createSelectionActionFixture(
'hello world',
{
enabledSelectionAction: true,
renderSelectionAction() {
renderCount++;
return document.createElement('div');
},
}
);

try {
const start = { line: 0, character: 0 };
const end = { line: 0, character: 5 };
editor.setSelections([{ start, end, direction: 'forward' }]);
editor.setMarkers([]);
await wait(0);

expect(editor.getState().selections).toEqual([
{ start, end, direction: DirectionForward },
]);
expect(renderCount).toBe(0);
expect(
(content.getRootNode() as ShadowRoot).querySelector(
'[data-selection-action-popover]'
)
).toBeNull();
} finally {
cleanup();
}
});

test('setState does not mount a selection action', async () => {
let renderCount = 0;
const { cleanup, editor, content } = await createSelectionActionFixture(
'hello world',
{
enabledSelectionAction: true,
renderSelectionAction() {
renderCount++;
return document.createElement('div');
},
}
);

try {
const selection: EditorSelection = {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
direction: DirectionForward,
};
editor.setState({ selections: [selection] });
editor.setMarkers([]);
await wait(0);

expect(editor.getState().selections).toEqual([selection]);
expect(renderCount).toBe(0);
expect(
(content.getRootNode() as ShadowRoot).querySelector(
'[data-selection-action-popover]'
)
).toBeNull();
} finally {
cleanup();
}
});

// Without `enabledSelectionAction`, a ranged selection renders nothing and the
// consumer's callback is never invoked.
test('renders no popover when the feature is disabled', async () => {
Expand All @@ -982,6 +1076,7 @@ describe('Editor selection action', () => {
direction: 'forward',
},
]);
settleSelectionAction(content);
const root = content.getRootNode() as ShadowRoot;
expect(root.querySelector('[data-selection-action-popover]')).toBeNull();
expect(rendered).toBe(false);
Expand Down
Loading