@tiptap/core 3.12.1 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiptap/core",
3
3
  "description": "headless rich text editor",
4
- "version": "3.12.1",
4
+ "version": "3.14.0",
5
5
  "homepage": "https://tiptap.dev",
6
6
  "keywords": [
7
7
  "tiptap",
@@ -52,10 +52,10 @@
52
52
  "jsx-dev-runtime"
53
53
  ],
54
54
  "devDependencies": {
55
- "@tiptap/pm": "^3.12.1"
55
+ "@tiptap/pm": "^3.14.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@tiptap/pm": "^3.12.1"
58
+ "@tiptap/pm": "^3.14.0"
59
59
  },
60
60
  "repository": {
61
61
  "type": "git",
@@ -1,6 +1,8 @@
1
1
  import type { Node as PMNode } from '@tiptap/pm/model'
2
2
  import type { Decoration, DecorationSource, NodeView } from '@tiptap/pm/view'
3
3
 
4
+ import type { Editor } from '../Editor.js'
5
+
4
6
  const isTouchEvent = (e: MouseEvent | TouchEvent): e is TouchEvent => {
5
7
  return 'touches' in e
6
8
  }
@@ -73,6 +75,11 @@ export type ResizableNodeViewOptions = {
73
75
  */
74
76
  node: PMNode
75
77
 
78
+ /**
79
+ * The Tiptap editor instance
80
+ */
81
+ editor: Editor
82
+
76
83
  /**
77
84
  * Function that returns the current position of the node in the document
78
85
  */
@@ -204,6 +211,51 @@ export type ResizableNodeViewOptions = {
204
211
  /** Class added to container while actively resizing */
205
212
  resizing?: string
206
213
  }
214
+
215
+ /**
216
+ * Optional callback for creating custom resize handle elements.
217
+ *
218
+ * This function allows developers to define their own handle element
219
+ * (e.g., custom icons, classes, or styles) for a given resize direction.
220
+ * It is called internally for each handle direction.
221
+ *
222
+ * @param direction - The direction of the handle being created (e.g., 'top', 'bottom-right').
223
+ * @returns The custom handle HTMLElement.
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * createCustomHandle: (direction) => {
228
+ * const handle = document.createElement('div')
229
+ * handle.dataset.resizeHandle = direction
230
+ * handle.style.position = 'absolute'
231
+ * handle.className = 'tiptap-custom-handle'
232
+ *
233
+ * const isTop = direction.includes('top')
234
+ * const isBottom = direction.includes('bottom')
235
+ * const isLeft = direction.includes('left')
236
+ * const isRight = direction.includes('right')
237
+ *
238
+ * if (isTop) handle.style.top = '0'
239
+ * if (isBottom) handle.style.bottom = '0'
240
+ * if (isLeft) handle.style.left = '0'
241
+ * if (isRight) handle.style.right = '0'
242
+ *
243
+ * // Edge handles span the full width or height
244
+ * if (direction === 'top' || direction === 'bottom') {
245
+ * handle.style.left = '0'
246
+ * handle.style.right = '0'
247
+ * }
248
+ *
249
+ * if (direction === 'left' || direction === 'right') {
250
+ * handle.style.top = '0'
251
+ * handle.style.bottom = '0'
252
+ * }
253
+ *
254
+ * return handle
255
+ * }
256
+ * ```
257
+ */
258
+ createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement
207
259
  }
208
260
  }
209
261
 
@@ -247,6 +299,9 @@ export class ResizableNodeView {
247
299
  /** The ProseMirror node instance */
248
300
  node: PMNode
249
301
 
302
+ /** The Tiptap editor instance */
303
+ editor: Editor
304
+
250
305
  /** The DOM element being made resizable */
251
306
  element: HTMLElement
252
307
 
@@ -294,6 +349,9 @@ export class ResizableNodeView {
294
349
  resizing: '',
295
350
  }
296
351
 
352
+ /** Optional callback for creating custom resize handles */
353
+ createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement
354
+
297
355
  /** Initial width of the element (for aspect ratio calculation) */
298
356
  private initialWidth: number = 0
299
357
 
@@ -324,6 +382,12 @@ export class ResizableNodeView {
324
382
  /** Whether Shift key is currently pressed (for temporary aspect ratio lock) */
325
383
  private isShiftKeyPressed: boolean = false
326
384
 
385
+ /** Last known editable state of the editor */
386
+ private lastEditableState: boolean | undefined = undefined
387
+
388
+ /** Map of handle elements by direction */
389
+ private handleMap = new Map<ResizableNodeViewDirection, HTMLElement>()
390
+
327
391
  /**
328
392
  * Creates a new ResizableNodeView instance.
329
393
  *
@@ -334,6 +398,7 @@ export class ResizableNodeView {
334
398
  */
335
399
  constructor(options: ResizableNodeViewOptions) {
336
400
  this.node = options.node
401
+ this.editor = options.editor
337
402
  this.element = options.element
338
403
  this.contentElement = options.contentElement
339
404
 
@@ -371,11 +436,17 @@ export class ResizableNodeView {
371
436
  }
372
437
  }
373
438
 
439
+ if (options.options?.createCustomHandle) {
440
+ this.createCustomHandle = options.options.createCustomHandle
441
+ }
442
+
374
443
  this.wrapper = this.createWrapper()
375
444
  this.container = this.createContainer()
376
445
 
377
446
  this.applyInitialSize()
378
447
  this.attachHandles()
448
+
449
+ this.editor.on('update', this.handleEditorUpdate.bind(this))
379
450
  }
380
451
 
381
452
  /**
@@ -394,6 +465,23 @@ export class ResizableNodeView {
394
465
  return this.contentElement
395
466
  }
396
467
 
468
+ private handleEditorUpdate() {
469
+ const isEditable = this.editor.isEditable
470
+
471
+ // Only if state actually changed
472
+ if (isEditable === this.lastEditableState) {
473
+ return
474
+ }
475
+
476
+ this.lastEditableState = isEditable
477
+
478
+ if (!isEditable) {
479
+ this.removeHandles()
480
+ } else if (isEditable && this.handleMap.size === 0) {
481
+ this.attachHandles()
482
+ }
483
+ }
484
+
397
485
  /**
398
486
  * Called when the node's content or attributes change.
399
487
  *
@@ -442,6 +530,8 @@ export class ResizableNodeView {
442
530
  this.activeHandle = null
443
531
  }
444
532
 
533
+ this.editor.off('update', this.handleEditorUpdate.bind(this))
534
+
445
535
  this.container.remove()
446
536
  }
447
537
 
@@ -459,8 +549,6 @@ export class ResizableNodeView {
459
549
  element.dataset.resizeContainer = ''
460
550
  element.dataset.node = this.node.type.name
461
551
  element.style.display = 'flex'
462
- element.style.justifyContent = 'flex-start'
463
- element.style.alignItems = 'flex-start'
464
552
 
465
553
  if (this.classNames.container) {
466
554
  element.className = this.classNames.container
@@ -567,14 +655,44 @@ export class ResizableNodeView {
567
655
  */
568
656
  private attachHandles(): void {
569
657
  this.directions.forEach(direction => {
570
- const handle = this.createHandle(direction)
571
- this.positionHandle(handle, direction)
658
+ let handle: HTMLElement
659
+
660
+ if (this.createCustomHandle) {
661
+ handle = this.createCustomHandle(direction)
662
+ } else {
663
+ handle = this.createHandle(direction)
664
+ }
665
+
666
+ if (!(handle instanceof HTMLElement)) {
667
+ console.warn(
668
+ `[ResizableNodeView] createCustomHandle("${direction}") did not return an HTMLElement. Falling back to default handle.`,
669
+ )
670
+ handle = this.createHandle(direction)
671
+ }
672
+
673
+ if (!this.createCustomHandle) {
674
+ this.positionHandle(handle, direction)
675
+ }
676
+
572
677
  handle.addEventListener('mousedown', event => this.handleResizeStart(event, direction))
573
678
  handle.addEventListener('touchstart', event => this.handleResizeStart(event as unknown as MouseEvent, direction))
679
+
680
+ this.handleMap.set(direction, handle)
681
+
574
682
  this.wrapper.appendChild(handle)
575
683
  })
576
684
  }
577
685
 
686
+ /**
687
+ * Removes all resize handles from the wrapper.
688
+ *
689
+ * Cleans up the handle map and removes each handle element from the DOM.
690
+ */
691
+ private removeHandles(): void {
692
+ this.handleMap.forEach(el => el.remove())
693
+ this.handleMap.clear()
694
+ }
695
+
578
696
  /**
579
697
  * Applies initial sizing from node attributes to the element.
580
698
  *
@@ -40,6 +40,23 @@ function serializeShortcodeAttributes(attrs: Record<string, any>): string {
40
40
  .join(' ')
41
41
  }
42
42
 
43
+ /**
44
+ * Configuration for an allowed attribute in markdown serialization.
45
+ * Can be a simple string (attribute name) or an object with additional options.
46
+ */
47
+ export type AllowedAttribute =
48
+ | string
49
+ | {
50
+ /** The attribute name */
51
+ name: string
52
+ /**
53
+ * If provided, the attribute will be skipped during serialization when its value
54
+ * equals this default value. This keeps markdown output clean by omitting
55
+ * attributes that have their default values.
56
+ */
57
+ skipIfDefault?: any
58
+ }
59
+
43
60
  export interface InlineMarkdownSpecOptions {
44
61
  /** The Tiptap node name this spec is for */
45
62
  nodeName: string
@@ -55,8 +72,26 @@ export interface InlineMarkdownSpecOptions {
55
72
  defaultAttributes?: Record<string, any>
56
73
  /** Whether this is a self-closing shortcode (no content, like [emoji name=party]) */
57
74
  selfClosing?: boolean
58
- /** Allowlist of attributes to include in markdown (if not provided, all attributes are included) */
59
- allowedAttributes?: string[]
75
+ /**
76
+ * Allowlist of attributes to include in markdown serialization.
77
+ * If not provided, all attributes are included.
78
+ *
79
+ * Each item can be either:
80
+ * - A string: the attribute name (always included if present)
81
+ * - An object: `{ name: string, skipIfDefault?: any }` for conditional inclusion
82
+ *
83
+ * @example
84
+ * // Simple string attributes (backward compatible)
85
+ * allowedAttributes: ['id', 'label']
86
+ *
87
+ * // Mixed with conditional attributes
88
+ * allowedAttributes: [
89
+ * 'id',
90
+ * 'label',
91
+ * { name: 'mentionSuggestionChar', skipIfDefault: '@' }
92
+ * ]
93
+ */
94
+ allowedAttributes?: AllowedAttribute[]
60
95
  }
61
96
 
62
97
  /**
@@ -130,9 +165,20 @@ export function createInlineMarkdownSpec(options: InlineMarkdownSpecOptions): {
130
165
  }
131
166
 
132
167
  const filtered: Record<string, any> = {}
133
- allowedAttributes.forEach(key => {
134
- if (key in attrs) {
135
- filtered[key] = attrs[key]
168
+ allowedAttributes.forEach(attr => {
169
+ // Handle both string and object formats for backward compatibility
170
+ const attrName = typeof attr === 'string' ? attr : attr.name
171
+ const skipIfDefault = typeof attr === 'string' ? undefined : attr.skipIfDefault
172
+
173
+ if (attrName in attrs) {
174
+ const value = attrs[attrName]
175
+
176
+ // Skip if value equals the default (when skipIfDefault is specified)
177
+ if (skipIfDefault !== undefined && value === skipIfDefault) {
178
+ return
179
+ }
180
+
181
+ filtered[attrName] = value
136
182
  }
137
183
  })
138
184
  return filtered