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'
| 'deleteBlockSdtAtTextBlockStart'
| 'deleteSkipEmptyRun'
| 'deleteNextToRun'
| 'deleteAtomAfter'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Selection } from 'prosemirror-state';

function isSdtContentFullyLocked(node) {
return node.attrs.lockMode === 'sdtContentLocked';
}

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

/**
* Deletes the block SDT wrapper from the start of its first paragraph.
*
* @returns {import('@core/commands/types').Command}
*/
export const deleteBlockSdtAtTextBlockStart =
() =>
({ state, dispatch }) => {
const { selection } = state;
if (!selection.empty) return false;

const { $from } = selection;
const sdtDepth = findAncestorDepth($from, (node) => node.type.name === 'structuredContentBlock');
if (sdtDepth == null) return false;

const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock);
if (textblockDepth !== sdtDepth + 1) return false;
if ($from.node(textblockDepth).type.name !== 'paragraph') return false;
if ($from.pos !== $from.start(textblockDepth)) return false;
if ($from.before(textblockDepth) !== $from.start(sdtDepth)) return false;

const sdtNode = $from.node(sdtDepth);
const lockMode = sdtNode.attrs.lockMode;
// Wrapper deletion is blocked for sdtLocked / sdtContentLocked (see createStructuredContentLockPlugin).
// For sdtLocked, content edits must still work — returning true here consumed Delete without
// dispatching, so the first character of the first paragraph was undeletable at this caret.
if (lockMode === 'sdtLocked') return false;
if (isSdtContentFullyLocked(sdtNode)) return true;

if (dispatch) {
const from = $from.before(sdtDepth);
const tr = state.tr.delete(from, from + sdtNode.nodeSize);
const selectionPos = Math.min(from, tr.doc.content.size);
dispatch(tr.setSelection(Selection.near(tr.doc.resolve(selectionPos), -1)).scrollIntoView());
}

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

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

const makeDoc = (schema, lockMode = 'unlocked', sdtContent = [schema.node('paragraph', null, schema.text('Inner'))]) =>
schema.node('doc', null, [
schema.node('paragraph', null, schema.text('Before')),
schema.node('structuredContentBlock', { lockMode }, sdtContent),
schema.node('paragraph', null, schema.text('After')),
]);

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

const paragraphStartInSdt = (doc, index = 0) => {
const sdt = findBlockSdt(doc);
expect(sdt).not.toBeNull();

let seen = 0;
let start = null;
sdt.node.descendants((node, offset) => {
if (node.type.name !== 'paragraph') return true;
if (seen === index) {
start = sdt.pos + 1 + offset + 1;
return false;
}
seen += 1;
return true;
});

expect(start).not.toBeNull();
return start;
};

describe('deleteBlockSdtAtTextBlockStart', () => {
it('deletes an unlocked block SDT when the caret is at the start of its first paragraph', () => {
const schema = makeSchema();
const doc = makeDoc(schema);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) });

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

expect(ok).toBe(true);
expect(dispatched).toBeDefined();
expect(findBlockSdt(dispatched.doc)).toBeNull();
expect(dispatched.doc.textContent).toBe('BeforeAfter');
});

it('returns false for sdtLocked so Delete can fall through for in-SDT content edits', () => {
const schema = makeSchema();
const doc = makeDoc(schema, 'sdtLocked');
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) });
const dispatch = vi.fn();

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

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

it('consumes sdtContentLocked block SDT wrapper delete without dispatching', () => {
const schema = makeSchema();
const doc = makeDoc(schema, 'sdtContentLocked');
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) });
const dispatch = vi.fn();

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

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

it('returns false when the caret is not at the paragraph start', () => {
const schema = makeSchema();
const doc = makeDoc(schema);
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, paragraphStartInSdt(doc) + 1),
});
const dispatch = vi.fn();

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

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

it('returns false from later paragraphs inside the same block SDT', () => {
const schema = makeSchema();
const doc = makeDoc(schema, 'unlocked', [
schema.node('paragraph', null, schema.text('First')),
schema.node('paragraph', null, schema.text('Second')),
]);
const state = EditorState.create({
schema,
doc,
selection: TextSelection.create(doc, paragraphStartInSdt(doc, 1)),
});
const dispatch = vi.fn();

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

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

it('returns false outside a block SDT', () => {
const schema = makeSchema();
const doc = makeDoc(schema);
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1) });
const dispatch = vi.fn();

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

expect(ok).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export * from './backspaceSkipEmptyRun.js';
export * from './backspaceNextToRun.js';
export * from './backspaceAcrossRuns.js';
export * from './backspaceAtomBefore.js';
export * from './deleteBlockSdtAtTextBlockStart.js';
export * from './deleteSkipEmptyRun.js';
export * from './deleteNextToRun.js';
export * from './deleteAtomAfter.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const handleBackspace = (editor) => {
tr.setMeta('inputType', 'deleteContentBackward');
return false;
},
() => commands.deleteBlockSdtAtTextBlockStart(),
() => commands.backspaceEmptyRunParagraph(),
() => commands.backspaceSkipEmptyRun(),
() => commands.backspaceAtomBefore(),
Expand All @@ -55,6 +56,7 @@ export const handleDelete = (editor) => {
dispatchHistoryBoundary(view);

return editor.commands.first(({ commands }) => [
() => commands.deleteBlockSdtAtTextBlockStart(),
() => commands.deleteSkipEmptyRun(),
() => commands.deleteAtomAfter(),
() => commands.deleteNextToRun(),
Expand Down
Loading