diff --git a/packages/editor/src/extensions/additional/Math/MathSpecs/index.ts b/packages/editor/src/extensions/additional/Math/MathSpecs/index.ts index 968cb75f9..2bcabdbe0 100644 --- a/packages/editor/src/extensions/additional/Math/MathSpecs/index.ts +++ b/packages/editor/src/extensions/additional/Math/MathSpecs/index.ts @@ -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', diff --git a/packages/editor/src/extensions/behavior/Selection/commands.test.ts b/packages/editor/src/extensions/behavior/Selection/commands.test.ts index b59f47d00..a1cf7806f 100644 --- a/packages/editor/src/extensions/behavior/Selection/commands.test.ts +++ b/packages/editor/src/extensions/behavior/Selection/commands.test.ts @@ -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'; @@ -13,6 +13,7 @@ import { type Direction, findFakeParaPosForTextSelection, findNextFakeParaPosForGapCursorSelection, + selectAll, } from './commands'; const {schema} = new ExtensionsManager({ @@ -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}, @@ -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: hello 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(); + }); + }); +}); diff --git a/packages/editor/src/extensions/behavior/Selection/commands.ts b/packages/editor/src/extensions/behavior/Selection/commands.ts index 93cef1c88..508da5a86 100644 --- a/packages/editor/src/extensions/behavior/Selection/commands.ts +++ b/packages/editor/src/extensions/behavior/Selection/commands.ts @@ -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'; @@ -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; +}; diff --git a/packages/editor/src/extensions/behavior/Selection/selection.ts b/packages/editor/src/extensions/behavior/Selection/selection.ts index 36746d176..22a8d74cd 100644 --- a/packages/editor/src/extensions/behavior/Selection/selection.ts +++ b/packages/editor/src/extensions/behavior/Selection/selection.ts @@ -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'; @@ -28,6 +28,7 @@ export const selection = () => ArrowUp: arrowUp, ArrowDown: arrowDown, Backspace: backspace, + 'Mod-a': selectAll, }), decorations(state) { return getDecorations(state.tr); @@ -53,6 +54,7 @@ export const selection = () => declare module 'prosemirror-model' { interface NodeSpec { allowSelection?: boolean | undefined; + selectContent?: boolean | undefined; } } diff --git a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts index 91c542f34..0d0c1c19e 100644 --- a/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts +++ b/packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts @@ -92,6 +92,7 @@ export const getSchemaSpecs = ( }, tableRole: TableRole.Cell, selectable: false, + selectContent: true, allowSelection: false, complex: 'leaf', },