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
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const QuoteLinkSpecs: ExtensionAuto = (builder) => {
return ['blockquote', node.attrs, 0];
},
selectable: true,
selectAll: 'node',
},
fromMd: {
tokenSpec: {
Expand Down
210 changes: 207 additions & 3 deletions packages/editor/src/extensions/behavior/Selection/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {Node} from 'prosemirror-model';
import {TextSelection} from 'prosemirror-state';
import {EditorState, TextSelection} from 'prosemirror-state';
import {builders} from 'prosemirror-test-builder';

import {ExtensionsManager} from '../../../core';
Expand All @@ -13,6 +13,7 @@ import {
type Direction,
findFakeParaPosForTextSelection,
findNextFakeParaPosForGapCursorSelection,
selectAll,
} from './commands';

const {schema} = new ExtensionsManager({
Expand All @@ -26,11 +27,25 @@ const {schema} = new ExtensionsManager({
spec: {content: `block*`, group: 'block', gapcursor: false},
fromMd: {tokenSpec: {name: 'testnode', type: 'block', ignore: true}},
toMd: () => {},
}))
.addNode('selectContentNode', () => ({
spec: {content: `block+`, group: 'block', selectAll: 'content'},
fromMd: {tokenSpec: {name: 'selectContentNode', type: 'block', ignore: true}},
toMd: () => {},
})),
}).buildDeps();

const {doc, p, bq, codeBlock, table, tbody, tr, td, testnode} = builders<
'doc' | 'p' | 'bq' | 'codeBlock' | 'table' | 'tbody' | 'tr' | 'td' | 'testnode'
const {doc, p, bq, codeBlock, table, tbody, tr, td, testnode, selectContentNode} = builders<
| 'doc'
| 'p'
| 'bq'
| 'codeBlock'
| 'table'
| 'tbody'
| 'tr'
| 'td'
| 'testnode'
| 'selectContentNode'
>(schema, {
doc: {nodeType: BaseNode.Doc},
p: {nodeType: BaseNode.Paragraph},
Expand Down Expand Up @@ -253,3 +268,192 @@ describe('Selection arrow commands: findFakeParaPosForTextSelection', () => {
},
);
});

describe('selectAll', () => {
function createState(document: Node, from: number, to?: number) {
return EditorState.create({
doc: document,
selection: TextSelection.create(document, from, to ?? from),
});
}

function runSelectAll(state: EditorState): EditorState | null {
let newState: EditorState | null = null;
selectAll(state, (tr) => {
newState = state.apply(tr);
});
return newState;
}

describe('code block (spec.code)', () => {
it('should select all content inside code block when cursor is inside', () => {
// doc: <cb>hello</cb> positions: 0[cb]1 h e l l o 6[/cb]7
const d = doc(codeBlock('hello'));
const state = createState(d, 3); // cursor in the middle of "hello"
const result = runSelectAll(state);

expect(result).toBeTruthy();
expect(result!.selection.from).toBe(1);
expect(result!.selection.to).toBe(6);
});

it('should return false when entire code block content is already selected', () => {
const d = doc(codeBlock('hello'));
const state = createState(d, 1, 6); // entire content selected
const result = runSelectAll(state);

expect(result).toBeNull();
});

it('should select code block content when partial selection exists', () => {
const d = doc(codeBlock('hello'));
const state = createState(d, 2, 4); // partial selection "el"
const result = runSelectAll(state);

expect(result).toBeTruthy();
expect(result!.selection.from).toBe(1);
expect(result!.selection.to).toBe(6);
});
});

describe('empty content', () => {
it('should skip empty code block', () => {
const d = doc(codeBlock());
const state = createState(d, 1); // cursor in empty code block
const result = runSelectAll(state);

expect(result).toBeNull();
});

it('should skip selectContent node with empty paragraph', () => {
const d = doc(selectContentNode(p()));
const state = createState(d, 2); // cursor in empty paragraph
const result = runSelectAll(state);

expect(result).toBeNull();
});

it('should select paragraph content first inside selectContent node', () => {
const d = doc(selectContentNode(p('text')));
const state = createState(d, 3); // cursor in "text"
const result = runSelectAll(state);

expect(result).toBeTruthy();
expect(result!.selection.from).toBe(2);
expect(result!.selection.to).toBe(6);
});
});

describe('selectContent with multiple paragraphs', () => {
// selectContentNode(p('hello'), p('world'))
// positions: 0[scn]1[p]2 hello 7[/p]8[p]9 world 14[/p]15[/scn]16

it('should select paragraph content first when cursor is inside', () => {
const d = doc(selectContentNode(p('hello'), p('world')));
const state = createState(d, 4); // cursor in "hello"
const result = runSelectAll(state);

expect(result).toBeTruthy();
expect(result!.selection.from).toBe(2);
expect(result!.selection.to).toBe(7);
});

it('should select all content after paragraph content is selected', () => {
const d = doc(selectContentNode(p('hello'), p('world')));
const state = createState(d, 2, 7); // first paragraph content selected
const result = runSelectAll(state);

expect(result).toBeTruthy();
expect(result!.selection.from).toBe(1);
expect(result!.selection.to).toBe(15);
});

it('should fall through when all text is mouse-selected (resolved positions)', () => {
const d = doc(selectContentNode(p('hello'), p('world')));
// mouse selection covers from start of first paragraph text to end of last
const state = createState(d, 2, 14);
const result = runSelectAll(state);

expect(result).toBeNull();
});

it('should fall through when content is fully selected via structural boundaries', () => {
const d = doc(selectContentNode(p('hello'), p('world')));
const state = createState(d, 1, 15);
const result = runSelectAll(state);

expect(result).toBeNull();
});

it('should select all content when only partial text is selected', () => {
const d = doc(selectContentNode(p('hello'), p('world')));
const state = createState(d, 3, 12); // partial selection
const result = runSelectAll(state);

expect(result).toBeTruthy();
expect(result!.selection.from).toBe(1);
expect(result!.selection.to).toBe(15);
});
});

describe('textblock', () => {
it('should select paragraph content when cursor is inside', () => {
const d = doc(p('hello'));
const state = createState(d, 3);
const result = runSelectAll(state);

expect(result).toBeTruthy();
expect(result!.selection.from).toBe(1);
expect(result!.selection.to).toBe(6);
});

it('should return false when paragraph content is already selected', () => {
const d = doc(p('hello'));
const state = createState(d, 1, 6);
const result = runSelectAll(state);

expect(result).toBeNull();
});

it('should skip empty paragraph', () => {
const d = doc(p());
const state = createState(d, 1);
const result = runSelectAll(state);

expect(result).toBeNull();
});
});

describe('selectContent: node', () => {
it('should select paragraph content first, then blockquote', () => {
const d = doc(bq(p('hello')));

// First Cmd+A: select paragraph content
const state1 = createState(d, 4);
const result1 = runSelectAll(state1);

expect(result1).toBeTruthy();
expect(result1!.selection.from).toBe(2);
expect(result1!.selection.to).toBe(7);

// Second Cmd+A: select blockquote as TextSelection
const result2 = runSelectAll(result1!);

expect(result2).toBeTruthy();
expect(result2!.selection).toBeInstanceOf(TextSelection);
expect(result2!.selection.from).toBe(0);
expect(result2!.selection.to).toBe(9);
});

it('should return false when node is already selected', () => {
const d = doc(bq(p('hello')));
const state = EditorState.create({
doc: d,
selection: TextSelection.create(d, 0, d.nodeSize - 2),
});
const result = runSelectAll(state);

expect(result).toBeNull();
});
});
});
72 changes: 68 additions & 4 deletions packages/editor/src/extensions/behavior/Selection/commands.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type {Node, ResolvedPos} from 'prosemirror-model';
import type {Command, NodeSelection, TextSelection, Transaction} from 'prosemirror-state';
import {Selection} from 'prosemirror-state';

import {isCodeBlock} from '../../../utils/nodes';
import {
type Command,
type NodeSelection,
Selection,
TextSelection as TextSel,
TextSelection,
type Transaction,
} from 'prosemirror-state';

import {isCodeBlock, isNodeEmpty} from '../../../utils/nodes';
import {isNodeSelection, isTextSelection} from '../../../utils/selection';
import {GapCursorSelection, isGapCursorSelection} from '../Cursor/GapCursorSelection';
import {hideSelectionMenu} from '../SelectionContext';

export type Direction = 'before' | 'after';
type ArrowDirection = 'up' | 'right' | 'down' | 'left';
Expand Down Expand Up @@ -186,3 +193,60 @@ export const backspace: Command = (state, dispatch) => {
}
return false;
};

function hasContentToSelect(node: Node): boolean {
if (node.isTextblock) return node.content.size > 0;
return !isNodeEmpty(node);
}

export const selectAll: Command = (state, dispatch) => {
const {selection} = state;
const {$from, $to} = selection;
const sharedDepth = $from.sharedDepth($to.pos);

for (let depth = sharedDepth; depth > 0; depth--) {
const node = $from.node(depth);
const {spec} = node.type;

if (spec.selectAll === false) continue;

let mode: 'content' | 'node';
if (spec.selectAll) mode = spec.selectAll;
else if (node.isTextblock || spec.code) mode = 'content';
else continue;

if (!hasContentToSelect(node)) continue;

if (mode === 'node') {
const selAroundNode = TextSelection.create(
state.doc,
$from.before(depth),
$from.after(depth),
);

if (selection.from <= selAroundNode.from && selection.to >= selAroundNode.to) continue;

dispatch?.(hideSelectionMenu(state.tr.setSelection(selAroundNode)));
return true;
}

const start = $from.start(depth);
const end = start + node.content.size;

const startCursor = Selection.findFrom(state.doc.resolve(start), 1);
const endCursor = Selection.findFrom(state.doc.resolve(end), -1);

if (
startCursor &&
endCursor &&
selection.from <= startCursor.from &&
selection.to >= endCursor.to
)
continue;

dispatch?.(state.tr.setSelection(TextSel.create(state.doc, start, end)));
return true;
}

return false;
};
18 changes: 17 additions & 1 deletion packages/editor/src/extensions/behavior/Selection/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {Decoration, DecorationSet, type EditorView} from 'prosemirror-view';
import {isSelectableNode} from '../../../utils/nodes';
import {isNodeSelection} from '../../../utils/selection';

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

import './selection.scss';

Expand All @@ -28,6 +28,7 @@ export const selection = () =>
ArrowUp: arrowUp,
ArrowDown: arrowDown,
Backspace: backspace,
'Mod-a': selectAll,
}),
decorations(state) {
return getDecorations(state.tr);
Expand All @@ -52,7 +53,22 @@ export const selection = () =>

declare module 'prosemirror-model' {
interface NodeSpec {
/**
* Whether clicking directly on this node creates a NodeSelection for it.
* Typically `true` for root complex nodes (tables, cuts, notes)
* and `false` for their inner parts and leaf elements.
*/
allowSelection?: boolean | undefined;
/**
* Controls how this node participates in hierarchical select-all (Cmd+A / Ctrl+A).
* Each press of select-all walks up the node tree and selects the nearest matching ancestor.
*
* - `'content'` — select the node's content (TextSelection over inner content range)
* - `'node'` — select the node itself (NodeSelection)
* - `false` — skip this node during select-all traversal
* - `undefined` — default: textblocks and code nodes select their content, others are skipped
*/
selectAll?: false | 'node' | 'content' | undefined;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const BlockquoteSpecs: ExtensionAuto = (builder) => {
return ['blockquote', 0];
},
selectable: true,
selectAll: 'node',
},
fromMd: {tokenSpec: {name: blockquoteNodeName, type: 'block'}},
toMd: (state, node) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const getSchemaSpecs = (
},
selectable: true,
allowSelection: true,
selectAll: 'node',
complex: 'root',
},

Expand Down Expand Up @@ -65,6 +66,7 @@ export const getSchemaSpecs = (
},
selectable: false,
allowSelection: false,
selectAll: 'node',
complex: 'leaf',
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const schemaSpecs: Record<TableNode, NodeSpec> = {
},
selectable: true,
allowSelection: true,
selectAll: 'node',
tableRole: TableRole.Table,
complex: 'root',
},
Expand Down
Loading
Loading