diff --git a/packages/layout-engine/dom-contract/src/class-names.ts b/packages/layout-engine/dom-contract/src/class-names.ts index f5fdf88d43..98e682124e 100644 --- a/packages/layout-engine/dom-contract/src/class-names.ts +++ b/packages/layout-engine/dom-contract/src/class-names.ts @@ -48,6 +48,9 @@ export const DOM_CLASS_NAMES = { /** Inline image element (ImageRun inside a paragraph). */ INLINE_IMAGE: 'superdoc-inline-image', + /** Wrapper around a paragraph's list marker (bullet glyph or ordered number). */ + LIST_MARKER: 'superdoc-list-marker', + /** Clip wrapper around a cropped inline image. */ INLINE_IMAGE_CLIP_WRAPPER: 'superdoc-inline-image-clip-wrapper', diff --git a/packages/layout-engine/dom-contract/src/index.test.ts b/packages/layout-engine/dom-contract/src/index.test.ts index a55fa0c340..5cab9112d5 100644 --- a/packages/layout-engine/dom-contract/src/index.test.ts +++ b/packages/layout-engine/dom-contract/src/index.test.ts @@ -28,6 +28,7 @@ describe('@superdoc/dom-contract', () => { SDT_GROUP_HOVER: 'sdt-group-hover', IMAGE_FRAGMENT: 'superdoc-image-fragment', INLINE_IMAGE: 'superdoc-inline-image', + LIST_MARKER: 'superdoc-list-marker', INLINE_IMAGE_CLIP_WRAPPER: 'superdoc-inline-image-clip-wrapper', ANNOTATION: 'annotation', ANNOTATION_CONTENT: 'annotation-content', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index b5a4608305..dd0ff0c2f1 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -110,6 +110,7 @@ import { applyImageClipPath } from './utils/image-clip-path.js'; import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import { computeTabWidth, + createListMarkerElement, resolvePainterListMarkerGeometry, resolvePainterListTextStartPx, } from './utils/marker-helpers.js'; @@ -526,7 +527,7 @@ function compactSnapshotObject>(input: T): T { return out; } -function applySourceAnchorDataset(element: HTMLElement, sourceAnchor?: SourceAnchor): void { +export function applySourceAnchorDataset(element: HTMLElement, sourceAnchor?: SourceAnchor): void { if (!sourceAnchor) { delete element.dataset.sourceAnchor; delete element.dataset.sourceNodeId; @@ -3215,15 +3216,12 @@ export class DomPainter { lineEl.style.paddingLeft = `${resolvedMarker.firstLinePaddingLeftPx}px`; if (!resolvedMarker.vanish) { - const markerContainer = this.doc!.createElement('span'); - markerContainer.style.display = 'inline-block'; - markerContainer.style.wordSpacing = '0px'; - - const markerEl = this.doc!.createElement('span'); - markerEl.classList.add('superdoc-paragraph-marker'); - markerEl.textContent = resolvedMarker.text; - applySourceAnchorDataset(markerEl, resolvedMarker.sourceAnchor ?? resolvedItem?.sourceAnchor); - markerEl.style.pointerEvents = 'none'; + const markerContainer = createListMarkerElement( + this.doc!, + resolvedMarker.text, + resolvedMarker.run, + resolvedMarker.sourceAnchor ?? resolvedItem?.sourceAnchor, + ); markerContainer.style.position = 'relative'; if (resolvedMarker.justification === 'right') { @@ -3236,19 +3234,6 @@ export class DomPainter { parseFloat(lineEl.style.paddingLeft) + (resolvedMarker.centerPaddingAdjustPx ?? 0) + 'px'; } - markerEl.style.fontFamily = - toCssFontFamily(resolvedMarker.run.fontFamily) ?? resolvedMarker.run.fontFamily; - markerEl.style.fontSize = `${resolvedMarker.run.fontSize}px`; - markerEl.style.fontWeight = resolvedMarker.run.bold ? 'bold' : ''; - markerEl.style.fontStyle = resolvedMarker.run.italic ? 'italic' : ''; - if (resolvedMarker.run.color) { - markerEl.style.color = resolvedMarker.run.color; - } - if (resolvedMarker.run.letterSpacing != null) { - markerEl.style.letterSpacing = `${resolvedMarker.run.letterSpacing}px`; - } - markerContainer.appendChild(markerEl); - if (resolvedMarker.suffix === 'tab') { const tabEl = this.doc!.createElement('span'); tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); @@ -3432,15 +3417,12 @@ export class DomPainter { lineEl.style.paddingLeft = `${paraIndentLeft + (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0)}px`; if (!marker.run.vanish) { - const markerContainer = this.doc!.createElement('span'); - markerContainer.style.display = 'inline-block'; - markerContainer.style.wordSpacing = '0px'; - - const markerEl = this.doc!.createElement('span'); - markerEl.classList.add('superdoc-paragraph-marker'); - markerEl.textContent = marker.markerText ?? ''; - applySourceAnchorDataset(markerEl, block.sourceAnchor ?? resolvedItem?.sourceAnchor); - markerEl.style.pointerEvents = 'none'; + const markerContainer = createListMarkerElement( + this.doc!, + marker.markerText ?? '', + marker.run, + block.sourceAnchor ?? resolvedItem?.sourceAnchor, + ); const markerJustification = marker.justification ?? 'left'; @@ -3454,18 +3436,6 @@ export class DomPainter { lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; } - markerEl.style.fontFamily = toCssFontFamily(marker.run.fontFamily) ?? marker.run.fontFamily; - markerEl.style.fontSize = `${marker.run.fontSize}px`; - markerEl.style.fontWeight = marker.run.bold ? 'bold' : ''; - markerEl.style.fontStyle = marker.run.italic ? 'italic' : ''; - if (marker.run.color) { - markerEl.style.color = marker.run.color; - } - if (marker.run.letterSpacing != null) { - markerEl.style.letterSpacing = `${marker.run.letterSpacing}px`; - } - markerContainer.appendChild(markerEl); - const suffix = marker.suffix ?? 'tab'; if (suffix === 'tab') { const tabEl = this.doc!.createElement('span'); @@ -3655,7 +3625,7 @@ export class DomPainter { } const markerEl = this.doc.createElement('span'); - markerEl.classList.add('superdoc-list-marker'); + markerEl.classList.add(DOM_CLASS_NAMES.LIST_MARKER); applySourceAnchorDataset(markerEl, item.marker.sourceAnchor ?? item.sourceAnchor ?? resolvedItem?.sourceAnchor); // Track B: Use marker styling from wordLayout if available diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index b3040178f0..36c1d4942f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -18,7 +18,10 @@ import type { WrapTextMode, } from '@superdoc/contracts'; import { effectiveTableCellSpacing, rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; -import { toCssFontFamily } from '@superdoc/font-utils'; +import { createListMarkerElement, + computeTabWidth, + resolvePainterListMarkerGeometry, + resolvePainterListTextStartPx } from '../utils/marker-helpers.js'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applyParagraphBorderStyles, applyParagraphShadingStyles } from '../features/paragraph-borders/index.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; @@ -29,11 +32,6 @@ import { getSdtContainerKey, type SdtBoundaryOptions, } from '../utils/sdt-helpers.js'; -import { - computeTabWidth, - resolvePainterListMarkerGeometry, - resolvePainterListTextStartPx, -} from '../utils/marker-helpers.js'; import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; @@ -351,29 +349,7 @@ function renderListMarker(params: MarkerRenderParams): void { return; } - // Create marker container (inline-block to isolate from word-spacing used for justification) - const markerContainer = doc.createElement('span'); - markerContainer.style.display = 'inline-block'; - markerContainer.style.wordSpacing = '0px'; - - const markerEl = doc.createElement('span'); - markerEl.classList.add('superdoc-paragraph-marker'); - markerEl.textContent = markerLayout?.markerText ?? ''; - markerEl.style.pointerEvents = 'none'; - - // Apply marker run styling - markerEl.style.fontFamily = toCssFontFamily(markerLayout?.run?.fontFamily) ?? markerLayout?.run?.fontFamily ?? ''; - if (markerLayout?.run?.fontSize != null) { - markerEl.style.fontSize = `${markerLayout.run.fontSize}px`; - } - markerEl.style.fontWeight = markerLayout?.run?.bold ? 'bold' : ''; - markerEl.style.fontStyle = markerLayout?.run?.italic ? 'italic' : ''; - if (markerLayout?.run?.color) { - markerEl.style.color = markerLayout.run.color; - } - if (markerLayout?.run?.letterSpacing != null) { - markerEl.style.letterSpacing = `${markerLayout.run.letterSpacing}px`; - } + const markerContainer = createListMarkerElement(doc, markerLayout?.markerText ?? '', markerLayout?.run ?? {}); // Left-justified markers stay inline (position: relative) within the text flow. // Right/center-justified markers are absolutely positioned. @@ -388,8 +364,6 @@ function renderListMarker(params: MarkerRenderParams): void { lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + markerTextWidth / 2 + 'px'; } - markerContainer.appendChild(markerEl); - // Add suffix separator after marker, before text content const suffixType = markerLayout?.suffix ?? 'tab'; if (suffixType === 'tab') { diff --git a/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts b/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts index c2bae38e79..577cb2ac9c 100644 --- a/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts +++ b/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts @@ -1,3 +1,5 @@ +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { toCssFontFamily } from '@superdoc/font-utils'; import { resolveListMarkerGeometry, resolveListTextStartPx, @@ -6,6 +8,8 @@ import { type MinimalWordLayout, type ResolvedListMarkerGeometry, } from '@superdoc/common/list-marker-utils'; +import { applySourceAnchorDataset } from '../renderer'; +import { SourceAnchor } from '@superdoc/contracts'; type PainterListTextStartParams = { wordLayout: MinimalWordLayout | undefined; @@ -74,3 +78,54 @@ export const resolvePainterListTextStartPx = ({ // Re-export computeTabWidth from shared module export { computeTabWidth }; + +type MarkerRunStyle = { + fontFamily?: string | null; + fontSize?: number | null; + bold?: boolean | null; + italic?: boolean | null; + color?: string | null; + letterSpacing?: number | null; +}; + +/** + * Build the marker container `` with the inner + * `` already appended and styled from the + * given run. Callers handle positioning, suffix separators, and the final prepend. + */ +export const createListMarkerElement = ( + doc: Document, + markerText: string, + run: MarkerRunStyle, + sourceAnchor?: SourceAnchor, +): HTMLElement => { + const markerContainer = doc.createElement('span'); + markerContainer.classList.add(DOM_CLASS_NAMES.LIST_MARKER); + markerContainer.style.display = 'inline-block'; + markerContainer.style.wordSpacing = '0px'; + + const markerEl = doc.createElement('span'); + markerEl.classList.add('superdoc-paragraph-marker'); + markerEl.textContent = markerText; + markerEl.style.pointerEvents = 'none'; + markerEl.style.fontFamily = toCssFontFamily(run.fontFamily) ?? run.fontFamily ?? ''; + + if (run.fontSize != null) { + markerEl.style.fontSize = `${run.fontSize}px`; + } + markerEl.style.fontWeight = run.bold ? 'bold' : ''; + markerEl.style.fontStyle = run.italic ? 'italic' : ''; + + if (run.color) { + markerEl.style.color = run.color; + } + if (run.letterSpacing != null) { + markerEl.style.letterSpacing = `${run.letterSpacing}px`; + } + + markerContainer.appendChild(markerEl); + if (sourceAnchor) { + applySourceAnchorDataset(markerEl, sourceAnchor); + } + return markerContainer; +}; diff --git a/packages/super-editor/src/editors/v1/components/context-menu/constants.js b/packages/super-editor/src/editors/v1/components/context-menu/constants.js index 8a8f3fad52..f2a28a937d 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/constants.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/constants.js @@ -13,6 +13,9 @@ import pasteIconSvg from '@superdoc/common/icons/paste-solid.svg?raw'; import checkIconSvg from '@superdoc/common/icons/check-solid.svg?raw'; import xMarkIconSvg from '@superdoc/common/icons/xmark-solid.svg?raw'; import paintRollerIconSvg from '@superdoc/common/icons/paint-roller-solid.svg?raw'; +import indentIconSvg from '@superdoc/common/icons/indent-solid.svg?raw'; +import outdentIconSvg from '@superdoc/common/icons/outdent-solid.svg?raw'; +import listOlIconSvg from '@superdoc/common/icons/list-ol-solid.svg?raw'; export const ICONS = { addRowBefore: plusIconSvg, @@ -37,6 +40,10 @@ export const ICONS = { trackChangesAccept: checkIconSvg, trackChangesReject: xMarkIconSvg, cellBackground: paintRollerIconSvg, + listRestartNumbering: listOlIconSvg, + listContinueNumbering: listOlIconSvg, + listDecreaseIndent: outdentIconSvg, + listIncreaseIndent: indentIconSvg, }; // Table actions constant @@ -65,6 +72,10 @@ export const TEXTS = { trackChangesAccept: 'Accept change', trackChangesReject: 'Reject change', cellBackground: 'Cell background', + listRestartNumbering: 'Restart numbering', + listContinueNumbering: 'Continue numbering', + listDecreaseIndent: 'Decrease indent', + listIncreaseIndent: 'Increase indent', }; export const tableActionsOptions = [ diff --git a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js index d9445b0c1c..cb62a7f019 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/menuItems.js @@ -288,6 +288,44 @@ export function getItems(context, customItems = [], includeDefaultItems = true) }, ], }, + { + id: 'list-marker', + isDefault: true, + items: [ + { + id: 'list-restart-numbering', + label: TEXTS.listRestartNumbering, + icon: ICONS.listRestartNumbering, + isDefault: true, + action: (editor) => editor.commands.restartNumbering(), + showWhen: (context) => context.trigger === TRIGGERS.click && context.isOnListMarker, + }, + { + id: 'list-continue-numbering', + label: TEXTS.listContinueNumbering, + icon: ICONS.listContinueNumbering, + isDefault: true, + action: (editor) => editor.commands.continueNumbering(), + showWhen: (context) => context.trigger === TRIGGERS.click && context.isOnListMarker, + }, + { + id: 'list-decrease-indent', + label: TEXTS.listDecreaseIndent, + icon: ICONS.listDecreaseIndent, + isDefault: true, + action: (editor) => editor.commands.decreaseListIndent(), + showWhen: (context) => context.trigger === TRIGGERS.click && context.isOnListMarker, + }, + { + id: 'list-increase-indent', + label: TEXTS.listIncreaseIndent, + icon: ICONS.listIncreaseIndent, + isDefault: true, + action: (editor) => editor.commands.increaseListIndent(), + showWhen: (context) => context.trigger === TRIGGERS.click && context.isOnListMarker, + }, + ], + }, { id: 'general', isDefault: true, diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/isOnListMarker.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/isOnListMarker.test.js new file mode 100644 index 0000000000..894f80f6fb --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/isOnListMarker.test.js @@ -0,0 +1,157 @@ +/** + * Tests for isOnListMarker detection in getEditorContext. + * + * When a right-click event lands on a `.superdoc-list-marker` element, + * the context returned by getEditorContext must include `isOnListMarker: true` + * so that the context menu can show list-marker-specific actions. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMockEditor, createBeforeEachSetup } from './testHelpers.js'; + +vi.mock('../../../core/utilities/clipboardUtils.js'); +vi.mock('../../cursor-helpers.js', async () => { + const actual = await vi.importActual('../../cursor-helpers.js'); + return { ...actual, selectionHasNodeOrMark: vi.fn(() => false) }; +}); +vi.mock('../constants.js', () => ({ + tableActionsOptions: [], +})); +vi.mock('prosemirror-history', () => ({ + undoDepth: vi.fn(() => 1), + redoDepth: vi.fn(() => 1), +})); +vi.mock('y-prosemirror', () => ({ + yUndoPluginKey: { + getState: vi.fn(() => ({ undoManager: { undoStack: [1], redoStack: [1] } })), + }, +})); +vi.mock('@extensions/track-changes/permission-helpers.js', () => ({ + collectTrackedChanges: vi.fn(() => []), + collectTrackedChangesForContext: vi.fn(() => []), + isTrackedChangeActionAllowed: vi.fn(() => true), +})); +vi.mock('@core/commands/list-helpers', () => ({ + isList: vi.fn(() => false), +})); +vi.mock('@extensions/table/tableHelpers/isCellSelection.js', () => ({ + isCellSelection: vi.fn(() => false), +})); +vi.mock('prosemirror-tables', () => ({ + selectedRect: vi.fn(() => ({ top: 0, bottom: 1, left: 0, right: 1, map: { height: 1, width: 1 } })), +})); + +import { getEditorContext } from '../utils.js'; + +describe('getEditorContext — isOnListMarker', () => { + let mockEditor; + + beforeEach( + createBeforeEachSetup(() => { + mockEditor = createMockEditor({ documentMode: 'editing', isEditable: true }); + // Provide content.size so posAtCoords path doesn't throw + mockEditor.view.state.doc.content = { size: 100 }; + mockEditor.view.state.doc.nodesBetween = vi.fn(); + mockEditor.view.state.doc.resolve = vi.fn(() => ({ + depth: 0, + marks: vi.fn(() => []), + nodeBefore: null, + nodeAfter: null, + })); + }), + ); + + /** + * Creates a minimal DOM element tree representing a list marker hit: + *
+ * 1. ← event.target + *
+ */ + function makeMarkerEvent(clientX = 100, clientY = 200) { + const markerSpan = document.createElement('span'); + markerSpan.classList.add('superdoc-list-marker'); + markerSpan.textContent = '1.'; + + const fragmentDiv = document.createElement('div'); + fragmentDiv.dataset.itemId = 'item-1'; + fragmentDiv.appendChild(markerSpan); + + return { + clientX, + clientY, + target: markerSpan, + }; + } + + /** + * Creates a mouse event whose target is regular paragraph text (no marker). + */ + function makeTextEvent(clientX = 100, clientY = 200) { + const textSpan = document.createElement('span'); + textSpan.textContent = 'Hello world'; + + return { + clientX, + clientY, + target: textSpan, + }; + } + + it('sets isOnListMarker to true when click target is .superdoc-list-marker', async () => { + const event = makeMarkerEvent(150, 250); + const context = await getEditorContext(mockEditor, event); + + expect(context.isOnListMarker).toBe(true); + }); + + it('sets isOnListMarker to false when click target is regular text', async () => { + const event = makeTextEvent(150, 250); + const context = await getEditorContext(mockEditor, event); + + expect(context.isOnListMarker).toBe(false); + }); + + it('sets isOnListMarker to false when no event is provided', async () => { + const context = await getEditorContext(mockEditor); + + expect(context.isOnListMarker).toBe(false); + }); + + it('sets isOnListMarker to false when event has no target', async () => { + const event = { clientX: 100, clientY: 200, target: null }; + const context = await getEditorContext(mockEditor, event); + + expect(context.isOnListMarker).toBe(false); + }); + + it('sets isOnListMarker to true when a descendant of the marker is the target', async () => { + // Edge case: if the marker span contains a child element and the child is clicked + const markerSpan = document.createElement('span'); + markerSpan.classList.add('superdoc-list-marker'); + + const innerSpan = document.createElement('span'); + innerSpan.textContent = '1.'; + markerSpan.appendChild(innerSpan); + + const fragmentDiv = document.createElement('div'); + fragmentDiv.dataset.itemId = 'item-1'; + fragmentDiv.appendChild(markerSpan); + + const event = { clientX: 100, clientY: 200, target: innerSpan }; + const context = await getEditorContext(mockEditor, event); + + expect(context.isOnListMarker).toBe(true); + }); + + it('sets isOnListMarker to true when click target is .list-marker (flow editor mode)', async () => { + // ParagraphNodeView uses class="list-marker" (no "superdoc-" prefix) + const markerSpan = document.createElement('span'); + markerSpan.classList.add('list-marker'); + markerSpan.setAttribute('contenteditable', 'false'); + markerSpan.textContent = '1.'; + + const event = { clientX: 100, clientY: 200, target: markerSpan }; + const context = await getEditorContext(mockEditor, event); + + expect(context.isOnListMarker).toBe(true); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/listMarkerMenuItems.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/listMarkerMenuItems.test.js new file mode 100644 index 0000000000..6e1c597bf8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/listMarkerMenuItems.test.js @@ -0,0 +1,116 @@ +/** + * Tests for list-marker context menu section. + * + * When the user right-clicks on a list marker, getItems() should return + * a "list-marker" section with restart-numbering, continue-numbering, + * decrease-list-indent, and increase-list-indent actions. + * These items must NOT appear when isOnListMarker is false. + */ +import { describe, it, expect, vi } from 'vitest'; +import { getItems } from '../menuItems.js'; +import { createMockContext, createMockEditor } from './testHelpers.js'; + +vi.mock('../constants.js', async () => { + const actual = await vi.importActual('../constants.js'); + return actual; +}); + +vi.mock('@extensions/track-changes/permission-helpers.js', () => ({ + isTrackedChangeActionAllowed: vi.fn(() => true), +})); + +vi.mock('../../../core/utilities/clipboardUtils.js', () => ({ + readClipboardRaw: vi.fn(async () => ({ html: '', text: '' })), +})); + +vi.mock('../../../core/InputRule.js', () => ({ + handleClipboardPaste: vi.fn(() => false), +})); + +/** IDs for the expected list-marker items */ +const LIST_MARKER_ITEM_IDS = [ + 'list-restart-numbering', + 'list-continue-numbering', + 'list-decrease-indent', + 'list-increase-indent', +]; + +function makeListMarkerContext(overrides = {}) { + return createMockContext({ + trigger: 'click', + isInList: true, + isOnListMarker: true, + ...overrides, + }); +} + +function flatItems(sections) { + return sections.flatMap((s) => s.items); +} + +describe('list-marker menu section', () => { + describe('visibility when isOnListMarker is true', () => { + it('exposes all four items, grouped in the list-marker section', () => { + const context = makeListMarkerContext(); + const sections = getItems(context); + const markerSection = sections.find((s) => s.id === 'list-marker'); + expect(markerSection).toBeDefined(); + expect(markerSection.items.map((i) => i.id)).toEqual(LIST_MARKER_ITEM_IDS); + }); + }); + + describe('visibility when isOnListMarker is false', () => { + it('hides all list-marker items when isOnListMarker is false', () => { + const context = makeListMarkerContext({ isOnListMarker: false }); + const sections = getItems(context); + const ids = flatItems(sections).map((i) => i.id); + LIST_MARKER_ITEM_IDS.forEach((id) => { + expect(ids).not.toContain(id); + }); + }); + + it('hides all list-marker items when trigger is slash (not click)', () => { + const context = makeListMarkerContext({ trigger: 'slash', isOnListMarker: true }); + const sections = getItems(context); + const ids = flatItems(sections).map((i) => i.id); + LIST_MARKER_ITEM_IDS.forEach((id) => { + expect(ids).not.toContain(id); + }); + }); + + it('hides all list-marker items when context is a plain paragraph', () => { + const context = createMockContext({ trigger: 'click', isInList: false, isOnListMarker: false }); + const sections = getItems(context); + const ids = flatItems(sections).map((i) => i.id); + LIST_MARKER_ITEM_IDS.forEach((id) => { + expect(ids).not.toContain(id); + }); + }); + }); + + describe('item actions', () => { + const ACTION_CASES = [ + { itemId: 'list-restart-numbering', command: 'restartNumbering' }, + { itemId: 'list-continue-numbering', command: 'continueNumbering' }, + { itemId: 'list-decrease-indent', command: 'decreaseListIndent' }, + { itemId: 'list-increase-indent', command: 'increaseListIndent' }, + ]; + + it.each(ACTION_CASES)('$itemId calls editor.commands.$command()', ({ itemId, command }) => { + const editor = createMockEditor({ + commands: { + restartNumbering: vi.fn(() => true), + continueNumbering: vi.fn(() => true), + decreaseListIndent: vi.fn(() => true), + increaseListIndent: vi.fn(() => true), + }, + }); + const context = makeListMarkerContext({ editor }); + const item = flatItems(getItems(context)).find((i) => i.id === itemId); + + expect(item).toBeDefined(); + item.action(editor, context); + expect(editor.commands[command]).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js index 7745b50833..d38e244441 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/tests/utils.test.js @@ -132,6 +132,7 @@ describe('utils.js', () => { // Document structure isInTable: false, isInList: false, + isOnListMarker: false, isInSectionNode: false, isCellSelection: false, tableSelectionKind: null, diff --git a/packages/super-editor/src/editors/v1/components/context-menu/utils.js b/packages/super-editor/src/editors/v1/components/context-menu/utils.js index 25421c0c03..8b61000c3b 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/utils.js +++ b/packages/super-editor/src/editors/v1/components/context-menu/utils.js @@ -10,6 +10,7 @@ import { import { isList } from '@core/commands/list-helpers'; import { isCellSelection } from '@extensions/table/tableHelpers/isCellSelection.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import { selectedRect } from 'prosemirror-tables'; export const resolveContextMenuCommandEditor = (editor) => { @@ -126,6 +127,9 @@ export async function getEditorContext(editor, event) { const isInTable = structureFromResolvedPos?.isInTable ?? selectionHasNodeOrMark(state, 'table', { requireEnds: true }); const isInList = structureFromResolvedPos?.isInList ?? selectionIncludesListParagraph(state); + // .superdoc-list-marker = DomPainter markerContainer / presentation mode + // .list-marker = ParagraphNodeView / flow editor mode + const isOnListMarker = Boolean(event?.target?.closest?.(`.${DOM_CLASS_NAMES.LIST_MARKER}, .list-marker`)); const isInSectionNode = structureFromResolvedPos?.isInSectionNode ?? selectionHasNodeOrMark(state, 'documentSection', { requireEnds: true }); @@ -203,6 +207,7 @@ export async function getEditorContext(editor, event) { selectionEnd: selection.to, isInTable, isInList, + isOnListMarker, isInSectionNode, isCellSelection: cellSelectionInfo.isCellSelection, tableSelectionKind: cellSelectionInfo.tableSelectionKind, diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index c47f7384ce..60777862ae 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -2703,6 +2703,11 @@ export class Editor extends EventEmitter { const prevState = this.state; let nextState: EditorState; let transactionToApply = transaction; + // appendTransaction plugins (e.g. numberingPlugin) may produce transactions that + // change the doc even when the original transaction does not. We resolve the + // effective doc-carrying tr after applyTransaction so the 'update' event is + // emitted with `docChanged` / `mapping` that consumers (notably + // PresentationEditor.handleUpdate) actually need. let effectiveTransaction: Transaction = transaction; const forceTrackChanges = transactionToApply.getMeta('forceTrackChanges') === true; try { @@ -2778,7 +2783,8 @@ export class Editor extends EventEmitter { } if (effectiveTransaction.docChanged) { - // Track document modifications and promote to GUID if needed + // Track document modifications and promote to GUID if needed. + // Only count user-initiated (original) transactions as document modifications. if (transaction.docChanged && this.converter) { if (!this.converter.documentGuid) { this.converter.promoteToGuid(); diff --git a/packages/super-editor/src/editors/v1/core/commands/continueNumbering.headless.test.js b/packages/super-editor/src/editors/v1/core/commands/continueNumbering.headless.test.js new file mode 100644 index 0000000000..7933c30cb3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/continueNumbering.headless.test.js @@ -0,0 +1,71 @@ +// @ts-check +/** + * Headless dispatch regression for `continueNumbering`. + * + * Bug: `continueNumbering` removes the `lvlOverride` (which fires + * `partChanged` -> `handleNumberingInvalidation`), then sets + * `preventDispatch: true` on the captured transaction. + * + * - `handleNumberingInvalidation` only dispatches via + * `editor.view?.dispatch?.(...)`, so it is a silent no-op when there is + * no view. + * - `preventDispatch: true` makes `CommandService` skip its own + * `dispatchWithFallback`, which is the path that would otherwise call + * `editor.dispatch(tr)` in headless mode. + * + * Net: in headless mode no transaction flows after `continueNumbering()`, + * so `numberingPlugin.appendTransaction` never runs and consumer-visible + * events (`update`, `transaction`) never fire even though the underlying + * numbering XML did mutate. Headless consumers see stale state. + * + * Expected fix shape: only skip the captured tr when a view-side + * invalidation actually dispatched. In headless mode, the captured tr (or + * an equivalent empty tr) should still dispatch. + */ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { TextSelection } from 'prosemirror-state'; + +let docxData; + +beforeAll(async () => { + docxData = await loadTestDataForEditorTests('restart-numbering-sub-list.docx'); +}); + +describe('continueNumbering — headless dispatch', () => { + it('dispatches a transaction when the editor has no view', () => { + const { editor } = initTestEditor({ + content: docxData.docx, + media: docxData.media, + mediaFiles: docxData.mediaFiles, + fonts: docxData.fonts, + element: null, + }); + + expect(editor.view).toBeFalsy(); + + let listParaPos = null; + editor.state.doc.descendants((node, pos) => { + if (listParaPos != null) return false; + if (node.type.name !== 'paragraph') return true; + const np = node.attrs?.paragraphProperties?.numberingProperties; + if (np && np.numId != null) { + listParaPos = pos; + return false; + } + return true; + }); + expect(listParaPos).not.toBeNull(); + + editor.dispatch(editor.state.tr.setSelection(TextSelection.near(editor.state.doc.resolve(listParaPos + 1)))); + + const dispatchSpy = vi.spyOn(editor, 'dispatch'); + + editor.commands.continueNumbering(); + + expect( + dispatchSpy, + 'continueNumbering must dispatch a transaction in headless mode so listRendering can recompute', + ).toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/commands/continueNumbering.js b/packages/super-editor/src/editors/v1/core/commands/continueNumbering.js new file mode 100644 index 0000000000..e75b5879c9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/continueNumbering.js @@ -0,0 +1,36 @@ +import { findParentNode } from '@helpers/index.js'; +import { isList } from '@core/commands/list-helpers'; +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; + +/** + * Remove the startOverride for the current list level so the counter continues + * from where the previous list chain left off. + * + * `removeLvlOverride` mutates the numbering XML part. That mutation fires + * `list-definitions-change` (which flips `numberingPlugin.forceFullRecompute` + * on) and a `partChanged` event; `handleNumberingInvalidation` then dispatches + * a fresh empty tr that lets `numberingPlugin.appendTransaction` rewrite the + * affected `listRendering` attrs. That nested dispatch is the real work. + * + * After it runs, `editor.state.doc` has moved on. The tr CommandService + * captured before the command ran still points at the old doc, so dispatching + * it would throw "Applying a mismatched transaction" — flag it with + * `preventDispatch` so CommandService skips the dispatch. + * + * In headless mode (no view) `handleNumberingInvalidation` is a silent no-op, + * so the captured tr stays valid. We must let CommandService dispatch it, + * otherwise `listRendering` never recomputes and `update`/`transaction` + * listeners never fire even though the numbering XML did mutate. + */ +export const continueNumbering = ({ editor, tr, state }) => { + const { node: paragraph } = findParentNode(isList)(state.selection) || {}; + if (!paragraph) return false; + + const { numId, ilvl = 0 } = getResolvedParagraphProperties(paragraph)?.numberingProperties || {}; + if (numId == null) return false; + + ListHelpers.removeLvlOverride(editor, numId, ilvl); + if (editor.view) tr.setMeta('preventDispatch', true); + return true; +}; diff --git a/packages/super-editor/src/editors/v1/core/commands/continueNumbering.test.js b/packages/super-editor/src/editors/v1/core/commands/continueNumbering.test.js new file mode 100644 index 0000000000..f9d45e0c76 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/continueNumbering.test.js @@ -0,0 +1,124 @@ +// @ts-check +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { continueNumbering } from './continueNumbering.js'; +import { findParentNode } from '@helpers/index.js'; +import { isList } from '@core/commands/list-helpers'; +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; + +vi.mock(import('@helpers/index.js'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findParentNode: vi.fn(), + }; +}); + +vi.mock('@core/commands/list-helpers', () => ({ + isList: vi.fn(), +})); + +vi.mock('@helpers/list-numbering-helpers.js', () => ({ + ListHelpers: { + removeLvlOverride: vi.fn(), + }, +})); + +vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ + getResolvedParagraphProperties: vi.fn((node) => { + return node?.attrs?.paragraphProperties || { numberingProperties: null }; + }), +})); + +describe('continueNumbering', () => { + /** @type {ReturnType} */ + let resolveParent; + /** @type {any} */ + let state; + /** @type {any} */ + let tr; + /** @type {any} */ + let editor; + + const createParagraph = ({ numId, ilvl = 0 }) => ({ + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { numberingProperties: { numId, ilvl } }, + }, + nodeSize: 4, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + resolveParent = vi.fn(); + findParentNode.mockReturnValue(resolveParent); + + state = { selection: {} }; + tr = { setMeta: vi.fn() }; + editor = {}; + + isList.mockReturnValue(true); + }); + + it('returns false when no list paragraph is found', () => { + resolveParent.mockReturnValue(null); + + const result = continueNumbering({ editor, tr, state }); + + expect(result).toBe(false); + expect(ListHelpers.removeLvlOverride).not.toHaveBeenCalled(); + }); + + it('returns false when paragraph has no numId', () => { + const paragraph = { + type: { name: 'paragraph' }, + attrs: { paragraphProperties: { numberingProperties: null } }, + }; + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); + + const result = continueNumbering({ editor, tr, state }); + + expect(result).toBe(false); + expect(ListHelpers.removeLvlOverride).not.toHaveBeenCalled(); + }); + + it('removes lvlOverride and flags the captured tr with preventDispatch (view present)', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); + editor.view = { dispatch: vi.fn() }; + + const result = continueNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.removeLvlOverride).toHaveBeenCalledWith(editor, 7, 0); + expect(tr.setMeta).toHaveBeenCalledWith('preventDispatch', true); + }); + + it('does NOT set preventDispatch in headless mode (no view) so CommandService can dispatch the captured tr', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); + // editor.view intentionally undefined + + const result = continueNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.removeLvlOverride).toHaveBeenCalledWith(editor, 7, 0); + expect(tr.setMeta).not.toHaveBeenCalledWith('preventDispatch', true); + }); + + it('defaults ilvl to 0 when not specified', () => { + const paragraph = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { numberingProperties: { numId: 5 } }, + }, + nodeSize: 4, + }; + resolveParent.mockReturnValue({ node: paragraph, pos: 3 }); + + const result = continueNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.removeLvlOverride).toHaveBeenCalledWith(editor, 5, 0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/commands/restartNumbering.headless.test.js b/packages/super-editor/src/editors/v1/core/commands/restartNumbering.headless.test.js new file mode 100644 index 0000000000..2228bb9353 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/restartNumbering.headless.test.js @@ -0,0 +1,65 @@ +// @ts-check +/** + * Headless dispatch regression for `restartNumbering` (first-item branch). + * + * Bug: when `restartNumbering` runs on the first list item, it calls + * `setLvlOverride` (which fires `partChanged` -> `handleNumberingInvalidation`) + * then sets `preventDispatch: true` on the captured tr. + * + * Same chain as `continueNumbering`: + * - `handleNumberingInvalidation` only dispatches through `editor.view?.dispatch`, + * silent no-op without a view. + * - `preventDispatch: true` blocks `CommandService`'s `editor.dispatch` fallback. + * + * Net: in headless mode no transaction flows. `listRendering` stays stale and + * `update` / `transaction` listeners do not fire. The mid-list branch (which + * remaps paragraphs onto a brand-new numId) is unaffected because it does not + * set `preventDispatch`. + */ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { TextSelection } from 'prosemirror-state'; + +let docxData; + +beforeAll(async () => { + docxData = await loadTestDataForEditorTests('restart-numbering-sub-list.docx'); +}); + +describe('restartNumbering — headless dispatch (first-item branch)', () => { + it('dispatches a transaction when the editor has no view', () => { + const { editor } = initTestEditor({ + content: docxData.docx, + media: docxData.media, + mediaFiles: docxData.mediaFiles, + fonts: docxData.fonts, + element: null, + }); + + expect(editor.view).toBeFalsy(); + + let firstListPos = null; + editor.state.doc.descendants((node, pos) => { + if (firstListPos != null) return false; + if (node.type.name !== 'paragraph') return true; + const np = node.attrs?.paragraphProperties?.numberingProperties; + if (np && np.numId != null) { + firstListPos = pos; + return false; + } + return true; + }); + expect(firstListPos).not.toBeNull(); + + editor.dispatch(editor.state.tr.setSelection(TextSelection.near(editor.state.doc.resolve(firstListPos + 1)))); + + const dispatchSpy = vi.spyOn(editor, 'dispatch'); + + editor.commands.restartNumbering(); + + expect( + dispatchSpy, + 'restartNumbering must dispatch a transaction in headless mode so listRendering can recompute', + ).toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/commands/restartNumbering.js b/packages/super-editor/src/editors/v1/core/commands/restartNumbering.js index 66110c3fc6..b3613d5b08 100644 --- a/packages/super-editor/src/editors/v1/core/commands/restartNumbering.js +++ b/packages/super-editor/src/editors/v1/core/commands/restartNumbering.js @@ -2,27 +2,78 @@ import { findParentNode } from '@helpers/index.js'; import { isList } from '@core/commands/list-helpers'; import { ListHelpers } from '@helpers/list-numbering-helpers.js'; import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; +import { updateNumberingProperties } from '@core/commands/changeListLevel.js'; /** - * Restart numbering for the current list by setting a startOverride on the - * existing w:num definition. + * Restart numbering at the current list item. * - * This uses the OOXML-correct pattern: w:lvlOverride/w:startOverride on the - * existing w:num. Paragraphs keep their numId — only the definition is mutated. - * This preserves list identity, makes join possible, and produces correct OOXML - * on export. - * - * Note: This command is being replaced by `lists.setValue` in SD-1272 Phase 3. + * If the cursor is on the first item of the list, sets startOverride=1 on the + * existing numId (no split needed). If it is on a mid-list item, a new numId + * pointing to the same abstractId is created, startOverride=1 is applied to + * that new numId, and all paragraphs from the current position onwards that + * share the old numId are remapped to the new numId. This produces two + * independent numbering sequences: the items before restart are unchanged and + * the items from the restart point count from 1. */ -export const restartNumbering = ({ editor, tr, state, dispatch }) => { - const { node: paragraph } = findParentNode(isList)(state.selection) || {}; +export const restartNumbering = ({ editor, tr, state }) => { + const parentResult = findParentNode(isList)(state.selection); + const { node: paragraph, pos: paragraphPos } = parentResult || {}; if (!paragraph) return false; const { numId, ilvl = 0 } = getResolvedParagraphProperties(paragraph).numberingProperties || {}; if (numId == null) return false; - ListHelpers.setLvlOverride(editor, numId, ilvl, { startOverride: 1 }); + // Check if any list items with the same numId appear before the current position. + // Non-paragraph nodes are skipped (not matched directly, but we still descend + // into block containers to find paragraphs inside tables/sections). + let hasPrecedingItems = false; + state.doc.nodesBetween(0, paragraphPos, (node) => { + if (hasPrecedingItems) return false; + if (node.type.name !== 'paragraph') return true; + const props = getResolvedParagraphProperties(node)?.numberingProperties; + if (props?.numId === numId) hasPrecedingItems = true; + return false; + }); + + if (!hasPrecedingItems) { + // Already the first item — pin startOverride on the existing numId. + // setLvlOverride triggers handleNumberingInvalidation, which dispatches a + // fresh tr through `editor.view.dispatch` to recompute listRendering. After + // that the captured tr points at a stale doc and dispatching it would throw + // "Applying a mismatched transaction" — so we flag it with `preventDispatch`. + // In headless mode (no view) handleNumberingInvalidation is a silent no-op, + // so the captured tr stays valid and we must let CommandService dispatch it + // (otherwise listRendering never recomputes and `update`/`transaction` + // listeners never fire). + ListHelpers.setLvlOverride(editor, numId, ilvl, { startOverride: 1 }); + if (editor.view) tr.setMeta('preventDispatch', true); + return true; + } + + // Mid-list restart: create a new numId sharing the same abstractId. + // createNumDefinition and setLvlOverride operate on a brand-new numId that + // no paragraph references yet, so handleNumberingInvalidation's appendTransaction + // produces no doc change. The original tr (and state.doc) remain valid. + const allDefs = ListHelpers.getAllListDefinitions(editor); + const abstractId = allDefs?.[numId]?.[ilvl]?.abstractId; + if (abstractId == null) return false; + + const { numId: newNumId } = ListHelpers.createNumDefinition(editor, Number(abstractId)); + ListHelpers.setLvlOverride(editor, newNumId, ilvl, { startOverride: 1 }); + + // Remap paragraphs from this position onwards to the new numId. Steps are + // accumulated on the captured tr; CommandService dispatches it after we return. + // Default ilvl to 0 — when numbering comes from a style (e.g. ListNumber) the + // inline numberingProperties may omit ilvl. Without the default we'd export + // a with no , but Word always writes . + state.doc.nodesBetween(paragraphPos, state.doc.content.size, (node, pos) => { + if (node.type.name !== 'paragraph') return true; + const props = getResolvedParagraphProperties(node)?.numberingProperties; + if (props?.numId === numId) { + updateNumberingProperties({ numId: newNumId, ilvl: props.ilvl ?? 0 }, node, pos, editor, tr); + } + return true; + }); - if (dispatch) dispatch(tr); return true; }; diff --git a/packages/super-editor/src/editors/v1/core/commands/restartNumbering.test.js b/packages/super-editor/src/editors/v1/core/commands/restartNumbering.test.js index 76b9ae2cdb..88b9984296 100644 --- a/packages/super-editor/src/editors/v1/core/commands/restartNumbering.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/restartNumbering.test.js @@ -20,6 +20,8 @@ vi.mock('@core/commands/list-helpers', () => ({ vi.mock('@helpers/list-numbering-helpers.js', () => ({ ListHelpers: { setLvlOverride: vi.fn(), + getAllListDefinitions: vi.fn(), + createNumDefinition: vi.fn(), }, })); @@ -29,6 +31,12 @@ vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ }), })); +vi.mock('@core/commands/changeListLevel.js', () => ({ + updateNumberingProperties: vi.fn(), +})); + +import { updateNumberingProperties } from '@core/commands/changeListLevel.js'; + describe('restartNumbering', () => { /** @type {ReturnType} */ let resolveParent; @@ -38,8 +46,6 @@ describe('restartNumbering', () => { let tr; /** @type {any} */ let editor; - /** @type {ReturnType} */ - let dispatch; const createParagraph = ({ numId, ilvl = 0 }) => ({ type: { name: 'paragraph' }, @@ -55,22 +61,26 @@ describe('restartNumbering', () => { resolveParent = vi.fn(); findParentNode.mockReturnValue(resolveParent); - state = { selection: {} }; - tr = {}; + const sharedDoc = { + content: { size: 100 }, + nodesBetween: vi.fn(), + }; + state = { selection: {}, doc: sharedDoc }; + tr = { setMeta: vi.fn() }; editor = {}; - dispatch = vi.fn(); isList.mockReturnValue(true); + ListHelpers.getAllListDefinitions.mockReturnValue({}); + ListHelpers.createNumDefinition.mockReturnValue({ numId: 99 }); }); it('returns false when no list paragraph is found', () => { resolveParent.mockReturnValue(null); - const result = restartNumbering({ editor, tr, state, dispatch }); + const result = restartNumbering({ editor, tr, state }); expect(result).toBe(false); expect(ListHelpers.setLvlOverride).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); }); it('returns false when paragraph has no numId', () => { @@ -80,58 +90,132 @@ describe('restartNumbering', () => { }; resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); - const result = restartNumbering({ editor, tr, state, dispatch }); + const result = restartNumbering({ editor, tr, state }); expect(result).toBe(false); expect(ListHelpers.setLvlOverride).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); - }); - - it('sets startOverride on the existing numId and dispatches', () => { - const paragraph = createParagraph({ numId: 7, ilvl: 0 }); - resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); - - const result = restartNumbering({ editor, tr, state, dispatch }); - - expect(result).toBe(true); - expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 7, 0, { startOverride: 1 }); - expect(dispatch).toHaveBeenCalledWith(tr); }); - it('uses the correct ilvl from paragraph properties', () => { - const paragraph = createParagraph({ numId: 3, ilvl: 2 }); - resolveParent.mockReturnValue({ node: paragraph, pos: 10 }); - - const result = restartNumbering({ editor, tr, state, dispatch }); - - expect(result).toBe(true); - expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 3, 2, { startOverride: 1 }); - expect(dispatch).toHaveBeenCalledWith(tr); + describe('first item in list (no preceding items)', () => { + beforeEach(() => { + // nodesBetween finds no preceding items + state.doc.nodesBetween.mockImplementation((from, to) => { + if (to === 5) return; // searching for preceding items — none found + }); + }); + + it('sets startOverride on the existing numId and flags the captured tr with preventDispatch (view present)', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); + editor.view = { dispatch: vi.fn() }; + + const result = restartNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 7, 0, { startOverride: 1 }); + expect(ListHelpers.createNumDefinition).not.toHaveBeenCalled(); + expect(tr.setMeta).toHaveBeenCalledWith('preventDispatch', true); + }); + + it('does NOT set preventDispatch in headless mode (no view) so CommandService can dispatch the captured tr', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); + // editor.view intentionally undefined + + const result = restartNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 7, 0, { startOverride: 1 }); + expect(tr.setMeta).not.toHaveBeenCalledWith('preventDispatch', true); + }); + + it('defaults ilvl to 0 when not specified', () => { + const paragraph = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { numberingProperties: { numId: 5 } }, + }, + nodeSize: 4, + }; + resolveParent.mockReturnValue({ node: paragraph, pos: 3 }); + + const result = restartNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 5, 0, { startOverride: 1 }); + }); }); - it('defaults ilvl to 0 when not specified', () => { - const paragraph = { + describe('mid-list restart (preceding items exist)', () => { + const precedingParagraph = { type: { name: 'paragraph' }, - attrs: { - paragraphProperties: { numberingProperties: { numId: 5 } }, - }, + attrs: { paragraphProperties: { numberingProperties: { numId: 7, ilvl: 0 } } }, nodeSize: 4, }; - resolveParent.mockReturnValue({ node: paragraph, pos: 3 }); - - const result = restartNumbering({ editor, tr, state, dispatch }); - - expect(result).toBe(true); - expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 5, 0, { startOverride: 1 }); - }); - - it('does not dispatch when dispatch is not provided', () => { - const paragraph = createParagraph({ numId: 7, ilvl: 0 }); - resolveParent.mockReturnValue({ node: paragraph, pos: 5 }); - - const result = restartNumbering({ editor, tr, state }); - expect(result).toBe(true); - expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 7, 0, { startOverride: 1 }); + beforeEach(() => { + // nodesBetween[0..paragraphPos] finds a preceding item with numId=7 + state.doc.nodesBetween.mockImplementation((from, to, cb) => { + if (to === 20) { + // preceding search range + cb(precedingParagraph, 5); + } + // forward range (paragraphPos..end): no further items to remap in this stub + }); + + ListHelpers.getAllListDefinitions.mockReturnValue({ + 7: { 0: { abstractId: '42' } }, + }); + ListHelpers.createNumDefinition.mockReturnValue({ numId: 99 }); + }); + + it('creates a new numId and sets startOverride on it', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 20 }); + + const result = restartNumbering({ editor, tr, state }); + + expect(result).toBe(true); + expect(ListHelpers.createNumDefinition).toHaveBeenCalledWith(editor, 42); + expect(ListHelpers.setLvlOverride).toHaveBeenCalledWith(editor, 99, 0, { startOverride: 1 }); + }); + + it('remaps paragraphs from current position to the new numId', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + const followingParagraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 20 }); + + state.doc.nodesBetween.mockImplementation((from, to, cb) => { + if (to === 20) { + cb(precedingParagraph, 5); // preceding item + } else { + // forward range: current and following item + cb(paragraph, 20); + cb(followingParagraph, 30); + } + }); + + restartNumbering({ editor, tr, state }); + + expect(updateNumberingProperties).toHaveBeenCalledWith({ numId: 99, ilvl: 0 }, paragraph, 20, editor, tr); + expect(updateNumberingProperties).toHaveBeenCalledWith( + { numId: 99, ilvl: 0 }, + followingParagraph, + 30, + editor, + tr, + ); + }); + + it('returns false when abstractId cannot be resolved', () => { + const paragraph = createParagraph({ numId: 7, ilvl: 0 }); + resolveParent.mockReturnValue({ node: paragraph, pos: 20 }); + ListHelpers.getAllListDefinitions.mockReturnValue({}); // no definition + + const result = restartNumbering({ editor, tr, state }); + + expect(result).toBe(false); + expect(ListHelpers.createNumDefinition).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js index 72bcb46c1b..b0ef33e3d2 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/paragraph.js @@ -9,6 +9,7 @@ import { findParentNode } from '@helpers/index.js'; import { InputRule } from '@core/InputRule.js'; import { toggleList } from '@core/commands/index.js'; import { restartNumbering } from '@core/commands/restartNumbering.js'; +import { continueNumbering } from '@core/commands/continueNumbering.js'; import { ParagraphNodeView } from './ParagraphNodeView.js'; import { createNumberingPlugin } from './numberingPlugin.js'; import { createLeadingCaretPlugin } from './leadingCaretPlugin.js'; @@ -354,6 +355,14 @@ export const Paragraph = OxmlNode.create({ * @note Resets list numbering for the current list item and following items */ restartNumbering: () => restartNumbering, + /** + * Remove the startOverride for the current list level so numbering continues + * from the previous chain instead of restarting. + * @category Command + * @example + * editor.commands.continueNumbering() + */ + continueNumbering: () => continueNumbering, }; },