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/.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/.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/.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. 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 diff --git a/packages/extension-mathematics/__tests__/blockMath.spec.ts b/packages/extension-mathematics/__tests__/blockMath.spec.ts new file mode 100644 index 0000000000..2621bc0af1 --- /dev/null +++ b/packages/extension-mathematics/__tests__/blockMath.spec.ts @@ -0,0 +1,102 @@ +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' + +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('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], + 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..bc10017708 100644 --- a/packages/extension-mathematics/src/extensions/BlockMath.ts +++ b/packages/extension-mathematics/src/extensions/BlockMath.ts @@ -221,10 +221,18 @@ 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 })) + const consumesHostTextblock = + $from.depth > 0 && $from.parent.isTextblock && range.from === $from.start() && range.to === $from.end() + // 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) + + const replacementRange = canReplaceHostTextblock ? { from: $from.before(), to: $from.after() } : range + + tr.replaceWith(replacementRange.from, replacementRange.to, node) }, }), ] 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