Skip to content

Commit ff66626

Browse files
committed
feat(Selection): add smart "select-all" behavior that respects node boundaries
1 parent 8349f2a commit ff66626

5 files changed

Lines changed: 200 additions & 6 deletions

File tree

packages/editor/src/extensions/additional/Math/MathSpecs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const MathSpecs: ExtensionAuto = (builder) => {
2727
['span', {class: CLASSNAMES.Inline.Sharp, contenteditable: 'false'}, '$'],
2828
],
2929
parseDOM: [{tag: `span.${CLASSNAMES.Inline.Content}`, priority: 200}],
30+
selectContent: false,
3031
},
3132
fromMd: {
3233
tokenName: 'math_inline',

packages/editor/src/extensions/behavior/Selection/commands.test.ts

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {Node} from 'prosemirror-model';
2-
import {TextSelection} from 'prosemirror-state';
2+
import {EditorState, TextSelection} from 'prosemirror-state';
33
import {builders} from 'prosemirror-test-builder';
44

55
import {ExtensionsManager} from '../../../core';
@@ -13,6 +13,7 @@ import {
1313
type Direction,
1414
findFakeParaPosForTextSelection,
1515
findNextFakeParaPosForGapCursorSelection,
16+
selectAll,
1617
} from './commands';
1718

1819
const {schema} = new ExtensionsManager({
@@ -26,11 +27,25 @@ const {schema} = new ExtensionsManager({
2627
spec: {content: `block*`, group: 'block', gapcursor: false},
2728
fromMd: {tokenSpec: {name: 'testnode', type: 'block', ignore: true}},
2829
toMd: () => {},
30+
}))
31+
.addNode('selectContentNode', () => ({
32+
spec: {content: `block+`, group: 'block', selectContent: true},
33+
fromMd: {tokenSpec: {name: 'selectContentNode', type: 'block', ignore: true}},
34+
toMd: () => {},
2935
})),
3036
}).buildDeps();
3137

32-
const {doc, p, bq, codeBlock, table, tbody, tr, td, testnode} = builders<
33-
'doc' | 'p' | 'bq' | 'codeBlock' | 'table' | 'tbody' | 'tr' | 'td' | 'testnode'
38+
const {doc, p, bq, codeBlock, table, tbody, tr, td, testnode, selectContentNode} = builders<
39+
| 'doc'
40+
| 'p'
41+
| 'bq'
42+
| 'codeBlock'
43+
| 'table'
44+
| 'tbody'
45+
| 'tr'
46+
| 'td'
47+
| 'testnode'
48+
| 'selectContentNode'
3449
>(schema, {
3550
doc: {nodeType: BaseNode.Doc},
3651
p: {nodeType: BaseNode.Paragraph},
@@ -253,3 +268,139 @@ describe('Selection arrow commands: findFakeParaPosForTextSelection', () => {
253268
},
254269
);
255270
});
271+
272+
describe('selectAll', () => {
273+
function createState(document: Node, from: number, to?: number) {
274+
return EditorState.create({
275+
doc: document,
276+
selection: TextSelection.create(document, from, to ?? from),
277+
});
278+
}
279+
280+
function runSelectAll(state: EditorState): EditorState | null {
281+
let newState: EditorState | null = null;
282+
selectAll(state, (tr) => {
283+
newState = state.apply(tr);
284+
});
285+
return newState;
286+
}
287+
288+
describe('code block (spec.code)', () => {
289+
it('should select all content inside code block when cursor is inside', () => {
290+
// doc: <cb>hello</cb> positions: 0[cb]1 h e l l o 6[/cb]7
291+
const d = doc(codeBlock('hello'));
292+
const state = createState(d, 3); // cursor in the middle of "hello"
293+
const result = runSelectAll(state);
294+
295+
expect(result).toBeTruthy();
296+
expect(result!.selection.from).toBe(1);
297+
expect(result!.selection.to).toBe(6);
298+
});
299+
300+
it('should return false when entire code block content is already selected', () => {
301+
const d = doc(codeBlock('hello'));
302+
const state = createState(d, 1, 6); // entire content selected
303+
const result = runSelectAll(state);
304+
305+
expect(result).toBeNull();
306+
});
307+
308+
it('should select code block content when partial selection exists', () => {
309+
const d = doc(codeBlock('hello'));
310+
const state = createState(d, 2, 4); // partial selection "ell"
311+
const result = runSelectAll(state);
312+
313+
expect(result).toBeTruthy();
314+
expect(result!.selection.from).toBe(1);
315+
expect(result!.selection.to).toBe(6);
316+
});
317+
});
318+
319+
describe('empty content', () => {
320+
it('should skip empty code block', () => {
321+
const d = doc(codeBlock());
322+
const state = createState(d, 1); // cursor in empty code block
323+
const result = runSelectAll(state);
324+
325+
expect(result).toBeNull();
326+
});
327+
328+
it('should skip selectContent node with empty paragraph', () => {
329+
const d = doc(selectContentNode(p()));
330+
const state = createState(d, 2); // cursor in empty paragraph
331+
const result = runSelectAll(state);
332+
333+
expect(result).toBeNull();
334+
});
335+
336+
it('should select content of selectContent node with non-empty paragraph', () => {
337+
const d = doc(selectContentNode(p('text')));
338+
const state = createState(d, 3); // cursor in "text"
339+
const result = runSelectAll(state);
340+
341+
expect(result).toBeTruthy();
342+
expect(result!.selection.from).toBe(1);
343+
expect(result!.selection.to).toBe(7);
344+
});
345+
});
346+
347+
describe('selectContent with multiple paragraphs', () => {
348+
// selectContentNode(p('hello'), p('world'))
349+
// positions: 0[scn]1[p]2 hello 7[/p]8[p]9 world 14[/p]15[/scn]16
350+
351+
it('should select all content when cursor is inside', () => {
352+
const d = doc(selectContentNode(p('hello'), p('world')));
353+
const state = createState(d, 4); // cursor in "hello"
354+
const result = runSelectAll(state);
355+
356+
expect(result).toBeTruthy();
357+
expect(result!.selection.from).toBe(1);
358+
expect(result!.selection.to).toBe(15);
359+
});
360+
361+
it('should fall through when all text is mouse-selected (resolved positions)', () => {
362+
const d = doc(selectContentNode(p('hello'), p('world')));
363+
// mouse selection covers from start of first paragraph text to end of last
364+
const state = createState(d, 2, 14);
365+
const result = runSelectAll(state);
366+
367+
expect(result).toBeNull();
368+
});
369+
370+
it('should fall through when content is fully selected via structural boundaries', () => {
371+
const d = doc(selectContentNode(p('hello'), p('world')));
372+
const state = createState(d, 1, 15);
373+
const result = runSelectAll(state);
374+
375+
expect(result).toBeNull();
376+
});
377+
378+
it('should select all content when only partial text is selected', () => {
379+
const d = doc(selectContentNode(p('hello'), p('world')));
380+
const state = createState(d, 3, 12); // partial selection
381+
const result = runSelectAll(state);
382+
383+
expect(result).toBeTruthy();
384+
expect(result!.selection.from).toBe(1);
385+
expect(result!.selection.to).toBe(15);
386+
});
387+
});
388+
389+
describe('no matching nodes', () => {
390+
it('should return false when cursor is in a regular paragraph', () => {
391+
const d = doc(p('hello'));
392+
const state = createState(d, 3);
393+
const result = runSelectAll(state);
394+
395+
expect(result).toBeNull();
396+
});
397+
398+
it('should return false when cursor is in a blockquote', () => {
399+
const d = doc(bq(p('hello')));
400+
const state = createState(d, 4);
401+
const result = runSelectAll(state);
402+
403+
expect(result).toBeNull();
404+
});
405+
});
406+
});

packages/editor/src/extensions/behavior/Selection/commands.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type {Node, ResolvedPos} from 'prosemirror-model';
22
import type {Command, NodeSelection, TextSelection, Transaction} from 'prosemirror-state';
3-
import {Selection} from 'prosemirror-state';
3+
import {Selection, TextSelection as TextSel} from 'prosemirror-state';
44

5-
import {isCodeBlock} from '../../../utils/nodes';
5+
import {isCodeBlock, isNodeEmpty} from '../../../utils/nodes';
66
import {isNodeSelection, isTextSelection} from '../../../utils/selection';
77
import {GapCursorSelection, isGapCursorSelection} from '../Cursor/GapCursorSelection';
88

@@ -186,3 +186,42 @@ export const backspace: Command = (state, dispatch) => {
186186
}
187187
return false;
188188
};
189+
190+
function hasContentToSelect(node: Node): boolean {
191+
if (node.isTextblock) return node.content.size > 0;
192+
return !isNodeEmpty(node);
193+
}
194+
195+
export const selectAll: Command = (state, dispatch) => {
196+
const {selection} = state;
197+
const {$from, $to} = selection;
198+
const sharedDepth = $from.sharedDepth($to.pos);
199+
200+
for (let depth = sharedDepth; depth > 0; depth--) {
201+
const node = $from.node(depth);
202+
const {spec} = node.type;
203+
if (spec.selectContent === false) continue;
204+
if (!spec.code && !spec.selectContent) continue;
205+
206+
if (!hasContentToSelect(node)) continue;
207+
208+
const start = $from.start(depth);
209+
const end = start + node.content.size;
210+
211+
const startCursor = Selection.findFrom(state.doc.resolve(start), 1);
212+
const endCursor = Selection.findFrom(state.doc.resolve(end), -1);
213+
214+
if (
215+
startCursor &&
216+
endCursor &&
217+
selection.from <= startCursor.from &&
218+
selection.to >= endCursor.to
219+
)
220+
continue;
221+
222+
dispatch?.(state.tr.setSelection(TextSel.create(state.doc, start, end)));
223+
return true;
224+
}
225+
226+
return false;
227+
};

packages/editor/src/extensions/behavior/Selection/selection.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {Decoration, DecorationSet, type EditorView} from 'prosemirror-view';
1515
import {isSelectableNode} from '../../../utils/nodes';
1616
import {isNodeSelection} from '../../../utils/selection';
1717

18-
import {arrowDown, arrowLeft, arrowRight, arrowUp, backspace} from './commands';
18+
import {arrowDown, arrowLeft, arrowRight, arrowUp, backspace, selectAll} from './commands';
1919

2020
import './selection.scss';
2121

@@ -28,6 +28,7 @@ export const selection = () =>
2828
ArrowUp: arrowUp,
2929
ArrowDown: arrowDown,
3030
Backspace: backspace,
31+
'Mod-a': selectAll,
3132
}),
3233
decorations(state) {
3334
return getDecorations(state.tr);
@@ -53,6 +54,7 @@ export const selection = () =>
5354
declare module 'prosemirror-model' {
5455
interface NodeSpec {
5556
allowSelection?: boolean | undefined;
57+
selectContent?: boolean | undefined;
5658
}
5759
}
5860

packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export const getSchemaSpecs = (
9292
},
9393
tableRole: TableRole.Cell,
9494
selectable: false,
95+
selectContent: true,
9596
allowSelection: false,
9697
complex: 'leaf',
9798
},

0 commit comments

Comments
 (0)