diff --git a/package-lock.json b/package-lock.json index 6f8f906fec..822400e8df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12407,6 +12407,18 @@ "prosemirror-view": "^1.39.1" } }, + "node_modules/prosemirror-test-builder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prosemirror-test-builder/-/prosemirror-test-builder-1.1.1.tgz", + "integrity": "sha512-DJ1+4TNTE9ZcYN/ozXCaWJVrGA99UttMoVvZuidvAotRg7FaiNtEYxL/vlDwfZDRnzJDXNYhmM3XPv3EweK7yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-schema-basic": "^1.0.0", + "prosemirror-schema-list": "^1.0.0" + } + }, "node_modules/prosemirror-transform": { "version": "1.10.4", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", @@ -16467,7 +16479,7 @@ }, "packages/ooxml-inspector": { "name": "@superdoc-dev/ooxml-inspector", - "version": "1.0.0", + "version": "1.0.1", "license": "AGPL-3.0", "dependencies": { "fast-xml-parser": "^4.4.0" @@ -16560,6 +16572,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", diff --git a/package.json b/package.json index 229e38f71f..c724d27f7d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 89cd078fb4..c3a0b6e680 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -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", diff --git a/packages/super-editor/src/core/commands/backspaceNextToList.js b/packages/super-editor/src/core/commands/backspaceNextToList.js new file mode 100644 index 0000000000..572358c7d4 --- /dev/null +++ b/packages/super-editor/src/core/commands/backspaceNextToList.js @@ -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; + }; diff --git a/packages/super-editor/src/core/commands/backspaceNextToList.test.js b/packages/super-editor/src/core/commands/backspaceNextToList.test.js new file mode 100644 index 0000000000..43bd1cb112 --- /dev/null +++ b/packages/super-editor/src/core/commands/backspaceNextToList.test.js @@ -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(); + }); +}); diff --git a/packages/super-editor/src/core/commands/deleteListItem.js b/packages/super-editor/src/core/commands/deleteListItem.js index 4d8e68ab39..f0f7bc24eb 100644 --- a/packages/super-editor/src/core/commands/deleteListItem.js +++ b/packages/super-editor/src/core/commands/deleteListItem.js @@ -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; } @@ -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 diff --git a/packages/super-editor/src/core/commands/deleteNextToList.js b/packages/super-editor/src/core/commands/deleteNextToList.js new file mode 100644 index 0000000000..0c6c74e71f --- /dev/null +++ b/packages/super-editor/src/core/commands/deleteNextToList.js @@ -0,0 +1,184 @@ +// @ts-check +import { Fragment } from 'prosemirror-model'; +import { TextSelection } from 'prosemirror-state'; +import { isList } from './list-helpers'; + +/** + * Get the context information for the current paragraph. + * @param {import("prosemirror-state").EditorState} state + * @returns {Object|null} Context information for the paragraph or null if not found. + */ +export function getParaCtx(state) { + const { $from } = state.selection; + for (let d = $from.depth; d >= 0; d--) { + const n = $from.node(d); + if (n.type.name === 'paragraph') { + // pos before paragraph by walking from $from to that depth: + const before = $from.before(d); + const endInside = before + 1 + n.content.size; + return { para: n, paraDepth: d, before, endInside }; + } + } + return null; +} + +/** + * Check if the cursor is at the visual end of the paragraph. + * @param {import("prosemirror-state").EditorState} state + * @param {Object} ctx + * @returns {boolean} + */ +export function atVisualParaEnd(state, ctx) { + const { $from } = state.selection; + const { para, paraDepth, endInside } = ctx; + + // paragraph parent at end + if ($from.parent.type.name === 'paragraph' && $from.parentOffset === $from.parent.content.size) return true; + + // run parent at end AND this run is last inline in the paragraph + if ($from.parent.type.name === 'run' && $from.parentOffset === $from.parent.content.size) { + const idxInPara = $from.index(paraDepth); + return idxInPara === para.childCount - 1; + } + + // fallback exact check + return $from.pos === endInside; +} + +/** + * Get the position and next sibling node at a given block depth. + * @param {import("prosemirror-state").EditorState} state + * @param {number} depth + * @returns {{ pos: number|null, next: import("prosemirror-model").Node|null }} + */ +function getNextSiblingAtDepth(state, depth) { + // `$from.after(depth)` is the position just after the node at `depth` (i.e., before the next sibling) + const pos = state.selection.$from.after(depth); + if (pos == null) return { pos: null, next: null }; + const $pos = state.doc.resolve(pos); + return { pos, next: $pos.nodeAfter || null }; +} + +/** + * Handle delete key behavior when the caret is next to a list. + * @returns {Function} The command function. + */ +export const handleDeleteNextToList = + () => + ({ state, dispatch }) => { + const { selection } = state; + const { $from } = selection; + if (!selection.empty) return false; + + const ctx = getParaCtx(state); + if (!ctx) return false; + const { paraDepth, endInside: paraEnd } = ctx; + + if (!atVisualParaEnd(state, ctx)) return false; + + const tr = state.tr; + tr.setMeta('suppressAutoList', true); + + const insertAtParaEnd = (frag) => { + const mapped = tr.mapping.map(paraEnd, 1); + tr.insert(mapped, frag); + return mapped; + }; + + // Are we in a list item? (and at which list depth) + let listItemDepth = -1; + let listDepth = -1; + for (let d = $from.depth; d > 0; d--) { + const maybeLI = $from.node(d - 1); + if (maybeLI.type.name === 'listItem') { + listItemDepth = d - 1; + if (d - 2 >= 0 && isList($from.node(d - 2))) listDepth = d - 2; + break; + } + } + + // If in LI and there’s another paragraph in the same LI: let default delete join inside LI + if (listItemDepth !== -1 && listDepth !== -1) { + const li = $from.node(listItemDepth); + const paraIdxInLI = $from.index(listItemDepth + 1); // index among LI children + if (paraIdxInLI < li.childCount - 1) return false; + } + + // Determine the “current block” depth to look after: + // - inside LI: the LIST is the current block (so we look after the whole list) + // - otherwise: the PARAGRAPH is the current block + const currentBlockDepth = listItemDepth !== -1 && listDepth !== -1 ? listDepth : paraDepth; + + const { pos: nextBeforePos, next: nextNode } = getNextSiblingAtDepth(state, currentBlockDepth); + if (nextBeforePos == null || !nextNode) return false; + + // Merge a paragraph that sits at `nextBeforePos` + const mergeParagraphAt = (beforePos) => { + // The node to merge is exactly nodeAfter at beforePos + const livePara = tr.doc.resolve(beforePos).nodeAfter; + if (!livePara || livePara.type.name !== 'paragraph') return false; + + if (livePara.content.size === 0) { + tr.delete(beforePos, beforePos + livePara.nodeSize); + dispatch?.(tr); + return true; + } + + const ins = insertAtParaEnd(Fragment.from(livePara.content)); + // delete the source paragraph (careful: map both ends) + const delFrom = tr.mapping.map(beforePos, 1); + const delTo = tr.mapping.map(beforePos + livePara.nodeSize, 1); + tr.delete(delFrom, delTo); + + const selPos = tr.mapping.map(ins + livePara.content.size, -1); + tr.setSelection(TextSelection.near(tr.doc.resolve(selPos), -1)).scrollIntoView(); + dispatch?.(tr); + return true; + }; + + // Merge from a list (single LI invariant). Swallow even if nothing to merge to prevent structural join. + const mergeListAt = (beforePos) => { + const liveList = tr.doc.resolve(beforePos).nodeAfter; + if (!liveList || !isList(liveList)) return true; // swallow, block joinForward + + const li = liveList.firstChild; + if (!li || li.type.name !== 'listItem' || li.childCount === 0) { + tr.delete(beforePos, beforePos + liveList.nodeSize); + dispatch?.(tr); + return true; + } + + // first non-empty paragraph + let content = null; + for (let i = 0; i < li.childCount; i++) { + const ch = li.child(i); + if (ch.type.name === 'paragraph' && ch.content.size > 0) { + content = ch.content; + break; + } + } + + if (content) insertAtParaEnd(Fragment.from(content)); + + // delete the whole list + const delFrom = tr.mapping.map(beforePos, 1); + const delTo = tr.mapping.map(beforePos + liveList.nodeSize, 1); + tr.delete(delFrom, delTo); + + const endPos = tr.mapping.map(paraEnd + (content ? content.size : 0), -1); + tr.setSelection(TextSelection.near(tr.doc.resolve(endPos), -1)).scrollIntoView(); + dispatch?.(tr); + return true; + }; + + if (nextNode.isTextblock) { + const changed = mergeParagraphAt(nextBeforePos); + return changed ? true : false; // only swallow if we actually merged/deleted + } + if (isList(nextNode)) { + return mergeListAt(nextBeforePos); // swallow to prevent structural list merge + } + + // Unknown block: let default behavior proceed + return false; + }; diff --git a/packages/super-editor/src/core/commands/deleteNextToList.test.js b/packages/super-editor/src/core/commands/deleteNextToList.test.js new file mode 100644 index 0000000000..87958ee9b0 --- /dev/null +++ b/packages/super-editor/src/core/commands/deleteNextToList.test.js @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { getParaCtx, atVisualParaEnd, handleDeleteNextToList } from './deleteNextToList.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: '(run|text)*', + renderDOM: () => ['p', 0], + parseDOM: () => [{ tag: 'p' }], + }, + run: { + inline: true, + group: 'inline', + content: 'text*', + renderDOM: () => ['span', { 'data-w-run': 'true' }, 0], + parseDOM: () => [{ tag: 'span[data-w-run]' }], + }, + text: { group: 'inline' }, + + listItem: { + content: 'paragraph+', + renderDOM: () => ['li', 0], + parseDOM: () => [{ tag: 'li' }], + }, + orderedList: { + group: 'block', + content: 'listItem+', + renderDOM: () => ['ol', 0], + parseDOM: () => [{ tag: 'ol' }], + }, + bulletList: { + group: 'block', + content: 'listItem+', + renderDOM: () => ['ul', 0], + parseDOM: () => [{ tag: 'ul' }], + }, + }, +}); + +const p = (...inlines) => schema.nodes.paragraph.create(null, inlines); +const t = (str) => schema.text(str); +const r = (str) => schema.nodes.run.create(null, schema.text(str)); +const li = (...paras) => schema.nodes.listItem.create(null, paras); +const ol = (...items) => schema.nodes.orderedList.create(null, items); +const docN = (...blocks) => schema.nodes.doc.create(null, blocks); + +function posBeforeNode(doc, target) { + let hit = null; + doc.descendants((node, pos) => { + if (node === target) { + hit = pos; + return false; + } + return true; + }); + return hit; +} +function cursorAtEndOfRun(state, runNode) { + const before = posBeforeNode(state.doc, runNode); + return before + 1 + runNode.content.size; +} +function mkState(doc, cursorPos) { + return EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, cursorPos), + }); +} + +function makeDispatchRef(stateRef) { + return (tr) => { + stateRef.state = stateRef.state.apply(tr); + }; +} + +describe('getParaCtx', () => { + it('finds the paragraph and computes before/endInside', () => { + const para = p(t('Hello')); + const d = docN(para); + // cursor anywhere in paragraph + const end = posBeforeNode(d, para) + 1 + para.content.size; + const state = mkState(d, end); + + const ctx = getParaCtx(state); + expect(ctx).toBeTruthy(); + expect(ctx.para).toBe(para); + expect(ctx.before).toBe(0); // first block starts at 0 + expect(ctx.endInside).toBe(1 + para.content.size); + }); + + it('returns null if no paragraph ancestor', () => { + // Create an invalid doc with nothing? (Schema requires blocks; use empty paragraph but place selection at doc start) + const para = p(); // empty paragraph + const d = docN(para); + // selection at doc start still has a paragraph ancestor (pos 1 is inside para) + // So simulate a selection *before* the paragraph: pos 0 (valid start) + const state = EditorState.create({ schema, doc: d, selection: TextSelection.create(d, 1) }); + const ctx = getParaCtx(state); + // We *are* inside the paragraph at pos 1 -> should not be null + expect(ctx).not.toBeNull(); + }); +}); + +describe('atVisualParaEnd', () => { + it('is true when cursor is at paragraph end directly (no run)', () => { + const para = p(t('abc')); + const d = docN(para); + const end = posBeforeNode(d, para) + 1 + para.content.size; + const state = mkState(d, end); + + const ctx = getParaCtx(state); + expect(atVisualParaEnd(state, ctx)).toBe(true); + }); + + it('is true when cursor is at end of the last run in a paragraph', () => { + const run1 = r('abc'); + const run2 = r('xyz'); + const para = p(run1, run2); + const d = docN(para); + const state = mkState(d, cursorAtEndOfRun({ doc: d }, run2)); + + const ctx = getParaCtx(state); + expect(atVisualParaEnd(state, ctx)).toBe(true); + }); + + it('is false when cursor is inside run but not at end of last run', () => { + const run1 = r('abc'); + const run2 = r('xyz'); + const para = p(run1, run2); + const d = docN(para); + // position at end of run1 (not last run) + const state = mkState(d, cursorAtEndOfRun({ doc: d }, run1)); + + const ctx = getParaCtx(state); + expect(atVisualParaEnd(state, ctx)).toBe(false); + }); +}); + +describe('handleDeleteNextToList', () => { + it('merges next paragraph into current paragraph (single delete)', () => { + const para1 = p(t('Hello ')); + const para2 = p(t('World')); + const d = docN(para1, para2); + + const end = posBeforeNode(d, para1) + 1 + para1.content.size; + let stateRef = { state: mkState(d, end) }; + const dispatch = makeDispatchRef(stateRef); + + const handled = handleDeleteNextToList()({ state: stateRef.state, dispatch }); + expect(handled).toBe(true); + + const json = stateRef.state.doc.toJSON(); + // should be a single paragraph with concatenated text + expect(json).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello World' }] }], + }); + }); + + it('deletes empty next paragraph', () => { + const para1 = p(t('A')); + const para2 = p(); // empty + const d = docN(para1, para2); + + const end = posBeforeNode(d, para1) + 1 + para1.content.size; + let stateRef = { state: mkState(d, end) }; + const dispatch = makeDispatchRef(stateRef); + + const handled = handleDeleteNextToList()({ state: stateRef.state, dispatch }); + expect(handled).toBe(true); + + const json = stateRef.state.doc.toJSON(); + expect(json).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }], + }); + }); + + it('merges from next list (paragraph + orderedList) and removes the list', () => { + const para = p(t('A')); + const list = ol(li(p(t('B')))); + const d = docN(para, list); + + const end = posBeforeNode(d, para) + 1 + para.content.size; + let stateRef = { state: mkState(d, end) }; + const dispatch = makeDispatchRef(stateRef); + + const handled = handleDeleteNextToList()({ state: stateRef.state, dispatch }); + expect(handled).toBe(true); + + const json = stateRef.state.doc.toJSON(); + expect(json).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'AB' }] }], + }); + }); + + it('inside list item, merges following paragraph into the list item’s paragraph', () => { + const inListPara = p(t('One ')); + const listDoc = ol(li(inListPara)); + const para2 = p(t('Two')); + const d = docN(listDoc, para2); + + // Cursor at end of the paragraph inside the list item + const end = posBeforeNode(d, inListPara) + 1 + inListPara.content.size; + let stateRef = { state: mkState(d, end) }; + const dispatch = makeDispatchRef(stateRef); + + const handled = handleDeleteNextToList()({ state: stateRef.state, dispatch }); + expect(handled).toBe(true); + + // The list should remain, paragraph content should be merged + const json = stateRef.state.doc.toJSON(); + expect(json).toEqual({ + type: 'doc', + content: [ + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'One Two' }] }], + }, + ], + }, + ], + }); + }); + + it('inside list item, merges content from next list and deletes that list', () => { + const liPara1 = p(t('X')); + const list1 = ol(li(liPara1)); + const list2 = ol(li(p(t('Y')))); + const d = docN(list1, list2); + + const end = posBeforeNode(d, liPara1) + 1 + liPara1.content.size; + let stateRef = { state: mkState(d, end) }; + const dispatch = makeDispatchRef(stateRef); + + const handled = handleDeleteNextToList()({ state: stateRef.state, dispatch }); + expect(handled).toBe(true); + + const json = stateRef.state.doc.toJSON(); + expect(json).toEqual({ + type: 'doc', + content: [ + { + type: 'orderedList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'XY' }] }], + }, + ], + }, + ], + }); + }); + + it('treats end-of-run as paragraph end and merges next paragraph', () => { + const run = r('A'); + const para1 = p(run); + const para2 = p(t('B')); + const d = docN(para1, para2); + + const endOfRun = cursorAtEndOfRun({ doc: d }, run); + let stateRef = { state: mkState(d, endOfRun) }; + const dispatch = makeDispatchRef(stateRef); + + const handled = handleDeleteNextToList()({ state: stateRef.state, dispatch }); + expect(handled).toBe(true); + + const { doc } = stateRef.state; + + // Assert overall text joined correctly + expect(doc.textContent).toBe('AB'); + + // Assert structure: first inline is still a run containing "A" + const firstPara = doc.firstChild; // paragraph + const firstInline = firstPara.firstChild; // run + expect(firstInline.type.name).toBe('run'); + expect(firstInline.textContent).toBe('A'); + + // Second inline is plain text "B" + const secondInline = firstPara.child(1); + expect(secondInline.type.name).toBe('text'); + expect(secondInline.text).toBe('B'); + }); +}); diff --git a/packages/super-editor/src/core/commands/deleteSelection.js b/packages/super-editor/src/core/commands/deleteSelection.js index a1621b43e8..503d566f41 100644 --- a/packages/super-editor/src/core/commands/deleteSelection.js +++ b/packages/super-editor/src/core/commands/deleteSelection.js @@ -1,230 +1,35 @@ import { deleteSelection as originalDeleteSelection } from 'prosemirror-commands'; -import { Fragment } from 'prosemirror-model'; -import { TextSelection } from 'prosemirror-state'; /** * Delete the selection, if there is one. */ -//prettier-ignore -export const deleteSelection = () => ({ state, tr, dispatch }) => { - const { from, to, empty } = state.selection; - - if (empty) { - return originalDeleteSelection(state, dispatch); - } - - let hasListContent = false; - state.doc.nodesBetween(from, to, (node) => { - if (node.type.name === 'orderedList' || - node.type.name === 'bulletList' || - node.type.name === 'listItem') { - hasListContent = true; - return false; - } - }); - - if (hasListContent) { - const transaction = tr || state.tr; - transaction.deleteRange(from, to); - - if (dispatch) { - dispatch(transaction); - } - - return true; - } - - return originalDeleteSelection(state, dispatch); -}; - -/** - * Helper function to find the position of a target node in the document. - * @param {Node} doc - The ProseMirror document to search in. - * @param {Node} targetNode - The ProseMirror node to find the position of. - * @returns {number|null} The position of the target node in the document, or null - */ -const findNodePosition = (doc, targetNode) => { - let nodePos = null; - doc.descendants((node, pos) => { - if (node === targetNode) { - nodePos = pos; - return false; - } - }); - return nodePos; -}; - -/** - * Helper function to check if a node is a list. - * @param {Node} n - The ProseMirror node to check. - * @returns {boolean} True if the node is an ordered or bullet list, false otherwise - */ -const isList = (n) => n.type.name === 'orderedList' || n.type.name === 'bulletList'; - -/** - * Handles the backspace key when the cursor is at the start of a paragraph next to a list. - * It merges the paragraph content into the last list item of the previous list. - * @param {Object} param0 - The ProseMirror command parameters. - * @param {Object} param0.state - The ProseMirror editor state. - * @param {Function} param0.dispatch - The function to dispatch a transaction. - * @returns {boolean} Returns true if the command was handled, false otherwise. - */ -export const handleBackspaceNextToList = +export const deleteSelection = () => - ({ state, dispatch }) => { - 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 - - 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; - - 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(); - - 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; - }; + ({ state, tr, dispatch }) => { + const { from, to, empty } = state.selection; -/** - * Handles the delete key when the cursor is at the end of a paragraph next to a list. - * It merges the paragraph content into the first list item of the next list. - * @param {Object} param0 - The ProseMirror command parameters. - * @param {Object} param0.state - The ProseMirror editor state. - * @param {Function} param0.dispatch - The function to dispatch a transaction. - * @returns {boolean} Returns true if the command was handled, false otherwise. - */ -export const handleDeleteNextToList = - () => - ({ state, dispatch }) => { - const { selection, doc } = state; - const { $from } = selection; - - if (!selection.empty) return false; - if ($from.parent.type.name !== 'paragraph') return false; - if ($from.parentOffset !== $from.parent.content.size) return false; // Only at end of paragraph - - // Check if we're inside a list item - let currentDepth = $from.depth; - let listItemDepth = -1; - - while (currentDepth > 0) { - const node = $from.node(currentDepth - 1); - if (node.type.name === 'listItem') { - listItemDepth = currentDepth - 1; - break; - } - currentDepth--; + if (empty) { + return originalDeleteSelection(state, dispatch); } - if (listItemDepth !== -1) { - // We're inside a list item - handle list-to-list merging - const listDepth = listItemDepth - 1; - const list = $from.node(listDepth); - const listItemIdx = $from.index(listDepth); - const listContainer = $from.node(listDepth - 1); - const listIdx = $from.index(listDepth - 1); - - // Check if we're at the last item in this list - if (listItemIdx < list.childCount - 1) { - // There's another list item in the same list - prevent merging - return true; + let hasListContent = false; + state.doc.nodesBetween(from, to, (node) => { + if (node.type.name === 'orderedList' || node.type.name === 'bulletList' || node.type.name === 'listItem') { + hasListContent = true; + return false; } + }); - // We're at the last item, check what's after the list - if (listIdx >= listContainer.childCount - 1) return false; - - const nextNode = listContainer.child(listIdx + 1); - if (!isList(nextNode)) return false; - - // Next node is a list - merge the paragraph content, delete the list - const nextListItem = nextNode.firstChild; - if (!nextListItem || nextListItem.type.name !== 'listItem') return false; - - const nextPara = nextListItem.firstChild; - if (!nextPara || nextPara.type.name !== 'paragraph') return false; - - const nextListStartPos = findNodePosition(doc, nextNode); - if (nextListStartPos == null) return false; - - const targetInlineContent = Fragment.from(nextPara.content); - const tr = state.tr; - tr.setMeta('updateListSync', true); - - // Delete the entire next list - tr.delete(nextListStartPos, nextListStartPos + nextNode.nodeSize); - - // Insert the content at current position - const insertPos = tr.mapping.map($from.pos); - tr.insert(insertPos, targetInlineContent); + if (hasListContent) { + const transaction = tr || state.tr; + transaction.deleteRange(from, to); - tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos), 1)); - - dispatch(tr); - return true; - } else { - // We're in a regular paragraph - handle paragraph-to-list merging - const parentDepth = $from.depth - 1; - if (parentDepth < 0) return false; - const container = $from.node(parentDepth); - const idx = $from.index(parentDepth); - - if (idx >= container.childCount - 1) return false; - - const afterNode = container.child(idx + 1); - if (!afterNode || !isList(afterNode)) return false; - - const listItem = afterNode.firstChild; - if (!listItem || listItem.type.name !== 'listItem') return false; - - const targetPara = listItem.firstChild; - if (!targetPara || targetPara.type.name !== 'paragraph') return false; - - const listStartPos = findNodePosition(doc, afterNode); - if (listStartPos == null) return false; - - const targetInlineContent = Fragment.from(targetPara.content); - const tr = state.tr; - tr.setMeta('updateListSync', true); - - tr.delete(listStartPos, listStartPos + afterNode.nodeSize); - - const insertPos = tr.mapping.map($from.pos); - tr.insert(insertPos, targetInlineContent); - - tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos), 1)); + if (dispatch) { + dispatch(transaction); + } - dispatch(tr); return true; } + + return originalDeleteSelection(state, dispatch); }; diff --git a/packages/super-editor/src/core/commands/deleteSelection.test.js b/packages/super-editor/src/core/commands/deleteSelection.test.js new file mode 100644 index 0000000000..9a61adc618 --- /dev/null +++ b/packages/super-editor/src/core/commands/deleteSelection.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { deleteSelection as pmDeleteSelection } from 'prosemirror-commands'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { deleteSelection } from './deleteSelection.js'; + +vi.mock('prosemirror-commands', () => ({ + deleteSelection: vi.fn(), +})); + +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 }); +} + +describe('deleteSelection', () => { + let schema; + + beforeEach(() => { + vi.clearAllMocks(); + schema = makeSchema(); + }); + + it('delegates to original deleteSelection when selection is empty', () => { + const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('hello world'))]); + const sel = TextSelection.create(doc, 2, 2); + const state = EditorState.create({ schema, doc, selection: sel }); + + pmDeleteSelection.mockReturnValueOnce('delegated'); + + const cmd = deleteSelection(); + const dispatch = vi.fn(); + const res = cmd({ state, tr: state.tr, dispatch }); + + expect(pmDeleteSelection).toHaveBeenCalledTimes(1); + expect(pmDeleteSelection).toHaveBeenCalledWith(state, dispatch); + expect(res).toBe('delegated'); + }); + + it('hard-deletes when selection contains list content (orderedList)', () => { + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, schema.text('before')), + schema.node('orderedList', null, [ + schema.node('listItem', null, [schema.node('paragraph', null, schema.text('one'))]), + schema.node('listItem', null, [schema.node('paragraph', null, schema.text('two'))]), + ]), + schema.node('paragraph', null, schema.text('after')), + ]); + + // select from inside "one" into "after" + const from = 8; + const to = doc.content.size - 2; + const sel = TextSelection.create(doc, from, to); + const state = EditorState.create({ schema, doc, selection: sel }); + + const tr = state.tr; + const deleteSpy = vi.spyOn(tr, 'deleteRange'); + + const cmd = deleteSelection(); + let dispatched = null; + const dispatch = (t) => (dispatched = t); + + const ok = cmd({ state, tr, dispatch }); + expect(ok).toBe(true); + expect(pmDeleteSelection).not.toHaveBeenCalled(); + expect(deleteSpy).toHaveBeenCalledWith(from, to); + expect(dispatched).toBeTruthy(); + }); + + it('delegates when non-empty selection has no list content', () => { + const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('abc def ghi'))]); + const sel = TextSelection.create(doc, 2, 6); // "c de" + const state = EditorState.create({ schema, doc, selection: sel }); + + pmDeleteSelection.mockReturnValueOnce('delegated-non-empty'); + + const cmd = deleteSelection(); + const dispatch = vi.fn(); + const res = cmd({ state, tr: state.tr, dispatch }); + + expect(pmDeleteSelection).toHaveBeenCalledTimes(1); + expect(res).toBe('delegated-non-empty'); + }); + + it('returns true when dispatch is omitted (list content case)', () => { + const doc = schema.node('doc', null, [ + schema.node('bulletList', null, [ + schema.node('listItem', null, [schema.node('paragraph', null, schema.text('foo bar'))]), + ]), + ]); + const sel = TextSelection.create(doc, 2, 5); + const state = EditorState.create({ schema, doc, selection: sel }); + + const cmd = deleteSelection(); + const ok = cmd({ state, tr: state.tr }); // no dispatch + + expect(ok).toBe(true); + expect(pmDeleteSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/core/commands/index.js b/packages/super-editor/src/core/commands/index.js index 2099fe42ae..10f70c2bfd 100644 --- a/packages/super-editor/src/core/commands/index.js +++ b/packages/super-editor/src/core/commands/index.js @@ -43,6 +43,8 @@ export * from './liftListItem.js'; export * from './deleteListItem.js'; export * from './increaseListIndent.js'; export * from './decreaseListIndent.js'; +export * from './backspaceNextToList.js'; +export * from './deleteNextToList.js'; // Selection export * from './restoreSelection.js'; diff --git a/packages/super-editor/src/core/commands/list-helpers/find-node-position.js b/packages/super-editor/src/core/commands/list-helpers/find-node-position.js new file mode 100644 index 0000000000..728c89c2a4 --- /dev/null +++ b/packages/super-editor/src/core/commands/list-helpers/find-node-position.js @@ -0,0 +1,17 @@ +/** + * Helper function to find the position of a target node in the document. + * @param {import("prosemirror-model").Node} doc - The ProseMirror document to search in. + * @param {import("prosemirror-model").Node} targetNode - The ProseMirror node to find the position of. + * @returns {number|null} The position of the target node in the document, or null + */ +export const findNodePosition = (doc, targetNode) => { + let nodePos = null; + doc.descendants((node, pos) => { + if (node === targetNode) { + nodePos = pos; + return false; + } + return true; + }); + return nodePos; +}; diff --git a/packages/super-editor/src/core/commands/list-helpers/find-node-position.test.js b/packages/super-editor/src/core/commands/list-helpers/find-node-position.test.js new file mode 100644 index 0000000000..06ea004fe6 --- /dev/null +++ b/packages/super-editor/src/core/commands/list-helpers/find-node-position.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { findNodePosition } from './find-node-position.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { content: 'text*', group: 'block' }, + text: { group: 'inline' }, + }, +}); + +describe('findNodePosition', () => { + it('returns the position of a direct child', () => { + const p1 = schema.nodes.paragraph.create(null, schema.text('hello')); + const p2 = schema.nodes.paragraph.create(null, schema.text('world')); + const doc = schema.nodes.doc.create(null, [p1, p2]); + + const pos1 = findNodePosition(doc, p1); + const pos2 = findNodePosition(doc, p2); + + expect(pos1).toBe(0); // first block starts at pos 0 + expect(pos2).toBe(p1.nodeSize); // second block starts after first + }); + + it('returns the position of a nested text node', () => { + const textNode = schema.text('abc'); + const para = schema.nodes.paragraph.create(null, textNode); + const doc = schema.nodes.doc.create(null, para); + + const paraPos = findNodePosition(doc, para); + const textPos = findNodePosition(doc, textNode); + + expect(paraPos).toBe(0); + expect(textPos).toBe(1); // text starts inside paragraph + }); + + it('returns null if the node is not in the document', () => { + const doc = schema.nodes.doc.create(null, schema.nodes.paragraph.create()); + const foreignNode = schema.nodes.paragraph.create(); + + const pos = findNodePosition(doc, foreignNode); + + expect(pos).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/core/commands/list-helpers/index.js b/packages/super-editor/src/core/commands/list-helpers/index.js new file mode 100644 index 0000000000..788b4af326 --- /dev/null +++ b/packages/super-editor/src/core/commands/list-helpers/index.js @@ -0,0 +1,2 @@ +export * from './is-list.js'; +export * from './find-node-position.js'; diff --git a/packages/super-editor/src/core/commands/list-helpers/is-list.js b/packages/super-editor/src/core/commands/list-helpers/is-list.js new file mode 100644 index 0000000000..3d8c1785a5 --- /dev/null +++ b/packages/super-editor/src/core/commands/list-helpers/is-list.js @@ -0,0 +1,6 @@ +/** + * Helper function to check if a node is a list. + * @param {import("prosemirror-model").Node} n - The ProseMirror node to check. + * @returns {boolean} True if the node is an ordered or bullet list, false otherwise + */ +export const isList = (n) => !!n && (n.type?.name === 'orderedList' || n.type?.name === 'bulletList'); diff --git a/packages/super-editor/src/core/commands/list-helpers/is-list.test.js b/packages/super-editor/src/core/commands/list-helpers/is-list.test.js new file mode 100644 index 0000000000..db2fec365d --- /dev/null +++ b/packages/super-editor/src/core/commands/list-helpers/is-list.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { isList } from './is-list.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'text*', renderDOM: () => ['p', 0], parseDOM: () => [{ tag: 'p' }] }, + text: { group: 'inline' }, + orderedList: { content: 'listItem+', group: 'block', renderDOM: () => ['ol', 0], parseDOM: () => [{ tag: 'ol' }] }, + bulletList: { content: 'listItem+', group: 'block', renderDOM: () => ['ul', 0], parseDOM: () => [{ tag: 'ul' }] }, + listItem: { content: 'paragraph+', renderDOM: () => ['li', 0], parseDOM: () => [{ tag: 'li' }] }, + }, +}); + +describe('isList', () => { + it('returns true for orderedList nodes', () => { + const node = schema.nodes.orderedList.createAndFill(); + expect(isList(node)).toBe(true); + }); + + it('returns true for bulletList nodes', () => { + const node = schema.nodes.bulletList.createAndFill(); + expect(isList(node)).toBe(true); + }); + + it('returns false for non-list nodes', () => { + const para = schema.nodes.paragraph.create(); + expect(isList(para)).toBe(false); + }); + + it('returns false for null/undefined', () => { + expect(isList(null)).toBe(false); + expect(isList(undefined)).toBe(false); + }); +}); diff --git a/packages/super-editor/src/core/commands/splitListItem.js b/packages/super-editor/src/core/commands/splitListItem.js index 118e5db930..819ebc9c7a 100644 --- a/packages/super-editor/src/core/commands/splitListItem.js +++ b/packages/super-editor/src/core/commands/splitListItem.js @@ -2,175 +2,115 @@ import { Fragment } from 'prosemirror-model'; import { TextSelection } from 'prosemirror-state'; import { Attribute } from '../Attribute.js'; import { findParentNode, getNodeType } from '@helpers/index.js'; -import { decreaseListIndent } from './decreaseListIndent.js'; -/** - * Splits one list item into two separate list items. - * @param typeOrName The type or name of the node. - * - * The command is a heavily modified version of the original - * `splitListItem` command to better manage attributes and marks - * as well as custom SuperDoc lists. - * - * https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts#L114 - */ export const splitListItem = () => (props) => { - const { tr, state, editor } = props; + const { tr, state, editor, dispatch } = props; const type = getNodeType('listItem', state.schema); - const { $from, $to } = state.selection; + const { $from, $to, empty } = state.selection; + tr.setMeta('updateListSync', true); - const currentListItem = findParentNode((node) => node.type.name === 'listItem')(state.selection); - if (!currentListItem) return false; + const listItemPM = findParentNode((n) => n.type === type)(state.selection); + if (!listItemPM) return false; + const { node: listItemNode } = listItemPM; - // If selection spans multiple blocks or we're not inside a list item, do nothing + // Must be a single textblock selection if ((state.selection.node && state.selection.node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) { return false; } - // Check if we should handle this as an empty block split + // Empty-block special case (unchanged) if ($from.parent.content.size === 0 && $from.node(-1).childCount === $from.indexAfter(-1)) { - return handleSplitInEmptyBlock(props, currentListItem); + return handleSplitInEmptyBlock(props, listItemPM); } - const matchedListItem = findParentNode((node) => node.type === type)(state.selection); - const { node: listItemNode } = matchedListItem || {}; - if (listItemNode.type !== type) return false; + // Parent list (MS-Word model: a root block with exactly one listItem) + const listPM = findParentNode((n) => ['orderedList', 'bulletList'].includes(n.type.name))(state.selection); + if (!listPM) return false; + const { node: parentListNode, pos: listStart } = listPM; + const listEnd = listStart + parentListNode.nodeSize; - const listTypes = ['orderedList', 'bulletList']; - const matchedParentList = findParentNode((node) => listTypes.includes(node.type.name))(state.selection); - const { node: parentListNode } = matchedParentList || {}; + // If text is selected, delete it first so we split at a caret + if (!empty) tr.delete($from.pos, $to.pos); - // If we have something in the selection, we need to remove it - if ($from.pos !== $to.pos) tr.delete($from.pos, $to.pos); + // Slice the *paragraph* at the cursor + const paraPM = findParentNode((n) => n.type.name === 'paragraph')(state.selection); + if (!paraPM) return false; + const paragraphNode = paraPM.node; + const paraStart = paraPM.pos + 1; // first position inside paragraph + const offsetInParagraph = state.selection.from - paraStart; - const paragraphNode = $from.node(); - const paraOffset = $from.parentOffset; - const beforeCursor = paragraphNode.content.cut(0, paraOffset); - const afterCursor = paragraphNode.content.cut(paraOffset); + const beforeCursor = paragraphNode.content.cut(0, Math.max(0, offsetInParagraph)); + const afterCursor = paragraphNode.content.cut(Math.max(0, offsetInParagraph)); - // Declare variables that will be used across if-else blocks - let firstList, secondList; - - const marks = state.storedMarks || $from.marks() || []; - - // Check if the list item has multiple paragraphs + // Multi-paragraph vs single-paragraph listItem + const paragraphIndex = $from.index(-1); const listItemHasMultipleParagraphs = listItemNode.childCount > 1; - if (listItemHasMultipleParagraphs) { - // Handle multi-paragraph case: preserve all content after cursor position - const paragraphIndex = $from.index(-1); // Index of current paragraph within list item - - // Get content before current paragraph - let contentBeforeCurrentPara = []; - for (let i = 0; i < paragraphIndex; i++) { - contentBeforeCurrentPara.push(listItemNode.child(i)); - } - - // Get content after current paragraph - let contentAfterCurrentPara = []; - for (let i = paragraphIndex + 1; i < listItemNode.childCount; i++) { - contentAfterCurrentPara.push(listItemNode.child(i)); - } - - // Create first list item content - let firstListContent = [...contentBeforeCurrentPara]; - if (beforeCursor.size > 0) { - const modifiedFirstParagraph = editor.schema.nodes.paragraph.create(paragraphNode.attrs, beforeCursor); - firstListContent.push(modifiedFirstParagraph); - } + let firstLI, secondLI; - // Create second list item content - let secondListContent = []; - - // Always create a paragraph for the cursor position in the second list item - // If there's content after cursor, use it; otherwise create an empty paragraph - if (afterCursor && afterCursor.size > 0) { - const modifiedSecondParagraph = editor.schema.nodes.paragraph.create(paragraphNode.attrs, afterCursor); - secondListContent.push(modifiedSecondParagraph); - } else { - // Create an empty paragraph where the cursor will be positioned - const emptyParagraph = editor.schema.nodes.paragraph.create(paragraphNode.attrs); - secondListContent.push(emptyParagraph); + if (listItemHasMultipleParagraphs) { + // Content before/after the current paragraph + const contentBefore = []; + for (let i = 0; i < paragraphIndex; i++) contentBefore.push(listItemNode.child(i)); + + const contentAfter = []; + for (let i = paragraphIndex + 1; i < listItemNode.childCount; i++) contentAfter.push(listItemNode.child(i)); + + // First listItem content + const firstParas = [ + ...contentBefore, + paragraphNode.type.create(paragraphNode.attrs, beforeCursor.size ? beforeCursor : null), + ].filter(Boolean); + if (firstParas.length === 0) { + firstParas.push(state.schema.nodes.paragraph.create(paragraphNode.attrs)); } - // Add any paragraphs that come after the current one - secondListContent = secondListContent.concat(contentAfterCurrentPara); - - // Ensure we have at least one paragraph in each list item - if (firstListContent.length === 0) { - const emptyParagraph = editor.schema.nodes.paragraph.create(); - firstListContent = [emptyParagraph]; - } - if (secondListContent.length === 0) { - const emptyParagraph = editor.schema.nodes.paragraph.create(); - secondListContent = [emptyParagraph]; + // Second listItem content + const secondParas = [ + paragraphNode.type.create(paragraphNode.attrs, afterCursor.size ? afterCursor : null), + ...contentAfter, + ].filter(Boolean); + if (secondParas.length === 0) { + secondParas.push(state.schema.nodes.paragraph.create(paragraphNode.attrs)); } - // Create the lists - const firstListItem = editor.schema.nodes.listItem.create( - { ...listItemNode.attrs }, - Fragment.from(firstListContent), - ); - firstList = editor.schema.nodes.orderedList.createAndFill(parentListNode.attrs, Fragment.from(firstListItem)); - - const secondListItem = editor.schema.nodes.listItem.create( - { ...listItemNode.attrs }, - Fragment.from(secondListContent), - ); - secondList = editor.schema.nodes.orderedList.createAndFill(parentListNode.attrs, Fragment.from(secondListItem)); + firstLI = state.schema.nodes.listItem.create({ ...listItemNode.attrs }, Fragment.from(firstParas)); + secondLI = state.schema.nodes.listItem.create({ ...listItemNode.attrs }, Fragment.from(secondParas)); } else { - // Simple case: single paragraph, use original logic - let firstParagraphContent = beforeCursor; - if (beforeCursor.size === 0) { - firstParagraphContent = editor.schema.text(' ', marks); - } - - const firstParagraph = editor.schema.nodes.paragraph.create(paragraphNode.attrs, firstParagraphContent); - const firstListItem = editor.schema.nodes.listItem.create({ ...listItemNode.attrs }, firstParagraph); - firstList = editor.schema.nodes.orderedList.createAndFill(parentListNode.attrs, Fragment.from(firstListItem)); - - let secondParagraphContent = afterCursor; - if (afterCursor.size === 0) { - secondParagraphContent = editor.schema.text(' ', marks); - } + // Single paragraph listItem: keep empty paragraphs empty (no " ") + const firstParagraph = paragraphNode.type.create(paragraphNode.attrs, beforeCursor.size ? beforeCursor : null); + const secondParagraph = paragraphNode.type.create(paragraphNode.attrs, afterCursor.size ? afterCursor : null); - const secondParagraph = editor.schema.nodes.paragraph.create(paragraphNode.attrs, secondParagraphContent); - const secondListItem = editor.schema.nodes.listItem.create({ ...listItemNode.attrs }, secondParagraph); - secondList = editor.schema.nodes.orderedList.createAndFill(parentListNode.attrs, Fragment.from(secondListItem)); + firstLI = state.schema.nodes.listItem.create({ ...listItemNode.attrs }, firstParagraph); + secondLI = state.schema.nodes.listItem.create({ ...listItemNode.attrs }, secondParagraph); } + if (!firstLI || !secondLI) return false; + + // Build two new lists (each with exactly one listItem) + const ListType = parentListNode.type; // orderedList or bulletList + const firstList = ListType.createAndFill(parentListNode.attrs, Fragment.from(firstLI)); + const secondList = ListType.createAndFill(parentListNode.attrs, Fragment.from(secondLI)); if (!firstList || !secondList) return false; - // Replace the entire original list with the first list - const listStart = matchedParentList.pos; - const listEnd = matchedParentList.pos + parentListNode.nodeSize; + // Replace the ENTIRE current list with firstList, then insert secondList after it tr.replaceWith(listStart, listEnd, firstList); + const insertAfterFirst = listStart + firstList.nodeSize; + tr.insert(insertAfterFirst, secondList); - // Insert the second list after the first one - const insertPosition = listStart + firstList.nodeSize; - tr.insert(insertPosition, secondList); - - // Set selection at the beginning of the second list's paragraph - const secondListStart = insertPosition + 2; // +1 for list, +1 for listItem - tr.setSelection(TextSelection.near(tr.doc.resolve(secondListStart))); - tr.scrollIntoView(); + // Place cursor inside the second list's paragraph (list + listItem + paragraph) + const cursorPos = insertAfterFirst + 3; + tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos), 1)).scrollIntoView(); - // Retain any marks - // const marks = state.storedMarks || $from.marks() || []; - if (marks?.length) { - tr.ensureMarks(marks); - } tr.setMeta('splitListItem', true); - + if (dispatch) dispatch(tr); return true; }; /** * Handle the case where we are splitting a list item in an empty block. - * @param {Object} props The props object containing the editor state and transaction. - * @param {Object} currentListItem The current list item node info. - * @returns {boolean} Returns true if the split was handled, false otherwise. + * (kept as you had it, but creates a NEW list after the current list) */ const handleSplitInEmptyBlock = (props, currentListItem) => { const { state, editor, tr } = props; @@ -178,120 +118,58 @@ const handleSplitInEmptyBlock = (props, currentListItem) => { const { $from } = state.selection; const extensionAttrs = editor.extensionService.attributes; - // Find the list item node const listItemNode = currentListItem.node; - - // Check if we're in an empty paragraph but the list item has other content - // This happens after shift+enter creates an empty line const isEmptyParagraph = $from.parent.content.size === 0; - const listItemHasOtherContent = listItemNode.content.size > $from.parent.nodeSize; // More than just this empty paragraph - - // Check if we're at the very end of the list item - // If we're not at the end, we should split normally rather than create a new list + const listItemHasOtherContent = listItemNode.content.size > $from.parent.nodeSize; const isAtEndOfListItem = $from.indexAfter(-1) === $from.node(-1).childCount; if (isEmptyParagraph && listItemHasOtherContent && isAtEndOfListItem) { - // We're in an empty paragraph after shift+enter AND we're at the end - create a new list item try { const listTypes = ['orderedList', 'bulletList']; - const parentList = findParentNode((node) => listTypes.includes(node.type.name))(state.selection); - + const parentList = findParentNode((n) => listTypes.includes(n.type.name))(state.selection); if (!parentList) return false; - // Get attributes for the new paragraph const newParagraphAttrs = Attribute.getSplittedAttributes(extensionAttrs, 'paragraph', {}); - - // Create a new paragraph and list item with same attributes as current const newParagraph = schema.nodes.paragraph.create(newParagraphAttrs); const newListItem = schema.nodes.listItem.create({ ...listItemNode.attrs }, newParagraph); - const newList = schema.nodes.orderedList.createAndFill(parentList.node.attrs, Fragment.from(newListItem)); + const ListType = parentList.node.type; + const newList = ListType.createAndFill(parentList.node.attrs, Fragment.from(newListItem)); if (!newList) return false; - // Insert the new list after the current one const insertPos = parentList.pos + parentList.node.nodeSize; tr.insert(insertPos, newList); - // Set selection to the new list item - const newPos = insertPos + 2; // +1 for list, +1 for listItem + const newPos = insertPos + 3; // list + listItem + paragraph tr.setSelection(TextSelection.near(tr.doc.resolve(newPos))); tr.scrollIntoView(); return true; - } catch (error) { - console.error('Error creating new list item:', error); + } catch (e) { + console.error('Error creating new list item:', e); return false; } } - // If we're in an empty paragraph but NOT at the end of the list item, - // return false to let the normal split logic handle it - if (isEmptyParagraph && listItemHasOtherContent && !isAtEndOfListItem) { - return false; - } - - // Check if the list item is completely empty (only contains empty paragraphs) - const isListItemEmpty = () => { - if (listItemNode.childCount === 0) return true; - - // Check if all children are empty paragraphs - for (let i = 0; i < listItemNode.childCount; i++) { - const child = listItemNode.child(i); - if (child.type.name === 'paragraph' && child.content.size === 0) { - continue; // Empty paragraph, keep checking - } else if (child.type.name === 'paragraph' && child.content.size > 0) { - return false; // Non-empty paragraph found - } else { - return false; // Non-paragraph content found - } - } - return true; // All children are empty paragraphs - }; - - if (isListItemEmpty()) { - // First, try to outdent - const didOutdent = decreaseListIndent()({ editor, tr }); - if (didOutdent) return true; - - try { - // Find the parent list (orderedList or bulletList) - const listTypes = ['orderedList', 'bulletList']; - const parentList = findParentNode((node) => listTypes.includes(node.type.name))(state.selection); - - if (!parentList) { - console.error('No parent list found'); - return false; - } - - // Get attributes for the new paragraph - const newParagraphAttrs = Attribute.getSplittedAttributes(extensionAttrs, 'paragraph', {}); - - // Create a new paragraph node - const paragraphType = schema.nodes.paragraph; - let newParagraph = paragraphType.createAndFill(newParagraphAttrs); - - if (!newParagraph) { - newParagraph = paragraphType.create(); - } - - // Replace the ENTIRE LIST with a paragraph - const listStart = parentList.pos; - const listEnd = parentList.pos + parentList.node.nodeSize; + // If empty but not at end, let normal split handle it + if (isEmptyParagraph && listItemHasOtherContent && !isAtEndOfListItem) return false; - tr.replaceWith(listStart, listEnd, newParagraph); + // Destroy list when completely empty (unchanged) + const listTypes = ['orderedList', 'bulletList']; + const parentList = findParentNode((n) => listTypes.includes(n.type.name))(state.selection); + if (!parentList) return false; - // Position cursor at start of new paragraph - const newPos = listStart + 1; - tr.setSelection(TextSelection.near(tr.doc.resolve(newPos))); + const newParagraphAttrs = Attribute.getSplittedAttributes(extensionAttrs, 'paragraph', {}); + let newParagraph = schema.nodes.paragraph.createAndFill(newParagraphAttrs); + if (!newParagraph) newParagraph = schema.nodes.paragraph.create(); - tr.scrollIntoView(); + const listStart = parentList.pos; + const listEnd = parentList.pos + parentList.node.nodeSize; + tr.replaceWith(listStart, listEnd, newParagraph); - return true; - } catch (error) { - console.error('Error destroying list:', error); - return false; - } - } + const newPos = listStart + 1; + tr.setSelection(TextSelection.near(tr.doc.resolve(newPos))); + tr.scrollIntoView(); - return false; + return true; }; diff --git a/packages/super-editor/src/core/commands/splitListItem.test.js b/packages/super-editor/src/core/commands/splitListItem.test.js new file mode 100644 index 0000000000..9d907c6547 --- /dev/null +++ b/packages/super-editor/src/core/commands/splitListItem.test.js @@ -0,0 +1,321 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { schema as basic } from 'prosemirror-schema-basic'; +import { builders } from 'prosemirror-test-builder'; +import { toggleList } from './toggleList'; + +vi.mock('../helpers/findParentNode.js', () => { + function findParentNode(predicate) { + return (sel) => { + const $pos = sel.$from; + for (let d = $pos.depth; d >= 0; d--) { + const node = $pos.node(d); + if (predicate(node)) { + const pos = $pos.before(d); + return { node, pos, depth: d }; + } + } + return null; + }; + } + return { findParentNode }; +}); + +let __id = 1; +const calls = {}; +function track(name, payload) { + calls[name] ??= []; + calls[name].push(payload); +} + +vi.mock('@helpers/list-numbering-helpers.js', () => { + const ListHelpers = { + getNewListId() { + return __id++; + }, + generateNewListDefinition({ numId, listType }) { + track('generateNewListDefinition', { numId, listType: listType?.name || listType }); + }, + getListDefinitionDetails({ listType }) { + const isOrdered = (typeof listType === 'string' ? listType : listType?.name) === 'orderedList'; + return { + start: 1, + numFmt: isOrdered ? 'decimal' : 'bullet', + lvlText: isOrdered ? '%1.' : '•', + listNumberingType: isOrdered ? 'decimal' : 'bullet', + abstract: {}, + abstractId: '1', + }; + }, + createListItemNodeJSON({ level, lvlText, numId, numFmt, listLevel, contentNode }) { + const content = Array.isArray(contentNode) ? contentNode : [contentNode]; + return { + type: 'listItem', + attrs: { level, listLevel, numId, lvlText, numPrType: 'inline', listNumberingType: numFmt }, + content, + }; + }, + createSchemaOrderedListNode({ level, numId, listType, editor, listLevel, contentNode }) { + const isOrdered = (typeof listType === 'string' ? listType : listType?.name) === 'orderedList'; + const type = isOrdered ? 'orderedList' : 'bulletList'; + return editor.schema.nodeFromJSON({ + type, + attrs: { + listId: numId, + ...(isOrdered ? { order: level, 'list-style-type': 'decimal' } : { 'list-style-type': 'bullet' }), + }, + content: [ + ListHelpers.createListItemNodeJSON({ + level, + numId, + listLevel, + contentNode, + numFmt: isOrdered ? 'decimal' : 'bullet', + lvlText: isOrdered ? '%1.' : '•', + }), + ], + }); + }, + insertNewList: () => true, + changeNumIdSameAbstract: vi.fn(), + removeListDefinitions: vi.fn(), + getListItemStyleDefinitions: vi.fn(), + addInlineTextMarks: (_, marks) => marks || [], + baseOrderedListDef: {}, + baseBulletList: {}, + }; + return { ListHelpers }; +}); + +const listItemSpec = { + content: 'paragraph block*', + attrs: { + level: { default: 0 }, + listLevel: { default: [1] }, + numId: { default: null }, + lvlText: { default: null }, + numPrType: { default: null }, + listNumberingType: { default: null }, + }, + renderDOM() { + return ['li', 0]; + }, + parseDOM: () => [{ tag: 'li' }], +}; + +const orderedListSpec = { + group: 'block', + content: 'listItem+', + attrs: { + listId: { default: null }, + 'list-style-type': { default: 'decimal' }, + order: { default: 0 }, + }, + renderDOM() { + return ['ol', 0]; + }, + parseDOM: () => [{ tag: 'ol' }], +}; + +const bulletListSpec = { + group: 'block', + content: 'listItem+', + attrs: { + listId: { default: null }, + 'list-style-type': { default: 'bullet' }, + }, + renderDOM() { + return ['ul', 0]; + }, + parseDOM: () => [{ tag: 'ul' }], +}; + +const nodes = basic.spec.nodes + .update('paragraph', basic.spec.nodes.get('paragraph')) + .addToEnd('listItem', listItemSpec) + .addToEnd('orderedList', orderedListSpec) + .addToEnd('bulletList', bulletListSpec); + +const schema = new Schema({ nodes, marks: basic.spec.marks }); + +const { + doc, + p, + bulletList, + orderedList, + li: listItem, +} = builders(schema, { + doc: { nodeType: 'doc' }, + p: { nodeType: 'paragraph' }, + bulletList: { nodeType: 'bulletList' }, + orderedList: { nodeType: 'orderedList' }, + li: { nodeType: 'listItem' }, +}); + +function firstInlinePos(root) { + let pos = null; + root.descendants((node, p) => { + if (node.isTextblock && node.content.size > 0 && pos == null) { + pos = p + 1; // first position inside inline content + return false; + } + return true; + }); + return pos ?? 1; +} + +function lastInlinePos(root) { + let pos = null; + root.descendants((node, p) => { + if (node.isTextblock && node.content.size > 0) { + pos = p + node.content.size; // last position inside inline content + } + return true; + }); + return pos ?? Math.max(1, root.nodeSize - 2); +} + +function inlineSpanOf(root) { + const from = firstInlinePos(root); + const to = lastInlinePos(root); + return [from, Math.max(from, to)]; +} + +function selectionInsideFirstAndLastTextblocks(root) { + // Convenience for “inside first item to inside last item” + return inlineSpanOf(root); +} + +function createEditor(docNode) { + const editor = { + schema, + converter: { numbering: { definitions: {}, abstracts: {} } }, + emit: () => {}, + }; + const [from, to] = inlineSpanOf(docNode); + const state = EditorState.create({ + schema, + doc: docNode, + selection: TextSelection.create(docNode, from, to), + }); + return { editor, state }; +} + +function applyCmd(state, editor, cmd) { + let newState = state; + cmd({ + editor, + state, + tr: state.tr, + dispatch: (tr) => { + newState = state.apply(tr); + }, + }); + return newState; +} + +function getSelectionRange(st) { + return [st.selection.from, st.selection.to]; +} + +function hasNestedListInsideParagraph(root) { + let nested = false; + root.descendants((node) => { + if (node.type.name === 'paragraph') { + node.descendants((child) => { + if (child.type.name === 'bulletList' || child.type.name === 'orderedList') nested = true; + }); + } + }); + return nested; +} + +describe('toggleList', () => { + beforeEach(() => { + __id = 1; + Object.keys(calls).forEach((k) => delete calls[k]); + }); + + it('wraps multiple paragraphs into ordered list and preserves selection span', () => { + const d = doc(p('A'), p('B'), p('C')); + const { editor, state } = createEditor(d); + + // Select from inside first paragraph to inside last paragraph + const [from0, to0] = inlineSpanOf(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('orderedList')); + + const first = s2.doc.child(0); + expect(first.type.name).toBe('orderedList'); + + // Selection should still span the whole transformed region (rough heuristic) + const [from, to] = getSelectionRange(s2); + expect(from).toBeLessThanOrEqual(firstInlinePos(s2.doc)); + expect(to).toBeGreaterThan(lastInlinePos(s2.doc) - 1); + }); + + it('switches ordered: bullet in place (no nested lists)', () => { + const d = doc(orderedList(listItem(p('One')), listItem(p('Two')), listItem(p('Three')))); + const { editor, state } = createEditor(d); + const [from0, to0] = selectionInsideFirstAndLastTextblocks(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('bulletList')); + + const top = s2.doc.child(0); + expect(top.type.name).toBe('bulletList'); + expect(hasNestedListInsideParagraph(s2.doc)).toBe(false); + }); + + it('switches bullet: ordered using one shared numId for all items', () => { + const d = doc(bulletList(listItem(p('a')), listItem(p('b')), listItem(p('c')))); + const { editor, state } = createEditor(d); + + const [from0, to0] = selectionInsideFirstAndLastTextblocks(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('orderedList')); + + const list = s2.doc.child(0); + expect(list.type.name).toBe('orderedList'); + + const containerNumId = list.attrs.listId; + const numIds = new Set(); + list.forEach((li) => { + numIds.add(li.attrs.numId); + }); + expect(numIds.size).toBe(1); + expect(Array.from(numIds)[0]).toBe(containerNumId); + }); + + it('does not create a list inside another list when selection starts/ends inside items', () => { + const base = doc(orderedList(listItem(p('x')), listItem(p('y')), listItem(p('z')))); + const { editor, state } = createEditor(base); + const [from0, to0] = selectionInsideFirstAndLastTextblocks(base); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(base, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('bulletList')); + + const top = s2.doc.child(0); + expect(top.type.name).toBe('bulletList'); + expect(hasNestedListInsideParagraph(s2.doc)).toBe(false); + }); + + it('toggle-off unwraps list to paragraphs and preserves selection over unwrapped span', () => { + const d = doc(bulletList(listItem(p('alpha')), listItem(p('beta')))); + const { editor, state } = createEditor(d); + const [from0, to0] = selectionInsideFirstAndLastTextblocks(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('bulletList')); // same type: unwrap + + expect(s2.doc.child(0).type.name).toBe('paragraph'); + expect(s2.doc.child(1).type.name).toBe('paragraph'); + + const [from, to] = getSelectionRange(s2); + // Spans more than one paragraph's content + expect(to - from).toBeGreaterThan(s2.doc.child(0).nodeSize - 2); + }); +}); diff --git a/packages/super-editor/src/core/commands/toggleList.js b/packages/super-editor/src/core/commands/toggleList.js index 4cddba664f..ad70ce16dc 100644 --- a/packages/super-editor/src/core/commands/toggleList.js +++ b/packages/super-editor/src/core/commands/toggleList.js @@ -1,82 +1,272 @@ +import { TextSelection } from 'prosemirror-state'; import { findParentNode } from '../helpers/findParentNode.js'; import { ListHelpers } from '@helpers/list-numbering-helpers.js'; /** - * Create a new list either from blank or content - * If multiple paragraphs are selected, it will create a new list item for each paragraph. - * @param listTypeOrName The type/name of the list. - * @param itemTypeOrName The type/name of the list item. - * @param keepMarks Keep marks when toggling. - * @param attributes Attrs for the new list. + * Find the nearest list node at the given position. + * @param {import("prosemirror-model").ResolvedPos} $pos + * @param {import("prosemirror-model").NodeType} OrderedType + * @param {import("prosemirror-model").NodeType} BulletType + * @returns {{ node: import("prosemirror-model").Node, pos: number } | null} + */ +export function nearestListAt($pos, OrderedType, BulletType) { + for (let d = $pos.depth; d >= 0; d--) { + const node = $pos.node(d); + if (node.type === OrderedType || node.type === BulletType) { + return { node, pos: $pos.before(d), depth: d }; + } + } + return null; +} + +/** + * Collect all top-level list nodes that intersect with the given selection. + * @param {Object} param0 + * @param {import("prosemirror-model").Node} param0.doc - The ProseMirror document. + * @param {import("prosemirror-state").Selection} param0.selection - The ProseMirror selection. + * @param {import("prosemirror-model").NodeType} param0.OrderedType - The ordered list node type. + * @param {import("prosemirror-model").NodeType} param0.BulletType - The bullet list node type. + * @returns {Array} An array of intersecting list nodes. + */ +export function collectIntersectingTopLists({ doc, selection, OrderedType, BulletType }) { + const { from, to, $from, $to } = selection; + const hit = new Map(); + + const startList = nearestListAt($from, OrderedType, BulletType); + if (startList) hit.set(startList.pos, startList); + + const endList = nearestListAt($to, OrderedType, BulletType); + if (endList) hit.set(endList.pos, endList); + + doc.nodesBetween(from, to, (node, pos, parent) => { + const isList = node.type === OrderedType || node.type === BulletType; + if (!isList) return true; + const parentIsList = parent && (parent.type === OrderedType || parent.type === BulletType); + if (!parentIsList) hit.set(pos, { node, pos, depth: null }); + return false; + }); + + return Array.from(hit.values()).sort((a, b) => b.pos - a.pos); +} + +/** + * Rebuild a list node with a new numbering scheme. + * @param {Object} param0 + * @param {import("prosemirror-model").Node} param0.oldList - The old list node to rebuild. + * @param {import("prosemirror-model").NodeType} param0.toType - The target list node type. + * @param {import("prosemirror-view").EditorView} param0.editor - The ProseMirror editor view. + * @param {import("prosemirror-model").Schema} param0.schema - The ProseMirror schema. + * @param {String|null} param0.fixedNumId - A fixed numbering ID, if any. + * @returns {import("prosemirror-model").Node} + */ +export function rebuildListNodeWithNewNum({ oldList, toType, editor, schema, fixedNumId }) { + const OrderedType = schema.nodes.orderedList; + + const numId = fixedNumId ?? ListHelpers.getNewListId(editor); + if (fixedNumId == null) { + ListHelpers.generateNewListDefinition({ numId, listType: toType, editor }); + } + + const items = []; + for (let i = 0; i < oldList.childCount; i++) { + const li = oldList.child(i); + if (li.type.name !== 'listItem') continue; + + const level = Number(li.attrs?.level ?? 0); + const listLevel = Array.isArray(li.attrs?.listLevel) ? li.attrs.listLevel : [level + 1]; + + const { numFmt, lvlText } = ListHelpers.getListDefinitionDetails({ + numId, + level, + listType: toType, + editor, + }); + + const contentJSON = li.content ? li.content.toJSON() : []; + const firstBlock = contentJSON?.[0] ?? { type: 'paragraph', content: [] }; + + items.push( + ListHelpers.createListItemNodeJSON({ + level, + listLevel, + numId, + numFmt: numFmt ?? (toType === OrderedType ? 'decimal' : 'bullet'), + lvlText: lvlText ?? (toType === OrderedType ? '%1.' : '•'), + contentNode: firstBlock, + }), + ); + } + + const isOrdered = toType === OrderedType; + const containerJSON = { + type: isOrdered ? 'orderedList' : 'bulletList', + attrs: { + listId: numId, + 'list-style-type': isOrdered ? (items[0]?.attrs?.listNumberingType ?? 'decimal') : 'bullet', + ...(isOrdered ? { order: 0 } : {}), + }, + content: items, + }; + + return editor.schema.nodeFromJSON(containerJSON); +} + +/** + * Set the selection span in the transaction to match the original span. + * @param {import("prosemirror-state").Transaction} tr The ProseMirror transaction. + * @param {number} fromBefore The start position of the original span. + * @param {number} toBefore The end position of the original span. + */ +export function setMappedSelectionSpan(tr, fromBefore, toBefore) { + const mappedFrom = tr.mapping.map(fromBefore, -1); + const mappedTo = tr.mapping.map(toBefore, 1); + const $from = tr.doc.resolve(Math.max(1, Math.min(mappedFrom, tr.doc.content.size))); + const $to = tr.doc.resolve(Math.max(1, Math.min(mappedTo, tr.doc.content.size))); + tr.setSelection(TextSelection.between($from, $to)); +} + +/** + * Toggle a list type in the editor. + * @param {String} listType The type of list to toggle (e.g., "bulletList" or "orderedList"). + * @returns {Function} The command function. */ export const toggleList = (listType) => - ({ editor, state, tr }) => { - const { selection } = state; - const { from, to } = selection; - - // Check if we're already in a list of this type - const isList = findParentNode((node) => node.type === listType)(tr.selection); - - if (!isList) { - // If selection spans multiple nodes, handle each paragraph separately - if (!selection.empty && from !== to) { - const paragraphsToConvert = []; - - // Collect all paragraph nodes that are fully or partially selected - state.doc.nodesBetween(from, to, (node, pos) => { - if (node.type.name === 'paragraph') { - // Check if this paragraph intersects with the selection - const nodeFrom = pos; - const nodeTo = pos + node.nodeSize; - - if (nodeFrom < to && nodeTo > from) { - paragraphsToConvert.push({ - node, - pos, - from: Math.max(nodeFrom, from), - to: Math.min(nodeTo, to), - }); - } - } - return false; + ({ editor, state, tr, dispatch }) => { + const { selection, doc } = state; + const { from, to, empty } = selection; + + const OrderedType = editor.schema.nodes.orderedList; + const BulletType = editor.schema.nodes.bulletList; + const TargetType = typeof listType === 'string' ? editor.schema.nodes[listType] : listType; + + const sameListAtCursor = findParentNode((n) => n.type === TargetType)(selection); + + // 1) Same type: unwrap, then restore span over the unwrapped content + if (sameListAtCursor) { + const { pos, node } = sameListAtCursor; + const spanFromBefore = pos; + const spanToBefore = pos + node.nodeSize; + + const paras = []; + for (let i = 0; i < node.childCount; i++) { + const li = node.child(i); + paras.push(li.firstChild || editor.schema.nodes.paragraph.create()); + } + tr.replaceWith(pos, pos + node.nodeSize, paras); + + setMappedSelectionSpan(tr, spanFromBefore, spanToBefore); + if (dispatch) dispatch(tr); + return true; + } + + // 2) Intersects list(s): convert containers; keep whole touched span selected + const touchedLists = collectIntersectingTopLists({ doc, selection, OrderedType, BulletType }); + + if (touchedLists.length > 0) { + // Compute span BEFORE we start mutating + let spanFromBefore = Infinity; + let spanToBefore = -Infinity; + for (const { node, pos } of touchedLists) { + spanFromBefore = Math.min(spanFromBefore, pos); + spanToBefore = Math.max(spanToBefore, pos + node.nodeSize); + } + + const switchingToOrdered = TargetType === OrderedType; + let sharedNumId = null; + if (switchingToOrdered) { + sharedNumId = ListHelpers.getNewListId(editor); + ListHelpers.generateNewListDefinition({ numId: sharedNumId, listType: TargetType, editor }); + } + + for (const { node: oldList, pos } of touchedLists) { + const mapped = tr.mapping.map(pos); + const newList = rebuildListNodeWithNewNum({ + oldList, + toType: TargetType, + editor, + schema: editor.schema, + fixedNumId: switchingToOrdered ? sharedNumId : null, }); + tr.replaceWith(mapped, mapped + oldList.nodeSize, newList); + } - if (paragraphsToConvert.length > 1) { - // Create a single new list definition that all lists will share - const numId = ListHelpers.getNewListId(editor); - if (typeof listType === 'string') listType = editor.schema.nodes[listType]; - - ListHelpers.generateNewListDefinition({ numId, listType, editor }); - - // Process paragraphs from end to beginning to avoid position shifts - paragraphsToConvert.reverse().forEach(({ node, pos }) => { - const level = 0; - const listLevel = [1]; - - // Create a new list with the shared numId - const listNode = ListHelpers.createSchemaOrderedListNode({ - level, - numId, - listType, - editor, - listLevel, - contentNode: node.toJSON(), - }); - - // Replace the paragraph with the new list - const replaceFrom = pos; - const replaceTo = pos + node.nodeSize; - ListHelpers.insertNewList(tr, replaceFrom, replaceTo, listNode); - }); + setMappedSelectionSpan(tr, spanFromBefore, spanToBefore); + if (dispatch) dispatch(tr); + return true; + } - return true; + // 3) Not in/over a list: wrap paragraphs (multi-node); keep whole original span selected + + /** + * Collect all paragraph nodes in the current selection. + * @returns {Array<{ node: Node, pos: number }>} An array of paragraph nodes and their positions. + */ + const collectParagraphs = () => { + const out = []; + doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name === 'paragraph') { + const nodeFrom = pos, + nodeTo = pos + node.nodeSize; + if (nodeFrom < to && nodeTo > from) out.push({ node, pos }); + return false; + } + return true; + }); + return out; + }; + + if (!empty && from !== to) { + const paragraphs = collectParagraphs(); + if (paragraphs.length > 1) { + // span BEFORE mutations + let spanFromBefore = Math.min(...paragraphs.map((p) => p.pos)); + let spanToBefore = Math.max(...paragraphs.map((p) => p.pos + p.node.nodeSize)); + + const numId = ListHelpers.getNewListId(editor); + ListHelpers.generateNewListDefinition({ numId, listType: TargetType, editor }); + + // Replace from end to start to avoid shifting + for (let i = paragraphs.length - 1; i >= 0; i--) { + const { node, pos } = paragraphs[i]; + const listNode = ListHelpers.createSchemaOrderedListNode({ + level: 0, + numId, + listType: TargetType, + editor, + listLevel: [1], + contentNode: node.toJSON(), + }); + // Do not rely on insertNewList’s selection side-effect; we’ll set it after the loop + tr.replaceWith(pos, pos + node.nodeSize, listNode); } - } - // Single paragraph or no multi-paragraph selection - use existing logic - return ListHelpers.createNewList({ listType, tr, editor }); + setMappedSelectionSpan(tr, spanFromBefore, spanToBefore); + if (dispatch) dispatch(tr); + return true; + } } - return false; + // Single paragraph case (keep default caret behavior) + const paraAtCursor = findParentNode((n) => n.type.name === 'paragraph')(selection); + if (!paraAtCursor) return false; + + { + const { node: paragraph, pos } = paraAtCursor; + const numId = ListHelpers.getNewListId(editor); + ListHelpers.generateNewListDefinition({ numId, listType: TargetType, editor }); + + const listNode = ListHelpers.createSchemaOrderedListNode({ + level: 0, + numId, + listType: TargetType, + editor, + listLevel: [1], + contentNode: paragraph.toJSON(), + }); + + tr.replaceWith(pos, pos + paragraph.nodeSize, listNode); + if (dispatch) dispatch(tr); + return true; + } }; diff --git a/packages/super-editor/src/core/commands/toggleList.test.js b/packages/super-editor/src/core/commands/toggleList.test.js new file mode 100644 index 0000000000..b3739e96bd --- /dev/null +++ b/packages/super-editor/src/core/commands/toggleList.test.js @@ -0,0 +1,556 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Schema, Node as PMNode } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { schema as basic } from 'prosemirror-schema-basic'; +import { builders } from 'prosemirror-test-builder'; +import { toggleList } from './toggleList'; +import { + nearestListAt, + collectIntersectingTopLists, + rebuildListNodeWithNewNum, + setMappedSelectionSpan, +} from './toggleList'; + +vi.mock('../helpers/findParentNode.js', () => { + function findParentNode(predicate) { + return (sel) => { + const $pos = sel.$from; + for (let d = $pos.depth; d >= 0; d--) { + const node = $pos.node(d); + if (predicate(node)) { + const pos = $pos.before(d); + return { node, pos, depth: d }; + } + } + return null; + }; + } + return { findParentNode }; +}); + +let __id = 1; +const calls = {}; +function track(name, payload) { + calls[name] ??= []; + calls[name].push(payload); +} + +vi.mock('@helpers/list-numbering-helpers.js', () => { + const ListHelpers = { + getNewListId() { + return __id++; + }, + generateNewListDefinition({ numId, listType }) { + track('generateNewListDefinition', { numId, listType: listType?.name || listType }); + }, + getListDefinitionDetails({ listType }) { + const isOrdered = (typeof listType === 'string' ? listType : listType?.name) === 'orderedList'; + return { + start: 1, + numFmt: isOrdered ? 'decimal' : 'bullet', + lvlText: isOrdered ? '%1.' : '•', + listNumberingType: isOrdered ? 'decimal' : 'bullet', + abstract: {}, + abstractId: '1', + }; + }, + createListItemNodeJSON({ level, lvlText, numId, numFmt, listLevel, contentNode }) { + const content = Array.isArray(contentNode) ? contentNode : [contentNode]; + return { + type: 'listItem', + attrs: { level, listLevel, numId, lvlText, numPrType: 'inline', listNumberingType: numFmt }, + content, + }; + }, + createSchemaOrderedListNode({ level, numId, listType, editor, listLevel, contentNode }) { + const isOrdered = (typeof listType === 'string' ? listType : listType?.name) === 'orderedList'; + const type = isOrdered ? 'orderedList' : 'bulletList'; + return editor.schema.nodeFromJSON({ + type, + attrs: { + listId: numId, + ...(isOrdered ? { order: level, 'list-style-type': 'decimal' } : { 'list-style-type': 'bullet' }), + }, + content: [ + ListHelpers.createListItemNodeJSON({ + level, + numId, + listLevel, + contentNode, + numFmt: isOrdered ? 'decimal' : 'bullet', + lvlText: isOrdered ? '%1.' : '•', + }), + ], + }); + }, + insertNewList: () => true, + changeNumIdSameAbstract: vi.fn(), + removeListDefinitions: vi.fn(), + getListItemStyleDefinitions: vi.fn(), + addInlineTextMarks: (_, marks) => marks || [], + baseOrderedListDef: {}, + baseBulletList: {}, + }; + return { ListHelpers }; +}); + +const listItemSpec = { + content: 'paragraph block*', + attrs: { + level: { default: 0 }, + listLevel: { default: [1] }, + numId: { default: null }, + lvlText: { default: null }, + numPrType: { default: null }, + listNumberingType: { default: null }, + }, + renderDOM() { + return ['li', 0]; + }, + parseDOM: () => [{ tag: 'li' }], +}; + +const orderedListSpec = { + group: 'block', + content: 'listItem+', + attrs: { + listId: { default: null }, + 'list-style-type': { default: 'decimal' }, + order: { default: 0 }, + }, + renderDOM() { + return ['ol', 0]; + }, + parseDOM: () => [{ tag: 'ol' }], +}; + +const bulletListSpec = { + group: 'block', + content: 'listItem+', + attrs: { + listId: { default: null }, + 'list-style-type': { default: 'bullet' }, + }, + renderDOM() { + return ['ul', 0]; + }, + parseDOM: () => [{ tag: 'ul' }], +}; + +const nodes = basic.spec.nodes + .update('paragraph', basic.spec.nodes.get('paragraph')) + .addToEnd('listItem', listItemSpec) + .addToEnd('orderedList', orderedListSpec) + .addToEnd('bulletList', bulletListSpec); + +const schema = new Schema({ nodes, marks: basic.spec.marks }); + +const { + doc, + p, + bulletList, + orderedList, + li: listItem, +} = builders(schema, { + doc: { nodeType: 'doc' }, + p: { nodeType: 'paragraph' }, + bulletList: { nodeType: 'bulletList' }, + orderedList: { nodeType: 'orderedList' }, + li: { nodeType: 'listItem' }, +}); + +function firstInlinePos(root) { + let pos = null; + root.descendants((node, p) => { + if (node.isTextblock && node.content.size > 0 && pos == null) { + pos = p + 1; // first position inside inline content + return false; + } + return true; + }); + return pos ?? 1; +} + +function lastInlinePos(root) { + let pos = null; + root.descendants((node, p) => { + if (node.isTextblock && node.content.size > 0) { + pos = p + node.content.size; // last position inside inline content + } + return true; + }); + return pos ?? Math.max(1, root.nodeSize - 2); +} + +function inlineSpanOf(root) { + const from = firstInlinePos(root); + const to = lastInlinePos(root); + return [from, Math.max(from, to)]; +} + +function selectionInsideFirstAndLastTextblocks(root) { + // Convenience for “inside first item to inside last item” + return inlineSpanOf(root); +} + +function createEditor(docNode) { + const editor = { + schema, + converter: { numbering: { definitions: {}, abstracts: {} } }, + emit: () => {}, + }; + const [from, to] = inlineSpanOf(docNode); + const state = EditorState.create({ + schema, + doc: docNode, + selection: TextSelection.create(docNode, from, to), + }); + return { editor, state }; +} + +function applyCmd(state, editor, cmd) { + let newState = state; + cmd({ + editor, + state, + tr: state.tr, + dispatch: (tr) => { + newState = state.apply(tr); + }, + }); + return newState; +} + +function getSelectionRange(st) { + return [st.selection.from, st.selection.to]; +} + +function hasNestedListInsideParagraph(root) { + let nested = false; + root.descendants((node) => { + if (node.type.name === 'paragraph') { + node.descendants((child) => { + if (child.type.name === 'bulletList' || child.type.name === 'orderedList') nested = true; + }); + } + }); + return nested; +} + +describe('toggleList', () => { + beforeEach(() => { + __id = 1; + Object.keys(calls).forEach((k) => delete calls[k]); + }); + + it('wraps multiple paragraphs into ordered list and preserves selection span', () => { + const d = doc(p('A'), p('B'), p('C')); + const { editor, state } = createEditor(d); + + // Select from inside first paragraph to inside last paragraph + const [from0, to0] = inlineSpanOf(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('orderedList')); + + const first = s2.doc.child(0); + expect(first.type.name).toBe('orderedList'); + + // Selection should still span the whole transformed region (rough heuristic) + const [from, to] = getSelectionRange(s2); + expect(from).toBeLessThanOrEqual(firstInlinePos(s2.doc)); + expect(to).toBeGreaterThan(lastInlinePos(s2.doc) - 1); + }); + + it('switches ordered: bullet in place (no nested lists)', () => { + const d = doc(orderedList(listItem(p('One')), listItem(p('Two')), listItem(p('Three')))); + const { editor, state } = createEditor(d); + const [from0, to0] = selectionInsideFirstAndLastTextblocks(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('bulletList')); + + const top = s2.doc.child(0); + expect(top.type.name).toBe('bulletList'); + expect(hasNestedListInsideParagraph(s2.doc)).toBe(false); + }); + + it('switches bullet: ordered using one shared numId for all items', () => { + const d = doc(bulletList(listItem(p('a')), listItem(p('b')), listItem(p('c')))); + const { editor, state } = createEditor(d); + + const [from0, to0] = selectionInsideFirstAndLastTextblocks(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('orderedList')); + + const list = s2.doc.child(0); + expect(list.type.name).toBe('orderedList'); + + const containerNumId = list.attrs.listId; + const numIds = new Set(); + list.forEach((li) => { + numIds.add(li.attrs.numId); + }); + expect(numIds.size).toBe(1); + expect(Array.from(numIds)[0]).toBe(containerNumId); + }); + + it('does not create a list inside another list when selection starts/ends inside items', () => { + const base = doc(orderedList(listItem(p('x')), listItem(p('y')), listItem(p('z')))); + const { editor, state } = createEditor(base); + const [from0, to0] = selectionInsideFirstAndLastTextblocks(base); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(base, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('bulletList')); + + const top = s2.doc.child(0); + expect(top.type.name).toBe('bulletList'); + expect(hasNestedListInsideParagraph(s2.doc)).toBe(false); + }); + + it('toggle-off unwraps list to paragraphs and preserves selection over unwrapped span', () => { + const d = doc(bulletList(listItem(p('alpha')), listItem(p('beta')))); + const { editor, state } = createEditor(d); + const [from0, to0] = selectionInsideFirstAndLastTextblocks(d); + const s1 = state.apply(state.tr.setSelection(TextSelection.create(d, from0, to0))); + + const s2 = applyCmd(s1, editor, toggleList('bulletList')); // same type: unwrap + + expect(s2.doc.child(0).type.name).toBe('paragraph'); + expect(s2.doc.child(1).type.name).toBe('paragraph'); + + const [from, to] = getSelectionRange(s2); + // Spans more than one paragraph's content + expect(to - from).toBeGreaterThan(s2.doc.child(0).nodeSize - 2); + }); +}); + +describe('nearestListAt', () => { + it('finds the nearest ordered list ancestor with correct pos', () => { + const d = doc(p('before'), orderedList(listItem(p('one')), listItem(p('two'))), p('after')); + + const pos = firstInlinePos(d); // inside "before" + // move to inside "one" + let foundPos = null; + d.descendants((node, p) => { + if (node.type.name === 'paragraph' && node.textContent.includes('one') && foundPos == null) { + foundPos = p + 1; + return false; + } + return true; + }); + + const { state } = createEditor(d); + const $pos = state.doc.resolve(foundPos); + + const res = nearestListAt($pos, schema.nodes.orderedList, schema.nodes.bulletList); + expect(res).not.toBeNull(); + expect(res.node.type.name).toBe('orderedList'); + + const listAtPos = state.doc.nodeAt(res.pos); + expect(listAtPos).toBe(res.node); + }); + + it('returns null when outside any list', () => { + const d = doc(p('hello'), p('world')); + const { state } = createEditor(d); + const $pos = state.doc.resolve(firstInlinePos(d)); + const res = nearestListAt($pos, schema.nodes.orderedList, schema.nodes.bulletList); + expect(res).toBeNull(); + }); + + it('prefers the inner list when nested (ol inside ul)', () => { + const d = doc(bulletList(listItem(orderedList(listItem(p('deep')))))); + // inside "deep" + let inside = null; + d.descendants((node, p) => { + if (node.type.name === 'paragraph' && node.textContent.includes('deep') && inside == null) { + inside = p + 1; + return false; + } + return true; + }); + const { state } = createEditor(d); + const $pos = state.doc.resolve(inside); + const res = nearestListAt($pos, schema.nodes.orderedList, schema.nodes.bulletList); + expect(res).not.toBeNull(); + expect(res.node.type.name).toBe('orderedList'); // inner list + }); +}); + +describe('collectIntersectingTopLists', () => { + function posInsideText(docNode, needle) { + let found = null; + docNode.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.textContent.includes(needle) && found == null) { + found = pos + 1; // first inline pos inside that paragraph + return false; // stop at the first match + } + return true; + }); + if (found == null) throw new Error(`Could not find paragraph containing "${needle}"`); + return found; + } + + it('collects deduped top-level lists intersecting the selection (sorted desc by pos)', () => { + const d = doc( + p('pre'), + orderedList(listItem(p('A1')), listItem(p('A2'))), + p('mid'), + bulletList(listItem(p('B1')), listItem(p('B2'))), + p('post'), + ); + + // from just inside A2: just inside B1 + const from = posInsideText(d, 'A2'); + const to = posInsideText(d, 'B1') + 1; // ensure the range reaches into list 2 + + const state = EditorState.create({ + schema, + doc: d, + selection: TextSelection.create(d, from, to), + }); + + const result = collectIntersectingTopLists({ + doc: d, + selection: state.selection, + OrderedType: schema.nodes.orderedList, + BulletType: schema.nodes.bulletList, + }); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(result.map((r) => r.node.type.name)).toEqual(['bulletList', 'orderedList']); + result.forEach((r) => { + const $pos = d.resolve(r.pos); + expect($pos.depth).toBe(0); + expect($pos.nodeAfter).toBe(r.node); + }); + }); + + it('returns only the single list when selection is within one', () => { + const list = orderedList(listItem(p('X')), listItem(p('Y'))); + const d = doc(p('before'), list, p('after')); + + const from = posInsideText(d, 'X'); + const to = posInsideText(d, 'Y') + 1; + + const state = EditorState.create({ + schema, + doc: d, + selection: TextSelection.create(d, from, to), + }); + + const result = collectIntersectingTopLists({ + doc: d, + selection: state.selection, + OrderedType: schema.nodes.orderedList, + BulletType: schema.nodes.bulletList, + }); + + expect(result.length).toBe(1); + expect(result[0].node).toBe(list); + }); +}); + +describe('rebuildListNodeWithNewNum', () => { + beforeEach(() => { + __id = 1; + Object.keys(calls).forEach((k) => delete calls[k]); + }); + + it('builds a new ordered list with a fresh listId and remapped items', () => { + const oldList = schema.node('orderedList', { listId: 'OLD' }, [ + schema.node('listItem', { level: 0 }, [p('a')]), + schema.node('listItem', { level: 1 }, [p('b')]), + ]); + + const { editor } = createEditor(doc(oldList)); + const newList = rebuildListNodeWithNewNum({ + oldList, + toType: schema.nodes.orderedList, + editor, + schema, + fixedNumId: null, + }); + + expect(newList.type.name).toBe('orderedList'); + expect(newList.attrs.listId).toBe(1); // first id from mocked getNewListId() + expect(newList.childCount).toBe(2); + + expect(calls.generateNewListDefinition).toBeTruthy(); + expect(calls.generateNewListDefinition[0].numId).toBe(1); + expect(calls.generateNewListDefinition[0].listType).toBe('orderedList'); + }); + + it('respects fixedNumId and does not allocate a new one', () => { + const oldList = schema.node('bulletList', { listId: 'ANY' }, [ + schema.node('listItem', {}, [p('x')]), + schema.node('listItem', {}, [p('y')]), + ]); + + const { editor } = createEditor(doc(oldList)); + const newList = rebuildListNodeWithNewNum({ + oldList, + toType: schema.nodes.bulletList, + editor, + schema, + fixedNumId: 'FIXED-7', + }); + + expect(newList.type.name).toBe('bulletList'); + expect(newList.attrs.listId).toBe('FIXED-7'); + // with fixedNumId, no new definition should be generated + expect(calls.generateNewListDefinition).toBeUndefined(); + }); +}); + +describe('setMappedSelectionSpan', () => { + // Find the first inline position inside the paragraph that contains `needle` + function posInsideText(docNode, needle) { + let found = null; + docNode.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.textContent.includes(needle) && found == null) { + found = pos + 1; // first inline pos inside that paragraph + return false; + } + return true; + }); + if (found == null) throw new Error(`Could not find paragraph containing "${needle}"`); + return found; + } + + it('remaps selection after an insertion before it', () => { + const d = doc(p('hello'), p('world')); + const state = EditorState.create({ schema, doc: d }); + + // Select within the SECOND paragraph to avoid boundary behavior at pos 1 + const fromBefore = posInsideText(d, 'world'); + const toBefore = fromBefore + 'world'.length; + + let tr = state.tr.setSelection(TextSelection.create(d, fromBefore, toBefore)); + + // Insert at the very start of the doc (clearly before the selected span) + tr = tr.insert(1, schema.text('X')); + + setMappedSelectionSpan(tr, fromBefore, toBefore); + + expect(tr.selection.from).toBe(fromBefore + 1); + expect(tr.selection.to).toBe(toBefore + 1); + }); + + it('clamps mapped positions to valid doc bounds', () => { + const d = doc(p('hi')); + const state = EditorState.create({ schema, doc: d }); + + const fromBefore = 10_000; + const toBefore = 20_000; + + const tr = state.tr; + setMappedSelectionSpan(tr, fromBefore, toBefore); + + expect(tr.selection.from).toBeGreaterThanOrEqual(1); + expect(tr.selection.to).toBeLessThanOrEqual(tr.doc.content.size); + }); +}); diff --git a/packages/super-editor/src/core/helpers/createNodeFromContent.js b/packages/super-editor/src/core/helpers/createNodeFromContent.js index a85c6016f8..081c379476 100644 --- a/packages/super-editor/src/core/helpers/createNodeFromContent.js +++ b/packages/super-editor/src/core/helpers/createNodeFromContent.js @@ -76,7 +76,7 @@ export function createNodeFromContent(content, schema, options) { __supereditor__private__unknown__catch__all__node: { content: 'inline*', group: 'block', - parseDOM: [ + parseDOM: () => [ { tag: '*', getAttrs: (e) => { diff --git a/packages/super-editor/src/core/helpers/findParentNode.js b/packages/super-editor/src/core/helpers/findParentNode.js index 058f6da83b..3983566132 100644 --- a/packages/super-editor/src/core/helpers/findParentNode.js +++ b/packages/super-editor/src/core/helpers/findParentNode.js @@ -1,9 +1,14 @@ +// @ts-check import { findParentNodeClosestToPos } from './findParentNodeClosestToPos'; +/** + * @typedef {import("./findParentNodeClosestToPos").ParentNodeInfo} ParentNodeInfo + */ + /** * Find the closest parent node to the current selection that matches a predicate. - * @param predicate Predicate to match. - * @returns Command that finds the closest parent node. + * @param {function(import("prosemirror-model").Node): boolean} predicate - A function that takes a node and returns true if it matches the desired condition. + * @returns {function(Object): ParentNodeInfo|null} A function that takes a ProseMirror selection and returns the closest matching parent node, or null if none is found. * * https://github.com/atlassian/prosemirror-utils/blob/master/src/selection.ts#L17 */ diff --git a/packages/super-editor/src/core/helpers/findParentNodeClosestToPos.js b/packages/super-editor/src/core/helpers/findParentNodeClosestToPos.js index 1020f369d5..77ce33a34c 100644 --- a/packages/super-editor/src/core/helpers/findParentNodeClosestToPos.js +++ b/packages/super-editor/src/core/helpers/findParentNodeClosestToPos.js @@ -1,8 +1,18 @@ +// @ts-check + +/** + * @typedef {Object} ParentNodeInfo + * @property {number} pos - The position of the parent node. + * @property {number} start - The start position of the parent node. + * @property {number} depth - The depth of the parent node. + * @property {import("prosemirror-model").Node} node - The parent node. + */ + /** * Finds the closest parent node to a resolved position that matches a predicate. - * @param $pos Resolved position. - * @param predicate Predicate to match. - * @returns Closest parent node to the resolved position that matches the predicate. + * @param {import("prosemirror-model").ResolvedPos} $pos - The resolved position. + * @param {function(import("prosemirror-model").Node): boolean} predicate - Predicate to match. + * @returns {ParentNodeInfo|null} Closest parent node to the resolved position that matches the predicate. * * https://github.com/atlassian/prosemirror-utils/blob/master/src/selection.ts#L57 */ diff --git a/packages/super-editor/src/extensions/structured-content/document-section.test.js b/packages/super-editor/src/extensions/structured-content/document-section.test.js index 033f32ed01..1edc2592af 100644 --- a/packages/super-editor/src/extensions/structured-content/document-section.test.js +++ b/packages/super-editor/src/extensions/structured-content/document-section.test.js @@ -83,7 +83,7 @@ function docHTML(schema, doc) { return wrap.innerHTML; } -describe('DocumentSection.updateSectionById (JS only)', () => { +describe('DocumentSection.updateSectionById', () => { let schema; beforeEach(() => {