Skip to content
Draft
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 @@ -27,6 +27,7 @@ export const MathSpecs: ExtensionAuto = (builder) => {
['span', {class: CLASSNAMES.Inline.Sharp, contenteditable: 'false'}, '$'],
],
parseDOM: [{tag: `span.${CLASSNAMES.Inline.Content}`, priority: 200}],
selectContent: false,
},
fromMd: {
tokenName: 'math_inline',
Expand Down
157 changes: 154 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', selectContent: true},
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,139 @@ 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 "ell"
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 content of selectContent node with non-empty paragraph', () => {
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(1);
expect(result!.selection.to).toBe(7);
});
});

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 all content 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(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('no matching nodes', () => {
it('should return false when cursor is in a regular paragraph', () => {
const d = doc(p('hello'));
const state = createState(d, 3);
const result = runSelectAll(state);

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

it('should return false when cursor is in a blockquote', () => {
const d = doc(bq(p('hello')));
const state = createState(d, 4);
const result = runSelectAll(state);

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

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

Expand Down Expand Up @@ -186,3 +186,42 @@ 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.selectContent === false) continue;
if (!spec.code && !spec.selectContent) continue;

if (!hasContentToSelect(node)) continue;

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;
};
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 @@ -53,6 +54,7 @@ export const selection = () =>
declare module 'prosemirror-model' {
interface NodeSpec {
allowSelection?: boolean | undefined;
selectContent?: boolean | undefined;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const getSchemaSpecs = (
},
tableRole: TableRole.Cell,
selectable: false,
selectContent: true,
allowSelection: false,
complex: 'leaf',
},
Expand Down
Loading