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 @@ -64,6 +64,8 @@ type CoreCommandNames =
| 'backspaceAtomBefore'
| 'selectInlineSdtBeforeRunStart'
| 'selectInlineSdtAfterRunEnd'
| 'selectBlockSdtBeforeTextBlockStart'
| 'selectBlockSdtAfterTextBlockEnd'
| 'deleteBlockSdtAtTextBlockStart'
| 'moveIntoBlockSdtBeforeTextBlockStart'
| 'moveIntoBlockSdtAfterTextBlockEnd'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ describe('core command map types', () => {

expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'");
expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'");
expect(declaration).toContain("| 'selectBlockSdtBeforeTextBlockStart'");
expect(declaration).toContain("| 'selectBlockSdtAfterTextBlockEnd'");
expect(declaration).toContain("| 'moveIntoBlockSdtBeforeTextBlockStart'");
expect(declaration).toContain("| 'moveIntoBlockSdtAfterTextBlockEnd'");
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export * from './backspaceNextToRun.js';
export * from './backspaceAcrossRuns.js';
export * from './backspaceAtomBefore.js';
export * from './selectInlineSdtBeforeRunStart.js';
export * from './selectBlockSdtAtTextBlockBoundary.js';
export * from './deleteBlockSdtAtTextBlockStart.js';
export * from './moveIntoBlockSdtBeforeTextBlockStart.js';
export * from './moveIntoBlockSdtAfterTextBlockEnd.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { TextSelection } from 'prosemirror-state';
import {
findFirstContentCursorPosInNode,
findLastContentCursorPosInNode,
isZeroWidthMarker,
} from './helpers/textPositions.js';

function findAncestorDepth($pos, predicate) {
for (let depth = $pos.depth; depth > 0; depth -= 1) {
if (predicate($pos.node(depth))) return depth;
}
return null;
}

function findSiblingAcrossHiddenMarkers(doc, pos, direction) {
let currentPos = pos;
let node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter;

while (node && isZeroWidthMarker(node)) {
currentPos += direction === 'before' ? -node.nodeSize : node.nodeSize;
node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter;
}

return {
node,
nodePos: direction === 'before' && node ? currentPos - node.nodeSize : currentPos,
};
}

function isAtTextBlockBoundary($from, direction) {
const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock);
if (textblockDepth == null) return null;

const textblock = $from.node(textblockDepth);
const textblockPos = $from.before(textblockDepth);
const boundary =
direction === 'before'
? (findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth))
: (findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth));
if ($from.pos !== boundary) return null;

return {
textblockDepth,
textblockPos,
};
}

function selectAdjacentBlockSdtContent(direction) {
return ({ state, dispatch }) => {
const { selection } = state;
if (!selection.empty) return false;

const boundary = isAtTextBlockBoundary(selection.$from, direction);
if (!boundary) return false;

const siblingBoundaryPos =
direction === 'before' ? boundary.textblockPos : selection.$from.after(boundary.textblockDepth);
const { node, nodePos } = findSiblingAcrossHiddenMarkers(state.doc, siblingBoundaryPos, direction);
if (node?.type.name !== 'structuredContentBlock') return false;
if (node.content.size === 0) return false;

const contentStart = findFirstContentCursorPosInNode(node, nodePos);
const contentEnd = findLastContentCursorPosInNode(node, nodePos);
if (contentStart == null || contentEnd == null) return false;
Comment thread
luccas-harbour marked this conversation as resolved.
if (contentStart >= contentEnd) return false;

if (dispatch) {
dispatch(state.tr.setSelection(TextSelection.create(state.doc, contentStart, contentEnd)).scrollIntoView());
}

return true;
};
}

/**
* Selects previous block SDT content when Backspace is pressed at the start of
* the following textblock.
*
* @returns {import('@core/commands/types').Command}
*/
export const selectBlockSdtBeforeTextBlockStart = () => selectAdjacentBlockSdtContent('before');

/**
* Selects next block SDT content when Delete is pressed at the end of the
* preceding textblock.
*
* @returns {import('@core/commands/types').Command}
*/
export const selectBlockSdtAfterTextBlockEnd = () => selectAdjacentBlockSdtContent('after');
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { describe, it, expect, vi } from 'vitest';
import { Schema } from 'prosemirror-model';
import { EditorState, TextSelection } from 'prosemirror-state';
import {
selectBlockSdtAfterTextBlockEnd,
selectBlockSdtBeforeTextBlockStart,
} from './selectBlockSdtAtTextBlockBoundary.js';
import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js';

const makeSchema = () =>
new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
run: { inline: true, group: 'inline', content: 'inline*' },
structuredContentBlock: {
group: 'block',
content: 'block*',
isolating: true,
attrs: {
lockMode: { default: 'unlocked' },
},
},
mathBlock: { group: 'block', atom: true },
image: { inline: true, group: 'inline', atom: true },
text: { group: 'inline' },
},
marks: {},
});

const run = (schema, text) => schema.nodes.run.create(null, schema.text(text));
const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text));

const findTextPos = (doc, text, offset = 0) => {
let found = null;
doc.descendants((node, pos) => {
if (!node.isText || found != null) return found == null;
const index = node.text.indexOf(text);
if (index === -1) return true;
found = pos + index + offset;
return false;
});
expect(found).not.toBeNull();
return found;
};

const findBlockSdt = (doc) => {
let result = null;
doc.descendants((node, pos) => {
if (node.type.name !== 'structuredContentBlock') return true;
result = { node, pos, end: pos + node.nodeSize };
return false;
});
expect(result).not.toBeNull();
return result;
};

const findBlockSdtByText = (doc, text) => {
let result = null;
doc.descendants((node, pos) => {
if (node.type.name !== 'structuredContentBlock' || node.textContent !== text) return true;
result = { node, pos, end: pos + node.nodeSize };
return false;
});
expect(result).not.toBeNull();
return result;
};

const makeDoc = (schema, lockMode = 'contentLocked') => {
const imageRun = schema.nodes.run.create(null, schema.nodes.image.create());
const sdt = schema.nodes.structuredContentBlock.create({ lockMode }, [
paragraph(schema, 'Inner text'),
schema.nodes.paragraph.create(null, imageRun),
]);
return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]);
};

const makeNestedDoc = (schema, lockMode = 'contentLocked') => {
const innerSdt = schema.nodes.structuredContentBlock.create({ lockMode }, [paragraph(schema, 'Nested text')]);
const outerSdt = schema.nodes.structuredContentBlock.create({ lockMode: 'unlocked' }, [
paragraph(schema, 'Outer before'),
innerSdt,
paragraph(schema, 'Outer after'),
]);
return schema.node('doc', null, [paragraph(schema, 'Before'), outerSdt, paragraph(schema, 'After')]);
};

const makeEmptyBlockSdtDoc = (schema) => {
const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [
schema.nodes.paragraph.create(),
]);
return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]);
};

const makeAtomBlockSdtDoc = (schema) => {
const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [
schema.nodes.mathBlock.create(),
]);
return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]);
};

describe('selectBlockSdtBeforeTextBlockStart', () => {
it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])(
'selects the previous %s block SDT content from the following textblock start',
(lockMode) => {
const schema = makeSchema();
const doc = makeDoc(schema, lockMode);
const sdt = findBlockSdt(doc);
const afterStart = findTextPos(doc, 'After');
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) });

let dispatched;
const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) });

expect(ok).toBe(true);
expect(dispatched).toBeDefined();
expect(dispatched.selection).toBeInstanceOf(TextSelection);
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos));
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos));
},
);

it('returns false away from the following textblock start', () => {
const schema = makeSchema();
const doc = makeDoc(schema);
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, findTextPos(doc, 'After', 1)),
});
const dispatch = vi.fn();

const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch });

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it.each([
['empty paragraph', makeEmptyBlockSdtDoc],
['block atom', makeAtomBlockSdtDoc],
])('returns false for previous block SDT with %s content', (_, makeAdjacentDoc) => {
const schema = makeSchema();
const doc = makeAdjacentDoc(schema);
const afterStart = findTextPos(doc, 'After');
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) });
const dispatch = vi.fn();

const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch });

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it('selects nested previous block SDT content from the following nested textblock start', () => {
const schema = makeSchema();
const doc = makeNestedDoc(schema);
const nestedSdt = findBlockSdtByText(doc, 'Nested text');
const outerAfterStart = findTextPos(doc, 'Outer after');
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerAfterStart) });

let dispatched;
const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) });

expect(ok).toBe(true);
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
});
});

describe('selectBlockSdtAfterTextBlockEnd', () => {
it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])(
'selects the next %s block SDT content from the preceding textblock end',
(lockMode) => {
const schema = makeSchema();
const doc = makeDoc(schema, lockMode);
const sdt = findBlockSdt(doc);
const beforeEnd = findTextPos(doc, 'Before', 'Before'.length);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) });

let dispatched;
const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) });

expect(ok).toBe(true);
expect(dispatched).toBeDefined();
expect(dispatched.selection).toBeInstanceOf(TextSelection);
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos));
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos));
},
);

it.each([
['empty paragraph', makeEmptyBlockSdtDoc],
['block atom', makeAtomBlockSdtDoc],
])('returns false for next block SDT with %s content', (_, makeAdjacentDoc) => {
const schema = makeSchema();
const doc = makeAdjacentDoc(schema);
const beforeEnd = findTextPos(doc, 'Before', 'Before'.length);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) });
const dispatch = vi.fn();

const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch });

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it('selects nested next block SDT content from the preceding nested textblock end', () => {
const schema = makeSchema();
const doc = makeNestedDoc(schema);
const nestedSdt = findBlockSdtByText(doc, 'Nested text');
const outerBeforeEnd = findTextPos(doc, 'Outer before', 'Outer before'.length);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerBeforeEnd) });

let dispatched;
const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) });

expect(ok).toBe(true);
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('handleBackspace chain ordering', () => {
undoInputRule: make('undoInputRule'),
deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'),
selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'),
selectBlockSdtBeforeTextBlockStart: make('selectBlockSdtBeforeTextBlockStart'),
moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'),
backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'),
backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'),
Expand Down Expand Up @@ -76,6 +77,7 @@ describe('handleBackspace chain ordering', () => {
// step 2 sets inputType meta and returns false (no command call)
'deleteBlockSdtAtTextBlockStart',
'selectInlineSdtBeforeRunStart',
'selectBlockSdtBeforeTextBlockStart',
'moveIntoBlockSdtBeforeTextBlockStart',
'backspaceEmptyRunParagraph',
'backspaceSkipEmptyRun',
Expand Down Expand Up @@ -107,7 +109,8 @@ describe('handleBackspace chain ordering', () => {
expect(callLog[0]).toBe('undoInputRule');
expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart');
expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart');
expect(callLog[3]).toBe('moveIntoBlockSdtBeforeTextBlockStart');
expect(callLog[3]).toBe('selectBlockSdtBeforeTextBlockStart');
expect(callLog[4]).toBe('moveIntoBlockSdtBeforeTextBlockStart');
});

it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => {
Expand Down Expand Up @@ -179,6 +182,7 @@ describe('handleDelete chain ordering', () => {
const commands = {
deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'),
selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'),
selectBlockSdtAfterTextBlockEnd: make('selectBlockSdtAfterTextBlockEnd'),
moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'),
deleteSkipEmptyRun: make('deleteSkipEmptyRun'),
deleteAtomAfter: make('deleteAtomAfter'),
Expand Down Expand Up @@ -212,6 +216,7 @@ describe('handleDelete chain ordering', () => {
expect(callLog).toEqual([
'deleteBlockSdtAtTextBlockStart',
'selectInlineSdtAfterRunEnd',
'selectBlockSdtAfterTextBlockEnd',
'moveIntoBlockSdtAfterTextBlockEnd',
'deleteSkipEmptyRun',
'deleteAtomAfter',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const handleBackspace = (editor) => {
},
() => commands.deleteBlockSdtAtTextBlockStart(),
() => commands.selectInlineSdtBeforeRunStart(),
() => commands.selectBlockSdtBeforeTextBlockStart(),
() => commands.moveIntoBlockSdtBeforeTextBlockStart(),
() => commands.backspaceEmptyRunParagraph(),
() => commands.backspaceSkipEmptyRun(),
Expand All @@ -61,6 +62,7 @@ export const handleDelete = (editor) => {
return editor.commands.first(({ commands }) => [
() => commands.deleteBlockSdtAtTextBlockStart(),
() => commands.selectInlineSdtAfterRunEnd(),
() => commands.selectBlockSdtAfterTextBlockEnd(),
() => commands.moveIntoBlockSdtAfterTextBlockEnd(),
() => commands.deleteSkipEmptyRun(),
() => commands.deleteAtomAfter(),
Expand Down
Loading
Loading