Skip to content
Open
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 @@ -62,6 +62,7 @@ type CoreCommandNames =
| 'backspaceNextToRun'
| 'backspaceAcrossRuns'
| 'backspaceAtomBefore'
| 'selectInlineSdtBeforeRunStart'
| 'deleteBlockSdtAtTextBlockStart'
| 'deleteSkipEmptyRun'
| 'deleteNextToRun'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { describe, expect, it } from 'vitest';

describe('core command map types', () => {
it('lists inline SDT Backspace selection in CoreCommandNames', () => {
const declarationPath = join(dirname(fileURLToPath(import.meta.url)), 'core-command-map.d.ts');
const declaration = readFileSync(declarationPath, 'utf8');

expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export * from './backspaceSkipEmptyRun.js';
export * from './backspaceNextToRun.js';
export * from './backspaceAcrossRuns.js';
export * from './backspaceAtomBefore.js';
export * from './selectInlineSdtBeforeRunStart.js';
export * from './deleteBlockSdtAtTextBlockStart.js';
export * from './deleteSkipEmptyRun.js';
export * from './deleteNextToRun.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NodeSelection } from 'prosemirror-state';

export const SELECT_INLINE_SDT_BEFORE_RUN_START_META = 'selectInlineSdtBeforeRunStart';

function blocksWrapperDelete(node) {
return node.attrs.lockMode === 'sdtLocked' || node.attrs.lockMode === 'sdtContentLocked';
}

/**
* Selects an inline SDT wrapper when Backspace is pressed at the start of the
* following run. Without this, run-aware Backspace scans into the SDT content.
*
* @returns {import('@core/commands/types').Command}
*/
export const selectInlineSdtBeforeRunStart =
() =>
({ state, dispatch }) => {
const { selection } = state;
if (!selection.empty) return false;

const { $from } = selection;
if ($from.parent.type.name !== 'run') return false;
if ($from.parentOffset !== 0) return false;

const runStart = $from.before($from.depth);
const previousSibling = state.doc.resolve(runStart).nodeBefore;
if (previousSibling?.type.name !== 'structuredContent') return false;

if (blocksWrapperDelete(previousSibling)) return true;

if (dispatch) {
dispatch(
state.tr
.setMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META, true)
.setSelection(NodeSelection.create(state.doc, runStart - previousSibling.nodeSize)),
);
}

return true;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi } from 'vitest';
import { Schema } from 'prosemirror-model';
import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
import { selectInlineSdtBeforeRunStart } from './selectInlineSdtBeforeRunStart.js';

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

const makeDoc = (schema, lockMode = 'contentLocked') => {
const sdtRun = schema.nodes.run.create(null, schema.text('Locked content'));
const sdt = schema.nodes.structuredContent.create({ lockMode }, sdtRun);
const followingRun = schema.nodes.run.create(null, schema.text('Adding text'));
return schema.node('doc', null, [schema.node('paragraph', null, [sdt, followingRun])]);
};

const findNode = (doc, typeName, predicate = () => true) => {
let result = null;
doc.descendants((node, pos) => {
if (node.type.name === typeName && predicate(node)) {
result = { node, pos, end: pos + node.nodeSize };
return false;
}
return true;
});
return result;
};

describe('selectInlineSdtBeforeRunStart', () => {
it.each(['unlocked', 'contentLocked'])('selects the %s inline SDT wrapper before the current run', (lockMode) => {
const schema = makeSchema();
const doc = makeDoc(schema, lockMode);
const sdt = findNode(doc, 'structuredContent');
const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding'));
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, followingRun.pos + 1),
});

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

expect(ok).toBe(true);
expect(dispatched).toBeDefined();
expect(dispatched.selection).toBeInstanceOf(NodeSelection);
expect(dispatched.selection.from).toBe(sdt.pos);
expect(dispatched.selection.to).toBe(sdt.end);
});

it.each(['sdtLocked', 'sdtContentLocked'])('consumes Backspace without selecting %s wrappers', (lockMode) => {
const schema = makeSchema();
const doc = makeDoc(schema, lockMode);
const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding'));
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, followingRun.pos + 1),
});
const dispatch = vi.fn();

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

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

it('returns false when the cursor is not at the start of a run', () => {
const schema = makeSchema();
const doc = makeDoc(schema);
const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding'));
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, followingRun.pos + 2),
});
const dispatch = vi.fn();

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

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

it('returns false when the previous sibling is not an inline SDT', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.nodes.run.create(null, schema.text('Before')),
schema.nodes.run.create(null, schema.text('Adding text')),
]),
]);
const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding'));
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, followingRun.pos + 1),
});
const dispatch = vi.fn();

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

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('handleBackspace chain ordering', () => {
const commands = {
undoInputRule: make('undoInputRule'),
deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'),
selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'),
backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'),
backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'),
backspaceAtomBefore: make('backspaceAtomBefore'),
Expand Down Expand Up @@ -73,6 +74,7 @@ describe('handleBackspace chain ordering', () => {
'undoInputRule',
// step 2 sets inputType meta and returns false (no command call)
'deleteBlockSdtAtTextBlockStart',
'selectInlineSdtBeforeRunStart',
'backspaceEmptyRunParagraph',
'backspaceSkipEmptyRun',
'backspaceAtomBefore',
Expand Down Expand Up @@ -102,17 +104,21 @@ describe('handleBackspace chain ordering', () => {
// walk (undoInputRule at 0, meta-setter at 1, then SDT at 2).
expect(callLog[0]).toBe('undoInputRule');
expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart');
expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart');
});

it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => {
const { editor, callLog } = makeEditor();
handleBackspace(editor);

const acrossRunsIndex = callLog.indexOf('backspaceAcrossRuns');
const inlineSdtIndex = callLog.indexOf('selectInlineSdtBeforeRunStart');
const mixedIndex = callLog.indexOf('mixedBidiBackspace');
const deleteSelectionIndex = callLog.indexOf('deleteSelection');

expect(inlineSdtIndex).toBeGreaterThanOrEqual(0);
expect(acrossRunsIndex).toBeGreaterThanOrEqual(0);
expect(acrossRunsIndex).toBeGreaterThan(inlineSdtIndex);
expect(mixedIndex).toBeGreaterThan(acrossRunsIndex);
expect(deleteSelectionIndex).toBeGreaterThan(mixedIndex);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const handleBackspace = (editor) => {
return false;
},
() => commands.deleteBlockSdtAtTextBlockStart(),
() => commands.selectInlineSdtBeforeRunStart(),
() => commands.backspaceEmptyRunParagraph(),
() => commands.backspaceSkipEmptyRun(),
() => commands.backspaceAtomBefore(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
import { Slice } from 'prosemirror-model';
import { ySyncPluginKey } from 'y-prosemirror';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { handleBackspace } from '@core/extensions/keymap.js';
import { STRUCTURED_CONTENT_LOCK_KEY } from './structured-content-lock-plugin.js';

/**
Expand Down Expand Up @@ -504,6 +505,40 @@ describe('StructuredContentLockPlugin', () => {
const finalState = afterSelectState.apply(deletionTr);
expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false);
});

it('contentLocked + Backspace at the start of the following run selects, then deletes the SDT wrapper', () => {
const sdtRun = schema.nodes.run.create(null, schema.text('Locked content'));
const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'contentLocked' }, sdtRun);
const followingRun = schema.nodes.run.create(null, schema.text('Adding some additional text here.'));
const paragraph = schema.nodes.paragraph.create(null, [sdt, followingRun]);
const doc = schema.nodes.doc.create(null, [paragraph]);
const state = applyDocToEditor(doc);
const sdtInfo = findSDTNode(state.doc, 'structuredContent');

let followingRunPos = null;
state.doc.descendants((node, pos) => {
if (node.type.name === 'run' && node.textContent.startsWith('Adding')) {
followingRunPos = pos;
return false;
}
return true;
});
expect(followingRunPos).not.toBeNull();

const caretBeforeAdding = followingRunPos + 1;
placeCaretAt(state, caretBeforeAdding);

handleBackspace(editor);

const selection = editor.state.selection;
expect(selection).toBeInstanceOf(NodeSelection);
expect(selection.from).toBe(sdtInfo.pos);
expect(selection.to).toBe(sdtInfo.end);

handleBackspace(editor);

expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false);
});
});

describe('Path 1 — selection covers SDT content (triple-click / first-click select-all)', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Plugin, TextSelection } from 'prosemirror-state';

import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js';
import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js';

const INLINE_LEAF_TEXT = '\ufffc';

Expand Down Expand Up @@ -93,6 +94,8 @@ export function createStructuredContentSelectPlugin(editor) {
if (transactions.some((tr) => tr.docChanged)) return null;

if (!selection.empty) {
if (transactions.some((tr) => tr.getMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META))) return null;

let selectedSdt = null;
newState.doc.descendants((node, pos) => {
if (node.type.name !== 'structuredContent') return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js';

function findNode(doc, nodeType) {
let result = null;
Expand Down Expand Up @@ -68,6 +69,28 @@ describe('StructuredContentSelectPlugin', () => {
expect(editor.state.selection.to).toBe(contentTo);
});

it('keeps Backspace-created inline SDT node selections when the meta escape is set', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
applyDoc(schema.nodes.doc.create(null, [paragraph]));

const sdt = findNode(editor.state.doc, 'structuredContent');
expect(sdt).not.toBeNull();

const afterSdt = sdt.pos + sdt.node.nodeSize;
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterSdt)));

editor.view.dispatch(
editor.state.tr
.setMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META, true)
.setSelection(NodeSelection.create(editor.state.doc, sdt.pos)),
);

expect(editor.state.selection).toBeInstanceOf(NodeSelection);
expect(editor.state.selection.from).toBe(sdt.pos);
expect(editor.state.selection.to).toBe(sdt.pos + sdt.node.nodeSize);
});

it('does not auto-select inline SDT content in viewing mode', () => {
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
Expand Down
Loading