From 2cd78b8e118fbfe1adc6c93607f2872f01cec0d7 Mon Sep 17 00:00:00 2001 From: Giorgio Garofalo Date: Thu, 30 Apr 2026 17:10:33 -0700 Subject: [PATCH 1/6] fix(extension-mathematics): consume host paragraph in block math input rule When `$$$x$$$` was typed inside an otherwise empty paragraph, the input rule replaced only the textblock content, leaving an empty wrapper paragraph above the inserted block math node. Resolve the match position and, when the match spans the entire host textblock, expand the replacement to include the parent's open/close so the paragraph is consumed instead of split. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...x-block-math-input-rule-empty-paragraph.md | 5 ++ .../__tests__/blockMath.spec.ts | 60 +++++++++++++++++++ .../src/extensions/BlockMath.ts | 10 +++- 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-block-math-input-rule-empty-paragraph.md create mode 100644 packages/extension-mathematics/__tests__/blockMath.spec.ts diff --git a/.changeset/fix-block-math-input-rule-empty-paragraph.md b/.changeset/fix-block-math-input-rule-empty-paragraph.md new file mode 100644 index 0000000000..6ba2ba7983 --- /dev/null +++ b/.changeset/fix-block-math-input-rule-empty-paragraph.md @@ -0,0 +1,5 @@ +--- +"@tiptap/extension-mathematics": patch +--- + +Fix `$$$...$$$` input rule leaving an empty paragraph above the inserted block math node when the match consumes the entire host paragraph. diff --git a/packages/extension-mathematics/__tests__/blockMath.spec.ts b/packages/extension-mathematics/__tests__/blockMath.spec.ts new file mode 100644 index 0000000000..298324fe28 --- /dev/null +++ b/packages/extension-mathematics/__tests__/blockMath.spec.ts @@ -0,0 +1,60 @@ +import { Editor } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { afterEach, describe, expect, it } from 'vitest' + +import { BlockMath } from '../src/index.js' + +describe('BlockMath', () => { + let editor: Editor + + afterEach(() => { + editor?.destroy() + }) + + describe('input rule', () => { + it('replaces an empty host paragraph instead of leaving it behind', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, BlockMath], + content: { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '$$$x^2$$' }] }], + }, + }) + + editor.commands.setTextSelection(editor.state.doc.content.size) + + editor.view.someProp('handleTextInput', f => + f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'), + ) + + expect(editor.getJSON()).toEqual({ + type: 'doc', + content: [{ type: 'blockMath', attrs: { latex: 'x^2' } }], + }) + }) + + it('does not fire when the match would not start at the textblock start', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, BlockMath], + content: { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello $$$x^2$$' }] }], + }, + }) + + editor.commands.setTextSelection(editor.state.doc.content.size) + + const handled = editor.view.someProp('handleTextInput', f => + f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'), + ) + + expect(handled).toBeFalsy() + expect(editor.getJSON()).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello $$$x^2$$' }] }], + }) + }) + }) +}) diff --git a/packages/extension-mathematics/src/extensions/BlockMath.ts b/packages/extension-mathematics/src/extensions/BlockMath.ts index b812a33478..d047b5fd9a 100644 --- a/packages/extension-mathematics/src/extensions/BlockMath.ts +++ b/packages/extension-mathematics/src/extensions/BlockMath.ts @@ -221,10 +221,14 @@ export const BlockMath = Node.create({ handler: ({ state, range, match }) => { const [, latex] = match const { tr } = state - const start = range.from - const end = range.to + const $from = state.doc.resolve(range.from) + const node = this.type.create({ latex }) - tr.replaceWith(start, end, this.type.create({ latex })) + if ($from.parent.isTextblock && range.from === $from.start() && range.to === $from.end()) { + tr.replaceWith($from.before(), $from.after(), node) + } else { + tr.replaceWith(range.from, range.to, node) + } }, }), ] From f6eb28f48824a82d3cf6094ad079501b21fd009a Mon Sep 17 00:00:00 2001 From: Giorgio Garofalo Date: Thu, 30 Apr 2026 18:27:06 -0700 Subject: [PATCH 2/6] fix(extension-mathematics): guard host-textblock replacement with canReplaceWith The previous handler unconditionally replaced the entire host textblock when the match consumed it, which produced an invalid document in schemas where the parent requires a textblock as the first child (e.g. listItem with `content: 'paragraph block*'`). Use `canReplaceWith` on the grandparent to verify the replacement is schema-valid, falling back to the inner-range replacement otherwise. Adds a regression test for the listItem case. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/blockMath.spec.ts | 42 +++++++++++++++++++ .../src/extensions/BlockMath.ts | 6 ++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/extension-mathematics/__tests__/blockMath.spec.ts b/packages/extension-mathematics/__tests__/blockMath.spec.ts index 298324fe28..2621bc0af1 100644 --- a/packages/extension-mathematics/__tests__/blockMath.spec.ts +++ b/packages/extension-mathematics/__tests__/blockMath.spec.ts @@ -1,5 +1,6 @@ import { Editor } from '@tiptap/core' import Document from '@tiptap/extension-document' +import { BulletList, ListItem } from '@tiptap/extension-list' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' import { afterEach, describe, expect, it } from 'vitest' @@ -35,6 +36,47 @@ describe('BlockMath', () => { }) }) + it('inserts a sibling block math node inside a list item without breaking the schema', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, BulletList, ListItem, BlockMath], + content: { + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '$$$x^2$$' }] }], + }, + ], + }, + ], + }, + }) + + editor.commands.setTextSelection(editor.state.doc.content.size - 3) + + editor.view.someProp('handleTextInput', f => + f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'), + ) + + expect(editor.getJSON()).toEqual({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph' }, { type: 'blockMath', attrs: { latex: 'x^2' } }], + }, + ], + }, + ], + }) + }) + it('does not fire when the match would not start at the textblock start', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, BlockMath], diff --git a/packages/extension-mathematics/src/extensions/BlockMath.ts b/packages/extension-mathematics/src/extensions/BlockMath.ts index d047b5fd9a..3dbb757a46 100644 --- a/packages/extension-mathematics/src/extensions/BlockMath.ts +++ b/packages/extension-mathematics/src/extensions/BlockMath.ts @@ -223,8 +223,12 @@ export const BlockMath = Node.create({ const { tr } = state const $from = state.doc.resolve(range.from) const node = this.type.create({ latex }) + const consumesHostTextblock = + $from.depth > 0 && $from.parent.isTextblock && range.from === $from.start() && range.to === $from.end() + const grandparentAcceptsReplacement = + consumesHostTextblock && $from.node(-1).canReplaceWith($from.index(-1), $from.indexAfter(-1), this.type) - if ($from.parent.isTextblock && range.from === $from.start() && range.to === $from.end()) { + if (consumesHostTextblock && grandparentAcceptsReplacement) { tr.replaceWith($from.before(), $from.after(), node) } else { tr.replaceWith(range.from, range.to, node) From fb8eba6d1cc41b4ad456fdb39feba0744884add3 Mon Sep 17 00:00:00 2001 From: Giorgio Garofalo Date: Mon, 4 May 2026 10:51:31 -0700 Subject: [PATCH 3/6] refactor(extension-mathematics): collapse block math input rule into single replaceWith Replace the if/else around `tr.replaceWith` with a single call driven by a `replacementRange` ternary, and rename `grandparentAcceptsReplacement` to `canReplaceHostTextblock` with a short inline note describing what it checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/extensions/BlockMath.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/extension-mathematics/src/extensions/BlockMath.ts b/packages/extension-mathematics/src/extensions/BlockMath.ts index 3dbb757a46..bc10017708 100644 --- a/packages/extension-mathematics/src/extensions/BlockMath.ts +++ b/packages/extension-mathematics/src/extensions/BlockMath.ts @@ -223,16 +223,16 @@ export const BlockMath = Node.create({ const { tr } = state const $from = state.doc.resolve(range.from) const node = this.type.create({ latex }) + const consumesHostTextblock = $from.depth > 0 && $from.parent.isTextblock && range.from === $from.start() && range.to === $from.end() - const grandparentAcceptsReplacement = + // Whether the node containing the host textblock can replace it with a blockMath node. + const canReplaceHostTextblock = consumesHostTextblock && $from.node(-1).canReplaceWith($from.index(-1), $from.indexAfter(-1), this.type) - if (consumesHostTextblock && grandparentAcceptsReplacement) { - tr.replaceWith($from.before(), $from.after(), node) - } else { - tr.replaceWith(range.from, range.to, node) - } + const replacementRange = canReplaceHostTextblock ? { from: $from.before(), to: $from.after() } : range + + tr.replaceWith(replacementRange.from, replacementRange.to, node) }, }), ] From 1852c7383dcf22c0dbad7fae924a28cb4bea1154 Mon Sep 17 00:00:00 2001 From: bdbch <6538827+bdbch@users.noreply.github.com> Date: Tue, 5 May 2026 12:52:16 +0200 Subject: [PATCH 4/6] docs(drag-handle): improve JSDoc clarity for nested drag handle options (#7797) * docs(drag-handle): improve JSDoc clarity for nested drag handle options * chore: add changeset * improve writing in jsdocs * improve writing in jsdocs * add new line to changeset file --- .changeset/clean-hairs-push.md | 5 + .../extension-drag-handle/src/drag-handle.ts | 34 +- .../src/types/options.ts | 312 ++++++++++++++++-- .../extension-drag-handle/src/types/rules.ts | 60 +++- 4 files changed, 350 insertions(+), 61 deletions(-) create mode 100644 .changeset/clean-hairs-push.md diff --git a/.changeset/clean-hairs-push.md b/.changeset/clean-hairs-push.md new file mode 100644 index 0000000000..82988844c8 --- /dev/null +++ b/.changeset/clean-hairs-push.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-drag-handle': patch +--- + +Improved JSDoc documentation for the nested drag handle options, making threshold, strength, edge detection, and custom rules easier to understand directly in your IDE. diff --git a/packages/extension-drag-handle/src/drag-handle.ts b/packages/extension-drag-handle/src/drag-handle.ts index 504402c403..12d2fe8bdd 100644 --- a/packages/extension-drag-handle/src/drag-handle.ts +++ b/packages/extension-drag-handle/src/drag-handle.ts @@ -45,24 +45,14 @@ export interface DragHandleOptions { /** * Enable drag handles for nested content (list items, blockquotes, etc.). * - * When enabled, the drag handle will appear for nested blocks, not just - * top-level blocks. A rule-based scoring system determines which node - * to target based on cursor position and configured rules. + * When enabled, the drag handle appears for block nodes at any depth, not just + * top-level blocks. A rule-based scoring system evaluates all ancestor nodes + * at the cursor position and selects the best drag target. * * **Values:** * - `false` (default): Only root-level blocks show drag handles * - `true`: Enable with sensible defaults (left edge detection, default rules) - * - `NestedOptions`: Enable with custom configuration - * - * **Configuration options:** - * - `rules`: Custom rules to determine which nodes are draggable - * - `defaultRules`: Whether to include default rules (default: true) - * - `allowedContainers`: Restrict nested dragging to specific container types - * - `edgeDetection`: Control when to prefer parent over nested node - * - `'left'` (default): Prefer parent near left/top edges - * - `'right'`: Prefer parent near right/top edges (for RTL) - * - `'both'`: Prefer parent near any horizontal edge - * - `'none'`: Disable edge detection + * - `NestedOptions`: Enable with full custom configuration * * @default false * @@ -81,7 +71,7 @@ export interface DragHandleOptions { * }) * * @example - * // With custom rules + * // With custom rules and edge detection disabled * DragHandle.configure({ * nested: { * rules: [{ @@ -91,6 +81,20 @@ export interface DragHandleOptions { * edgeDetection: 'none', * }, * }) + * + * @example + * // Full configuration + * DragHandle.configure({ + * nested: { + * defaultRules: true, + * allowedContainers: ['bulletList', 'orderedList', 'blockquote'], + * edgeDetection: { threshold: 20 }, + * rules: [{ + * id: 'preferShallow', + * evaluate: ({ depth }) => depth * 200, + * }], + * }, + * }) */ nested?: boolean | NestedOptions } diff --git a/packages/extension-drag-handle/src/types/options.ts b/packages/extension-drag-handle/src/types/options.ts index a994defaab..745893d083 100644 --- a/packages/extension-drag-handle/src/types/options.ts +++ b/packages/extension-drag-handle/src/types/options.ts @@ -2,33 +2,154 @@ import type { DragHandleRule } from './rules.js' /** * Edge detection presets for common use cases. + * + * Edge detection helps you grab parent containers (lists, blockquotes, etc.) + * by moving the cursor near the edge of a nested element. When the cursor is + * within the `threshold` zone of a configured edge, the scoring system deducts + * `strength * depth` from deeper nodes, making the outer container the easier + * target. + * + * In short: cursor near edge prefers parent; cursor centered prefers child. + * + * @example + * // Left/top edges, natural for LTR layouts (default) + * DragHandle.configure({ + * nested: { + * edgeDetection: 'left', + * }, + * }) + * + * @example + * // Right/top edges, for RTL layouts + * DragHandle.configure({ + * nested: { + * edgeDetection: 'right', + * }, + * }) + * + * @example + * // No edge detection, cursor position does not affect scoring + * DragHandle.configure({ + * nested: { + * edgeDetection: 'none', + * }, + * }) */ export type EdgeDetectionPreset = - | 'left' // Prefer parent when cursor near left edge (default) - | 'right' // Prefer parent when cursor near right edge (RTL support) - | 'both' // Prefer parent when cursor near left OR right edge - | 'none' // Disable edge detection entirely + | 'left' // Prefer parent when cursor near left or top edge (LTR default) + | 'right' // Prefer parent when cursor near right or top edge (RTL support) + | 'both' // Prefer parent when cursor near any horizontal edge or top edge + | 'none' // Disable edge detection entirely, cursor position does not affect scoring /** - * Advanced edge detection configuration. - * Most users should use presets instead. + * Advanced edge detection configuration for fine-grained control. + * + * Use this interface when the preset strings (\`'left'\`, \`'right'\`, etc.) aren't + * enough and you need to customize **which edges**, **how wide the zone is**, + * or **how aggressive** the parent preference should be. + * + * Most users should use \`EdgeDetectionPreset\` strings instead of this interface. + * Only reach for this when you need precise control. + * + * @example + * // Wider edge zone, gentler deduction, top/bottom edges only + * DragHandle.configure({ + * nested: { + * edgeDetection: { + * edges: ['top', 'bottom'], + * threshold: 24, + * strength: 300, + * }, + * }, + * }) + * + * @example + * // Aggressive left-edge only: narrow zone, strong deduction + * DragHandle.configure({ + * nested: { + * edgeDetection: { + * edges: ['left'], + * threshold: 8, + * strength: 800, + * }, + * }, + * }) */ export interface EdgeDetectionConfig { /** * Which edges trigger parent preference. + * - `'left'`: Cursor within threshold pixels of the element's left edge + * - `'right'`: Cursor within threshold pixels of the element's right edge + * - `'top'`: Cursor within threshold pixels of the element's top edge + * - `'bottom'`: Cursor within threshold pixels of the element's bottom edge + * * @default ['left', 'top'] */ edges: Array<'left' | 'right' | 'top' | 'bottom'> /** - * Distance in pixels from edge to trigger. + * Distance in pixels from the element edge that triggers the deduction. + * + * Think of this as the size of an invisible "edge zone" around the element. + * When the cursor is inside this zone, `strength * depth` is deducted from + * deeper nodes, making parent containers easier to grab. + * + * - **Higher value** (e.g., 24): The zone is wider, edge detection triggers + * even when the cursor is relatively far from the element's edge. Parent + * selection feels more "eager." + * - **Lower value** (e.g., 6): The zone is narrower, the cursor must be + * very close to the edge before parent preference kicks in. You need to be + * more deliberate to grab a parent container. + * + * @example + * // threshold: 12 means the cursor must be within 12px of the edge + * // threshold: 24 doubles the trigger zone + * * @default 12 */ threshold: number /** - * How strongly to prefer parent (higher = stronger preference). - * This is multiplied by depth, so deeper nodes are affected more. + * How strongly to prefer parent nodes near edges (higher = stronger preference). + * + * The deduction formula is: `strength * depth`. This means the penalty grows + * linearly with nesting depth, making deeply nested children less attractive + * targets when you're near an edge, exactly what you want when trying to + * grab the outer list rather than the inner paragraph. + * + * **Visual guide, default strength (500):** + * ``` + * Depth | Deduction | Eligible? + * ──────┼───────────┼────────── + * 1 | 500 │ Yes, still a valid target + * 2 | 1000 │ No, penalty matches base score + * 3 | 1500 │ No, penalty exceeds base score + * 4 | 2000 │ No, deeply buried + * ``` + * + * **Lower strength (200):** + * ``` + * Depth | Deduction | Eligible? + * ──────┼───────────┼────────── + * 1 | 200 │ Yes + * 2 | 400 │ Yes + * 3 | 600 │ Yes + * 4 | 800 │ Yes (but parent still preferred) + * 5 | 1000 │ No, excluded at threshold + * ``` + * Good when you want edge detection to nudge toward parents without + * excluding typical nesting depths. + * + * **Higher strength (1000):** + * ``` + * Depth | Deduction | Eligible? + * ──────┼───────────┼────────── + * 1 | 1000 │ No, excluded at threshold + * ``` + * Every non-doc candidate near the edge is excluded from being a drag + * target. Use when you want edge detection to completely disable nested + * dragging near the edges and force root-level handles. + * * @default 500 */ strength: number @@ -36,13 +157,69 @@ export interface EdgeDetectionConfig { /** * Configuration for nested drag handle behavior. + * + * When enabled, the drag handle can target nodes at any depth in the document + * tree (not just top-level blocks). A rule-based scoring system evaluates all + * ancestor nodes at the cursor position and selects the best drag target. + * + * **How the scoring works:** + * 1. Each ancestor node at the cursor position starts with a base score of 1000 + * 2. Default rules are applied first (subtracting deductions for lists, tables, etc.) + * 3. Your custom rules are applied next (for app-specific logic) + * 4. Edge detection adds a final deduction (`strength * depth`) when near element edges + * 5. The highest-scoring node wins; ties are broken by depth (deeper nodes win) + * 6. Any node with a score of 0 or below is excluded as a drag target + * + * @example + * // Simple enable with sensible defaults + * DragHandle.configure({ + * nested: true, + * }) + * + * @example + * // Full custom configuration + * DragHandle.configure({ + * nested: { + * defaultRules: true, + * allowedContainers: ['bulletList', 'orderedList', 'blockquote'], + * edgeDetection: 'left', + * rules: [ + * { + * id: 'myCustomRule', + * evaluate: ({ node }) => + * node.type.name === 'myCustomBlock' ? 1000 : 0, + * }, + * ], + * }, + * }) */ export interface NestedOptions { /** - * Additional rules to determine which nodes are draggable. - * These run AFTER the default rules. + * Custom rules that determine which nodes are draggable. + * + * Rules are evaluated AFTER the default rules. Each rule receives a + * `RuleContext` and returns a score deduction: + * - `0`: No effect, node remains fully eligible + * - `1-999`: Partial deduction, node is less preferred but still eligible + * - `>= 1000`: Node is **excluded** from being a drag target + * + * Common use cases for custom rules: + * - Exclude specific node types from being draggable + * - Deprioritize certain nodes with partial deductions + * - Scope dragging to specific document structures + * + * @example + * // Exclude code blocks from being draggable + * rules: [ + * { + * id: 'excludeCodeBlocks', + * evaluate: ({ node }) => + * node.type.name === 'codeBlock' ? 1000 : 0, + * }, + * ] * * @example + * // Inside a custom "question" block, only allow dragging "alternative" children * rules: [ * { * id: 'onlyAlternatives', @@ -54,64 +231,135 @@ export interface NestedOptions { * }, * }, * ] + * + * @example + * // Deprioritize deeper nodes with partial deduction + * rules: [ + * { + * id: 'preferShallow', + * evaluate: ({ depth }) => depth * 100, + * }, + * ] */ rules?: DragHandleRule[] /** - * Set to `false` to disable default rules and use only your custom rules. - * Default rules handle common cases like list items and inline content. + * Whether to include the built-in default rules before your custom rules. + * + * The default rules handle common editor patterns: + * - \`listItemFirstChild\` -- Excludes the first child of listItem/taskItem + * (the content paragraph), so the list item itself is the drag target + * - \`listWrapperDeprioritize\` -- Excludes bulletList/orderedList wrappers, + * so individual list items are the default drag target + * - \`tableStructure\` -- Excludes tableRow, tableCell, tableHeader from dragging + * (table extensions handle their own drag behavior) + * - \`inlineContent\` -- Excludes inline nodes and text from being drag targets + * + * Set to `false` to disable all default rules and use only your custom `rules`. + * This is useful when the default behavior conflicts with your custom setup. * * @default true + * + * @example + * // Use only your own rule, no defaults + * nested: { + * defaultRules: false, + * rules: [{ + * id: 'onlyParagraphs', + * evaluate: ({ node }) => + * node.type.name === 'paragraph' ? 0 : 1000, + * }], + * } */ defaultRules?: boolean /** - * Restrict nested drag handles to specific container types. - * If set, nested dragging only works inside these node types. + * Restrict nested drag handles to specific container node types. + * + * When set, nested dragging only activates when the cursor is inside one of + * the specified node types (at any ancestor level). When the cursor is + * outside these containers, the drag handle hides entirely for nested + * content positioned inside those regions. + * + * This is useful for scoping nested drag handles to specific editor regions + * (e.g., lists and blockquotes) while keeping simpler blocks (headings, + * paragraphs) working with only top-level handles. + * + * @example + * // Only enable nested dragging inside lists + * allowedContainers: ['bulletList', 'orderedList'] * * @example - * // Only enable nested dragging in lists and custom question blocks - * allowedContainers: ['bulletList', 'orderedList', 'questionBlock'] + * // Enable nested dragging inside lists and blockquotes + * allowedContainers: ['bulletList', 'orderedList', 'blockquote'] */ allowedContainers?: string[] /** - * Edge detection behavior. Controls when to prefer parent over nested node. + * Controls when the drag handle prefers a parent node over a deeply nested + * child node, based on cursor proximity to element edges. + * + * When the cursor is near a configured edge of a nested element, the scoring + * system deducts \`strength * depth\` from deeper nodes, making the parent + * container (like an entire list) easier to grab. + * + * **Presets (quick and simple):** + * - `'left'` (default): Cursor near left or top edge → prefer parent (LTR) + * - `'right'`: Cursor near right or top edge → prefer parent (RTL) + * - `'both'`: Cursor near left, right, or top edge → prefer parent + * - \`'none'\`: Disabled, cursor position does not affect scoring at all * - * Presets: - * - `'left'` (default) - Prefer parent near left/top edges - * - `'right'` - Prefer parent near right/top edges (for RTL) - * - `'both'` - Prefer parent near any horizontal edge - * - `'none'` - Disable edge detection + * **Fine-tuned object (full control):** + * Pass a partial `EdgeDetectionConfig` to override only what you need: + * - `edges`: Which element edges trigger parent preference (default: `['left', 'top']`) + * - `threshold`: Width of the edge zone in pixels (default: `12`). Higher = easier to trigger. + * - `strength`: Deduction multiplier per depth level (default: `500`). Higher = stronger parent preference. * - * Or pass a partial/full config object for fine-tuned control. - * Partial configs are merged with defaults. + * The effective deduction when near an edge is `strength * depth`, so deeper + * nesting always gets penalized more, you naturally grab the outer wrapper. * * @default 'left' * * @example - * // Only override threshold, keep default edges and strength - * edgeDetection: { threshold: 20 } + * // Just widen the trigger zone to 24px + * edgeDetection: { threshold: 24 } + * + * @example + * // Top/bottom edges only, very aggressive parent preference + * edgeDetection: { + * edges: ['top', 'bottom'], + * threshold: 30, + * strength: 1000, + * } + * + * @example + * // Gentle edge detection, nudges toward parents without blocking typical depths + * edgeDetection: { + * threshold: 6, + * strength: 200, + * } */ edgeDetection?: EdgeDetectionPreset | Partial } /** - * Normalized nested options with all properties resolved. + * Fully resolved nested drag handle options after normalization. + * Produced by `normalizeNestedOptions()` from user-provided `NestedOptions` + * or a boolean flag. This is the internal representation consumed by the plugin. */ export interface NormalizedNestedOptions { /** Whether nested drag handles are enabled */ enabled: boolean - /** Custom rules to apply */ + /** Custom rules to apply (combined with default rules if `defaultRules` is true) */ rules: DragHandleRule[] - /** Whether to include default rules */ + /** Whether the built-in default rules are included alongside custom rules */ defaultRules: boolean - /** Allowed container node types (undefined means all) */ + /** Allowed container node types, or `undefined` to allow all containers */ allowedContainers: string[] | undefined - /** Resolved edge detection configuration */ + /** Fully resolved edge detection configuration with all defaults applied */ edgeDetection: EdgeDetectionConfig } diff --git a/packages/extension-drag-handle/src/types/rules.ts b/packages/extension-drag-handle/src/types/rules.ts index 5354f7c2e7..7d58bb9947 100644 --- a/packages/extension-drag-handle/src/types/rules.ts +++ b/packages/extension-drag-handle/src/types/rules.ts @@ -2,52 +2,86 @@ import type { Node, ResolvedPos } from '@tiptap/pm/model' import type { EditorView } from '@tiptap/pm/view' /** - * Context provided to each rule for evaluation. - * Contains all information needed to make a decision. + * Context provided to each rule evaluation function. + * + * Contains information about the node being evaluated and its position in the + * ProseMirror document tree. This is the full context available for making + * scoring decisions in custom `DragHandleRule` implementations. + * + * @example + * // Typical usage in a custom rule + * evaluate: ({ node, parent, depth, isFirst }) => { + * if (parent?.type.name === 'listItem' && isFirst) { + * return 1000 // exclude first child of list items + * } + * if (depth > 3) { + * return depth * 200 // deprioritize deep nesting + * } + * return 0 + * } */ export interface RuleContext { - /** The node being evaluated */ + /** The ProseMirror node being evaluated as a potential drag target */ node: Node /** Absolute position of the node in the document */ pos: number - /** Depth in the document tree (0 = doc root) */ + /** + * Depth in the document tree (0 = document root). + * A paragraph inside a listItem inside a bulletList has depth 3. + */ depth: number - /** Parent node (null if this is the doc) */ + /** + * Parent node of the node being evaluated. + * `null` if the node is the document root (depth 0). + */ parent: Node | null - /** This node's index among siblings (0-based) */ + /** This node's index among its parent's children (0-based) */ index: number - /** Convenience: true if index === 0 */ + /** Convenience: `true` when this node is the first child of its parent (index === 0) */ isFirst: boolean - /** Convenience: true if this is the last child */ + /** Convenience: `true` when this node is the last child of its parent */ isLast: boolean - /** The resolved position for advanced queries */ + /** + * The resolved position for advanced ProseMirror queries. + * Allows access to ancestor nodes, child nodes, and document structure + * beyond the current node. + */ $pos: ResolvedPos - /** Editor view for DOM access if needed */ + /** + * The editor view for DOM access if needed in custom rules. + * Can be used to access the editor DOM element, measure dimensions, etc. + */ view: EditorView } /** * A rule that determines whether a node should be a drag target. + * + * Each rule receives a `RuleContext` and returns a numeric deduction. + * Multiple rules are evaluated in sequence; the total deduction is subtracted + * from the node's base score (1000). If the score drops to 0 or below, + * the node is excluded as a drag target. */ export interface DragHandleRule { /** * Unique identifier for debugging and rule management. + * Choose a descriptive name that explains what the rule does. */ id: string /** * Evaluate the node and return a score deduction. * - * The return value is subtracted from the node's score (which starts at 1000). - * Higher deductions make the node less likely to be selected as the drag target. + * The return value is subtracted from the node's base score (1000). + * Higher deductions make the node less likely to be selected. * * @returns A number representing the score deduction: * - `0` - No deduction, node remains fully eligible @@ -66,7 +100,6 @@ export interface DragHandleRule { * @example * // Prefer shallower nodes with partial deduction * evaluate: ({ depth }) => { - * // Deeper nodes get small deductions, making shallower nodes win ties * return depth * 50 * } * @@ -74,7 +107,6 @@ export interface DragHandleRule { * // Context-based partial deductions * evaluate: ({ node, parent }) => { * if (parent?.type.name === 'tableCell') { - * // Inside table cells, slightly prefer the cell over its content * return node.type.name === 'paragraph' ? 100 : 0 * } * return 0 From 207a2bc147fa8b098553b83019d03791bb876f5d Mon Sep 17 00:00:00 2001 From: bdbch <6538827+bdbch@users.noreply.github.com> Date: Tue, 5 May 2026 12:52:31 +0200 Subject: [PATCH 5/6] fix(core): release extension parent/child graph on Editor.destroy() to prevent memory leak (#7795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): release extension parent/child graph on Editor.destroy() to prevent memory leak (#7769) * fix(core): make Editor.destroy() idempotent with a destroyed flag * fix(core): avoid nulling ancestor.child in ExtensionManager.destroy() to prevent cross-editor side effects * fix(core): address PR review feedback — preserve extension.parent/child on shared instances, clear extensionStorage, fix walk-up loop * fix(core): walk full parent chain in ExtensionManager.destroy() and add multi-level test * docs(core): clarify ExtensionManager.destroy() JSDoc on what is mutated --- .changeset/ninety-icons-hide.md | 5 + .../core/__tests__/extendExtensions.spec.ts | 118 +++++++++++++++++- packages/core/src/Editor.ts | 14 +++ packages/core/src/Extendable.ts | 2 + packages/core/src/ExtensionManager.ts | 35 ++++++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 .changeset/ninety-icons-hide.md diff --git a/.changeset/ninety-icons-hide.md b/.changeset/ninety-icons-hide.md new file mode 100644 index 0000000000..320f1f5c82 --- /dev/null +++ b/.changeset/ninety-icons-hide.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Fixed a memory leak where `Editor.destroy()` did not release the Extension parent/child graph. Module-scope extension singletons retained references to configured extensions, preventing garbage collection of extension options (including DOM closures). The fix also cleans up `ExtensionManager`, `schema`, and `commandManager` references on destroy. diff --git a/packages/core/__tests__/extendExtensions.spec.ts b/packages/core/__tests__/extendExtensions.spec.ts index 2d97488b5d..67a286d608 100644 --- a/packages/core/__tests__/extendExtensions.spec.ts +++ b/packages/core/__tests__/extendExtensions.spec.ts @@ -1,4 +1,7 @@ -import { Extension, getExtensionField, Mark, Node } from '@tiptap/core' +import { Editor, Extension, getExtensionField, Mark, Node } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' import { describe, expect, it } from 'vitest' declare module '@tiptap/core' { @@ -416,3 +419,116 @@ describe('extend extensions', () => { }) }) }) + +describe('parent/child cleanup on destroy', () => { + it('should not leak child reference when configure() is called on a singleton', () => { + const singleton = Extension.create({ + name: 'testExtension', + addOptions() { + return { foo: 'bar' } + }, + }) + + const configuredExtension = singleton.configure({ foo: 'baz' }) + + expect(singleton.child).toBeNull() + expect(configuredExtension.parent).toBeNull() + }) + + it('should break parent/child chain when editor is destroyed (extend path)', () => { + const singleton = Extension.create({ + name: 'testExtension', + addOptions() { + return { foo: 'bar' } + }, + }) + + const childExtension = singleton.extend({ + addOptions() { + return { ...this.parent?.(), foo: 'baz' } + }, + }) + + expect(singleton.child).toBe(childExtension) + expect(childExtension.parent).toBe(singleton) + + const editor = new Editor({ + element: null, + extensions: [Document, Paragraph, Text, childExtension], + }) + + editor.destroy() + + expect(singleton.child).toBeNull() + }) + + it('should clear forward parent.child links on all extensions after editor.destroy()', () => { + const singletonA = Extension.create({ + name: 'extA', + addOptions() { + return { value: 'a' } + }, + }) + const singletonB = Extension.create({ + name: 'extB', + addOptions() { + return { value: 'b' } + }, + }) + + const configuredA = singletonA.configure({ value: 'a-configured' }) + const childB = singletonB.extend({ name: 'extB-child' }) + + const editor = new Editor({ + element: null, + extensions: [Document, Paragraph, Text, configuredA, childB], + }) + + const { extensions } = editor.extensionManager + + editor.destroy() + + extensions.forEach(ext => { + if (ext.parent?.child === ext) { + // This should never be true after destroy — the forward link is always broken + expect(ext.parent.child).toBeNull() + } + }) + }) + + it('should break all ancestor child links in a multi-level extend chain after editor.destroy()', () => { + const root = Extension.create({ + name: 'root', + addOptions() { + return { level: 0 } + }, + }) + + const child = root.extend({ + addOptions() { + return { ...this.parent?.(), level: 1 } + }, + }) + + const grandchild = child.extend({ + addOptions() { + return { ...this.parent?.(), level: 2 } + }, + }) + + expect(root.child).toBe(child) + expect(child.child).toBe(grandchild) + expect(grandchild.parent).toBe(child) + expect(child.parent).toBe(root) + + const editor = new Editor({ + element: null, + extensions: [Document, Paragraph, Text, grandchild], + }) + + editor.destroy() + + expect(root.child).toBeNull() + expect(child.child).toBeNull() + }) +}) diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index 08cfb681e5..c9f83e641b 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -68,6 +68,8 @@ export class Editor extends EventEmitter { public isFocused = false + private destroyed = false + private editorState!: EditorState /** @@ -764,11 +766,23 @@ export class Editor extends EventEmitter { * Destroy the editor. */ public destroy(): void { + if (this.destroyed) { + return + } + + this.destroyed = true + this.emit('destroy') this.unmount() this.removeAllListeners() + + this.extensionManager.destroy() + this.extensionManager = null as any + this.schema = null as any + this.commandManager = null as any + this.extensionStorage = {} as Storage } /** diff --git a/packages/core/src/Extendable.ts b/packages/core/src/Extendable.ts index b6056f3533..130988b5fd 100644 --- a/packages/core/src/Extendable.ts +++ b/packages/core/src/Extendable.ts @@ -580,6 +580,8 @@ export class Extendable< extension.name = this.name extension.parent = this.parent + this.child = null + return extension } diff --git a/packages/core/src/ExtensionManager.ts b/packages/core/src/ExtensionManager.ts index 6c8d0eeb30..7ad85c9168 100644 --- a/packages/core/src/ExtensionManager.ts +++ b/packages/core/src/ExtensionManager.ts @@ -363,6 +363,41 @@ export class ExtensionManager { ) } + /** + * Destroy the extension manager and clean up all extension references + * to prevent memory leaks through parent/child extension chains. + * + * Walks each extension's full parent chain and nulls every forward + * `parent.child → current` link where the parent still points to the + * current node. This breaks the retention path from module-scope + * singleton roots through deep extend() chains. + * + * Only ancestor `.child` links matching the current chain are cleared. + * The `.parent` pointer on ancestors is never touched — extensions + * may be shared across live editors, so their own backward references + * and non-matching forward links must remain intact. + */ + destroy() { + this.extensions.forEach(extension => { + let current: any = extension + + while (current.parent) { + const parent = current.parent + + if (parent.child === current) { + parent.child = null + } + + current = parent + } + }) + + this.extensions = [] + this.baseExtensions = [] + this.schema = null as any + this.editor = null as any + } + /** * Go through all extensions, create extension storages & setup marks * & bind editor event listener. From 0e6a6e44aa8462d1c3f1e5c2f7b4e282b25c0505 Mon Sep 17 00:00:00 2001 From: bdbch <6538827+bdbch@users.noreply.github.com> Date: Tue, 5 May 2026 12:52:41 +0200 Subject: [PATCH 6/6] fix(markdown): handle empty initial markdown content (#7796) * fix(markdown): handle empty initial markdown content When initial content is an empty string with contentType 'markdown', MarkdownManager.parse('') returns { type: 'doc', content: [] } which violates the schema's block+ requirement. Instead of overriding editor options with an invalid doc, leave the content as-is so ProseMirror's DOMParser.fillBefore can insert the required default paragraph. Closes #7157 * chore: add changeset * fix(markdown): enhance tests for empty markdown content and support bold formatting --- .changeset/clean-bobcats-double.md | 5 ++ .../markdown/__tests__/empty-content.spec.ts | 88 +++++++++++++++++++ packages/markdown/src/Extension.ts | 8 +- 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 .changeset/clean-bobcats-double.md create mode 100644 packages/markdown/__tests__/empty-content.spec.ts diff --git a/.changeset/clean-bobcats-double.md b/.changeset/clean-bobcats-double.md new file mode 100644 index 0000000000..9133f790d6 --- /dev/null +++ b/.changeset/clean-bobcats-double.md @@ -0,0 +1,5 @@ +--- +'@tiptap/markdown': patch +--- + +Fix editor becoming unresponsive when initialized with empty markdown content and `contentType: 'markdown'` diff --git a/packages/markdown/__tests__/empty-content.spec.ts b/packages/markdown/__tests__/empty-content.spec.ts new file mode 100644 index 0000000000..ddf6578e4d --- /dev/null +++ b/packages/markdown/__tests__/empty-content.spec.ts @@ -0,0 +1,88 @@ +import { Editor } from '@tiptap/core' +import { Bold } from '@tiptap/extension-bold' +import { Document } from '@tiptap/extension-document' +import { Paragraph } from '@tiptap/extension-paragraph' +import { Text } from '@tiptap/extension-text' +import { Markdown } from '@tiptap/markdown' +import { afterEach, describe, expect, it } from 'vitest' + +describe('empty markdown content', () => { + let editor: Editor + + afterEach(() => { + editor?.destroy() + }) + + it('should create a valid document when initial content is an empty string', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Markdown], + content: '', + contentType: 'markdown', + }) + + const json = editor.getJSON() + + expect(json.type).toBe('doc') + expect(json.content).toBeDefined() + expect(json.content).toHaveLength(1) + expect(json.content![0].type).toBe('paragraph') + }) + + it('should allow inserting text after initialization with empty markdown content', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Markdown], + content: '', + contentType: 'markdown', + }) + + editor.commands.insertContent('Hello') + + const json = editor.getJSON() + expect((json.content![0].content![0] as any).text).toBe('Hello') + }) + + it('should allow inserting text with a paragraph command after initialization with empty markdown content', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Markdown], + content: '', + contentType: 'markdown', + }) + + editor.commands.insertContentAt(0, { type: 'text', text: 'Hello' }) + + const json = editor.getJSON() + expect((json.content![0].content![0] as any).text).toBe('Hello') + }) + + it('should allow inserting markdown-formatted content after initialization with empty markdown content', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Bold, Markdown], + content: '', + contentType: 'markdown', + }) + + editor.commands.insertContent('**bold**', { contentType: 'markdown' }) + + const json = editor.getJSON() + const textNode = json.content![0].content![0] as any + expect(textNode.type).toBe('text') + expect(textNode.text).toBe('bold') + expect(textNode.marks?.[0]?.type).toBe('bold') + }) + + it('should allow inserting markdown-formatted content at a specific position after empty initialization', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, Bold, Markdown], + content: '', + contentType: 'markdown', + }) + + editor.commands.insertContentAt(0, '**bold**', { contentType: 'markdown' }) + + const json = editor.getJSON() + const textNode = json.content![0].content![0] as any + expect(textNode.type).toBe('text') + expect(textNode.text).toBe('bold') + expect(textNode.marks?.[0]?.type).toBe('bold') + }) +}) diff --git a/packages/markdown/src/Extension.ts b/packages/markdown/src/Extension.ts index eaa42ef38e..14b2755533 100644 --- a/packages/markdown/src/Extension.ts +++ b/packages/markdown/src/Extension.ts @@ -208,6 +208,12 @@ export const Markdown = Extension.create