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
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
],
"scripts": {
"test": "vitest ./packages/superdoc/ ./packages/super-editor/",
"test:cov": "vitest --root ./packages/super-editor --coverage",
"unzip": "bash packages/super-editor/src/tests/helpers/unzip.sh",
"dev": "npm --workspace=@harbour-enterprises/superdoc run dev",
"dev:superdoc": "npm run dev --workspace=packages/superdoc",
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"@vue/test-utils": "^2.4.6",
"postcss-nested": "^6.0.1",
"postcss-nested-import": "^1.3.0",
"prosemirror-test-builder": "^1.1.1",
"tippy.js": "^6.3.7",
"typescript": "^5.7.3",
"vite": "^6.3.5",
Expand Down
107 changes: 107 additions & 0 deletions packages/super-editor/src/core/commands/backspaceNextToList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @ts-check
import { Fragment } from 'prosemirror-model';
import { TextSelection } from 'prosemirror-state';
import { decreaseListIndent } from './decreaseListIndent.js';
import { isList, findNodePosition } from './list-helpers';

/**
* Handle backspace key behavior when the caret is next to a list.
* @returns {Function} A command function to be used in the editor.
*/
export const handleBackspaceNextToList =
() =>
({ state, dispatch, editor }) => {
const { selection, doc } = state;
const { $from } = selection;

if (!selection.empty) return false;
if ($from.parent.type.name !== 'paragraph') return false;
if ($from.parentOffset !== 0) return false; // Only at start of paragraph

/* Case A: caret INSIDE a list */
let depth = $from.depth;
let listDepth = -1;
while (depth > 0) {
const n = $from.node(depth - 1);
if (isList(n)) {
listDepth = depth - 1;
break;
}
depth--;
}

if (listDepth !== -1) {
// We are inside a list’s single listItem (MS Word model).
// 1) Try to decrease indent
// Note: provide a fresh tr to allow the command to operate.
const tr1 = state.tr;
if (decreaseListIndent && typeof decreaseListIndent === 'function') {
const didOutdent = decreaseListIndent()({
editor,
state,
tr: tr1,
dispatch: (t) => t && dispatch && dispatch(t),
});
if (didOutdent) return true;
}

// 2) Already at minimum level: unwrap the list:
// Replace the WHOLE list block with its listItem content (paragraphs).
const listNode = $from.node(listDepth);
const li = listNode.firstChild;
const posBeforeList = listDepth === 0 ? 0 : $from.before(listDepth);

const tr = state.tr;
// If the listItem has paragraphs/content, use that; otherwise drop an empty paragraph.
const replacement =
li && li.content && li.content.size > 0 ? li.content : Fragment.from(state.schema.nodes.paragraph.create());

tr.replaceWith(posBeforeList, posBeforeList + listNode.nodeSize, replacement);

// Put the caret at the start of the first inserted paragraph
const newPos = posBeforeList + 1; // into first block node
tr.setSelection(TextSelection.near(tr.doc.resolve(newPos), 1)).scrollIntoView();

tr.setMeta('updateListSync', true);
dispatch(tr);
return true;
}

/* Case B: caret OUTSIDE a list; previous sibling is a list */
const parentDepth = $from.depth - 1;
if (parentDepth < 0) return false;

const container = $from.node(parentDepth);
const idx = $from.index(parentDepth);

// Must have a node before us
if (idx === 0) return false;

const beforeNode = container.child(idx - 1);
if (!beforeNode || !isList(beforeNode)) return false;

const listItem = beforeNode.lastChild;
if (!listItem || listItem.type.name !== 'listItem') return false;

// Merge into the last paragraph of the previous list
const targetPara = listItem.lastChild;
if (!targetPara || targetPara.type.name !== 'paragraph') return false;

const paraStartPos = findNodePosition(doc, targetPara);
if (paraStartPos == null) return false;

const inlineContent = Fragment.from($from.parent.content);
const tr = state.tr;
tr.setMeta('updateListSync', true);

const oldParaPos = $from.before(); // safe: parentDepth >= 0 and parent is paragraph
tr.delete(oldParaPos, oldParaPos + $from.parent.nodeSize);

const insertPos = paraStartPos + 1 + targetPara.content.size;
tr.insert(insertPos, inlineContent);

tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos), 1));

dispatch(tr);
return true;
};
208 changes: 208 additions & 0 deletions packages/super-editor/src/core/commands/backspaceNextToList.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Schema, Fragment } from 'prosemirror-model';
import { EditorState, TextSelection } from 'prosemirror-state';

import { decreaseListIndent as mockDecreaseListIndent } from './decreaseListIndent.js';
import { handleBackspaceNextToList } from './backspaceNextToList.js';

vi.mock('./decreaseListIndent.js', () => ({
decreaseListIndent: vi.fn(() => {
// default mock: command that returns false (no outdent)
return () => false;
}),
}));

function makeSchema() {
const nodes = {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'text*' },
text: { group: 'inline' },

orderedList: {
group: 'block',
content: 'listItem+',
renderDOM: () => ['ol', 0],
parseDOM: () => [{ tag: 'ol' }],
},
bulletList: {
group: 'block',
content: 'listItem+',
renderDOM: () => ['ul', 0],
parseDOM: () => [{ tag: 'ul' }],
},
listItem: {
group: 'block',
content: 'paragraph block*',
defining: true,
renderDOM: () => ['li', 0],
parseDOM: () => [{ tag: 'li' }],
},
};
return new Schema({ nodes });
}

function findNodePos(doc, predicate) {
let found = null;
doc.descendants((node, pos) => {
if (predicate(node)) {
found = pos;
return false;
}
return true;
});
return found;
}

describe('handleBackspaceNextToList', () => {
let schema;

beforeEach(() => {
vi.clearAllMocks();
schema = makeSchema();
});

it('returns false if selection is not empty', () => {
const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('hello'))]);
const sel = TextSelection.create(doc, 2, 4); // non-empty
const state = EditorState.create({ schema, doc, selection: sel });

const cmd = handleBackspaceNextToList();
const dispatch = vi.fn();

const res = cmd({ state, dispatch, editor: {} });
expect(res).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it('returns false if not at start of a paragraph', () => {
const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('hello'))]);
const sel = TextSelection.create(doc, 3, 3); // inside paragraph, not at start
const state = EditorState.create({ schema, doc, selection: sel });

const cmd = handleBackspaceNextToList();
const dispatch = vi.fn();

const res = cmd({ state, dispatch, editor: {} });
expect(res).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});

it('inside a list: delegates to decreaseListIndent when it returns true', () => {
// Make decreaseListIndent() return a command that returns true
mockDecreaseListIndent.mockImplementationOnce(() => () => true);

const liPara = schema.node('paragraph', null, schema.text('item'));
const list = schema.node('orderedList', null, [schema.node('listItem', null, [liPara])]);

const doc = schema.node('doc', null, [list]);
// caret at start of the paragraph inside the list
const paraPos = findNodePos(doc, (n) => n === liPara);
const sel = TextSelection.create(doc, paraPos + 1, paraPos + 1);
const state = EditorState.create({ schema, doc, selection: sel });

const cmd = handleBackspaceNextToList();
const dispatch = vi.fn();

const ok = cmd({ state, dispatch, editor: {} });
expect(ok).toBe(true);
// decreaseListIndent should have been called once (outer function)
expect(mockDecreaseListIndent).toHaveBeenCalledTimes(1);
// We don't assert doc shape here; this path is delegated.
});

it('inside a list: unwraps list when decreaseListIndent returns false', () => {
// default mock already returns false (no outdent)
const liPara = schema.node('paragraph', null, schema.text('item'));
const list = schema.node('orderedList', null, [schema.node('listItem', null, [liPara])]);
const after = schema.node('paragraph', null, schema.text('after'));

const doc = schema.node('doc', null, [list, after]);

const paraPos = findNodePos(doc, (n) => n === liPara);
const sel = TextSelection.create(doc, paraPos + 1, paraPos + 1);
const state = EditorState.create({ schema, doc, selection: sel });

const cmd = handleBackspaceNextToList();
let dispatched = null;
const dispatch = (tr) => (dispatched = tr);

const ok = cmd({ state, dispatch, editor: {} });
expect(ok).toBe(true);
expect(mockDecreaseListIndent).toHaveBeenCalledTimes(1);

// The list should be replaced by its listItem content ("item"), followed by "after"
const outText = dispatched.doc.textBetween(0, dispatched.doc.content.size, ' ');
expect(outText).toContain('item');
expect(outText).toContain('after');

// Selection should be at the start of the first inserted paragraph (near posBeforeList + 1)
const selPos = dispatched.selection.from;
// That should resolve to a paragraph
const $pos = dispatched.doc.resolve(selPos);
expect($pos.parent.type.name).toBe('paragraph');
expect($pos.parentOffset).toBe(0);
});

it('outside a list with a previous sibling list: merges paragraph into last list item', () => {
const li1 = schema.node('paragraph', null, schema.text('alpha'));
const li2 = schema.node('paragraph', null, schema.text('beta'));
const list = schema.node('bulletList', null, [
schema.node('listItem', null, [li1]),
schema.node('listItem', null, [li2]),
]);

const followingPara = schema.node('paragraph', null, schema.text(' tail'));
const doc = schema.node('doc', null, [list, followingPara]);

// caret at start of the following paragraph
const paraPos = findNodePos(doc, (n) => n === followingPara);
const sel = TextSelection.create(doc, paraPos + 1, paraPos + 1);
const state = EditorState.create({ schema, doc, selection: sel });

const cmd = handleBackspaceNextToList();
let dispatched = null;
const dispatch = (tr) => (dispatched = tr);

const ok = cmd({ state, dispatch, editor: {} });
expect(ok).toBe(true);

// Should have set meta updateListSync = true
expect(dispatched.getMeta('updateListSync')).toBe(true);

// The following paragraph is removed, its content appended to last list item's paragraph
const outText = dispatched.doc.textBetween(0, dispatched.doc.content.size, ' ');
// alpha (first li)
expect(outText).toContain('alpha');
// beta + tail merged
expect(outText).toContain('beta tail');

// Selection placed near the end of the inserted content in the last list paragraph
const selParent = dispatched.selection.$from.parent;
expect(selParent.type.name).toBe('paragraph');
// It should be the last paragraph inside the last list item
const lastList = dispatched.doc.child(0); // first block is the list
const lastItem = lastList.lastChild;
const lastPara = lastItem.lastChild;
expect(selParent).toBe(lastPara);
});

it('returns false when parent is not a paragraph', () => {
// caret at start of listItem (not paragraph)
const liPara = schema.node('paragraph', null, schema.text('x'));
const list = schema.node('orderedList', null, [schema.node('listItem', null, [liPara])]);
const doc = schema.node('doc', null, [list]);

// Place cursor at the very start of the list node (not valid paragraph start case)
const listPos = findNodePos(doc, (n) => n === list);
// Resolve to pos inside the list node (1 step in)
const sel = TextSelection.create(doc, listPos + 1, listPos + 1);
const state = EditorState.create({ schema, doc, selection: sel });

const cmd = handleBackspaceNextToList();
const dispatch = vi.fn();

const res = cmd({ state, dispatch, editor: {} });
expect(res).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
});
});
6 changes: 3 additions & 3 deletions packages/super-editor/src/core/commands/deleteListItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const deleteListItem = () => (props) => {
return true;
}

// no full blocks found let other commands handle it
// no full blocks found: let other commands handle it
return false;
}

Expand All @@ -65,13 +65,13 @@ export const deleteListItem = () => (props) => {
const listFrom = parentList.pos;
const listTo = listFrom + parentList.node.nodeSize;

// Case 1: empty list item remove whole list
// Case 1: empty list item: remove whole list
if (currentListItem.node.content.size === 0) {
tr.delete(listFrom, listTo);
return true;
}

// Case 2: non‐empty list item replace list with all content from the list item
// Case 2: non‐empty list item: replace list with all content from the list item
const listItemContent = currentListItem.node.content;

// Create nodes from the list item content
Expand Down
Loading
Loading