@tiptap/core 3.10.7 → 3.11.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.10.7",
4
+ "version": "3.11.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.10.7"
55
+ "@tiptap/pm": "^3.11.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@tiptap/pm": "^3.10.7"
58
+ "@tiptap/pm": "^3.11.0"
59
59
  },
60
60
  "repository": {
61
61
  "type": "git",
package/src/Editor.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  Keymap,
18
18
  Paste,
19
19
  Tabindex,
20
+ TextDirection,
20
21
  } from './extensions/index.js'
21
22
  import { createDocument } from './helpers/createDocument.js'
22
23
  import { getAttributes } from './helpers/getAttributes.js'
@@ -87,6 +88,7 @@ export class Editor extends EventEmitter<EditorEvents> {
87
88
  extensions: [],
88
89
  autofocus: false,
89
90
  editable: true,
91
+ textDirection: undefined,
90
92
  editorProps: {},
91
93
  parseOptions: {},
92
94
  coreExtensionOptions: {},
@@ -430,6 +432,9 @@ export class Editor extends EventEmitter<EditorEvents> {
430
432
  Drop,
431
433
  Paste,
432
434
  Delete,
435
+ TextDirection.configure({
436
+ direction: this.options.textDirection,
437
+ }),
433
438
  ].filter(ext => {
434
439
  if (typeof this.options.enableCoreExtensions === 'object') {
435
440
  return (
@@ -39,6 +39,7 @@ export * from './setMark.js'
39
39
  export * from './setMeta.js'
40
40
  export * from './setNode.js'
41
41
  export * from './setNodeSelection.js'
42
+ export * from './setTextDirection.js'
42
43
  export * from './setTextSelection.js'
43
44
  export * from './sinkListItem.js'
44
45
  export * from './splitBlock.js'
@@ -50,6 +51,7 @@ export * from './toggleWrap.js'
50
51
  export * from './undoInputRule.js'
51
52
  export * from './unsetAllMarks.js'
52
53
  export * from './unsetMark.js'
54
+ export * from './unsetTextDirection.js'
53
55
  export * from './updateAttributes.js'
54
56
  export * from './wrapIn.js'
55
57
  export * from './wrapInList.js'
@@ -43,23 +43,31 @@ export const resetAttributes: RawCommands['resetAttributes'] =
43
43
  markType = getMarkType(typeOrName as MarkType, state.schema)
44
44
  }
45
45
 
46
- if (dispatch) {
47
- tr.selection.ranges.forEach(range => {
48
- state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
49
- if (nodeType && nodeType === node.type) {
46
+ let canReset = false
47
+
48
+ tr.selection.ranges.forEach(range => {
49
+ state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
50
+ if (nodeType && nodeType === node.type) {
51
+ canReset = true
52
+
53
+ if (dispatch) {
50
54
  tr.setNodeMarkup(pos, undefined, deleteProps(node.attrs, attributes))
51
55
  }
56
+ }
57
+
58
+ if (markType && node.marks.length) {
59
+ node.marks.forEach(mark => {
60
+ if (markType === mark.type) {
61
+ canReset = true
52
62
 
53
- if (markType && node.marks.length) {
54
- node.marks.forEach(mark => {
55
- if (markType === mark.type) {
63
+ if (dispatch) {
56
64
  tr.addMark(pos, pos + node.nodeSize, markType.create(deleteProps(mark.attrs, attributes)))
57
65
  }
58
- })
59
- }
60
- })
66
+ }
67
+ })
68
+ }
61
69
  })
62
- }
70
+ })
63
71
 
64
- return true
72
+ return canReset
65
73
  }
@@ -0,0 +1,51 @@
1
+ import type { Range, RawCommands } from '../types.js'
2
+
3
+ declare module '@tiptap/core' {
4
+ interface Commands<ReturnType> {
5
+ setTextDirection: {
6
+ /**
7
+ * Set the text direction for nodes.
8
+ * If no position is provided, it will use the current selection.
9
+ * @param direction The text direction to set ('ltr', 'rtl', or 'auto')
10
+ * @param position Optional position or range to apply the direction to
11
+ * @example editor.commands.setTextDirection('rtl')
12
+ * @example editor.commands.setTextDirection('ltr', { from: 0, to: 10 })
13
+ */
14
+ setTextDirection: (direction: 'ltr' | 'rtl' | 'auto', position?: number | Range) => ReturnType
15
+ }
16
+ }
17
+ }
18
+
19
+ export const setTextDirection: RawCommands['setTextDirection'] =
20
+ (direction, position) =>
21
+ ({ tr, state, dispatch }) => {
22
+ const { selection } = state
23
+ let from: number
24
+ let to: number
25
+
26
+ if (typeof position === 'number') {
27
+ from = position
28
+ to = position
29
+ } else if (position && 'from' in position && 'to' in position) {
30
+ from = position.from
31
+ to = position.to
32
+ } else {
33
+ from = selection.from
34
+ to = selection.to
35
+ }
36
+
37
+ if (dispatch) {
38
+ tr.doc.nodesBetween(from, to, (node, pos) => {
39
+ if (node.isText) {
40
+ return
41
+ }
42
+
43
+ tr.setNodeMarkup(pos, undefined, {
44
+ ...node.attrs,
45
+ dir: direction,
46
+ })
47
+ })
48
+ }
49
+
50
+ return true
51
+ }
@@ -0,0 +1,51 @@
1
+ import type { Range, RawCommands } from '../types.js'
2
+
3
+ declare module '@tiptap/core' {
4
+ interface Commands<ReturnType> {
5
+ unsetTextDirection: {
6
+ /**
7
+ * Remove the text direction attribute from nodes.
8
+ * If no position is provided, it will use the current selection.
9
+ * @param position Optional position or range to remove the direction from
10
+ * @example editor.commands.unsetTextDirection()
11
+ * @example editor.commands.unsetTextDirection({ from: 0, to: 10 })
12
+ */
13
+ unsetTextDirection: (position?: number | Range) => ReturnType
14
+ }
15
+ }
16
+ }
17
+
18
+ export const unsetTextDirection: RawCommands['unsetTextDirection'] =
19
+ position =>
20
+ ({ tr, state, dispatch }) => {
21
+ const { selection } = state
22
+ let from: number
23
+ let to: number
24
+
25
+ if (typeof position === 'number') {
26
+ from = position
27
+ to = position
28
+ } else if (position && 'from' in position && 'to' in position) {
29
+ from = position.from
30
+ to = position.to
31
+ } else {
32
+ from = selection.from
33
+ to = selection.to
34
+ }
35
+
36
+ if (dispatch) {
37
+ tr.doc.nodesBetween(from, to, (node, pos) => {
38
+ if (node.isText) {
39
+ return
40
+ }
41
+
42
+ const newAttrs = { ...node.attrs }
43
+
44
+ delete newAttrs.dir
45
+
46
+ tr.setNodeMarkup(pos, undefined, newAttrs)
47
+ })
48
+ }
49
+
50
+ return true
51
+ }
@@ -53,45 +53,55 @@ export const updateAttributes: RawCommands['updateAttributes'] =
53
53
  markType = getMarkType(typeOrName as MarkType, state.schema)
54
54
  }
55
55
 
56
- if (dispatch) {
57
- tr.selection.ranges.forEach((range: SelectionRange) => {
58
- const from = range.$from.pos
59
- const to = range.$to.pos
60
-
61
- let lastPos: number | undefined
62
- let lastNode: Node | undefined
63
- let trimmedFrom: number
64
- let trimmedTo: number
65
-
66
- if (tr.selection.empty) {
67
- state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
56
+ let canUpdate = false
57
+
58
+ tr.selection.ranges.forEach((range: SelectionRange) => {
59
+ const from = range.$from.pos
60
+ const to = range.$to.pos
61
+
62
+ let lastPos: number | undefined
63
+ let lastNode: Node | undefined
64
+ let trimmedFrom: number
65
+ let trimmedTo: number
66
+
67
+ if (tr.selection.empty) {
68
+ state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
69
+ if (nodeType && nodeType === node.type) {
70
+ canUpdate = true
71
+ trimmedFrom = Math.max(pos, from)
72
+ trimmedTo = Math.min(pos + node.nodeSize, to)
73
+ lastPos = pos
74
+ lastNode = node
75
+ }
76
+ })
77
+ } else {
78
+ state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
79
+ if (pos < from && nodeType && nodeType === node.type) {
80
+ canUpdate = true
81
+ trimmedFrom = Math.max(pos, from)
82
+ trimmedTo = Math.min(pos + node.nodeSize, to)
83
+ lastPos = pos
84
+ lastNode = node
85
+ }
86
+
87
+ if (pos >= from && pos <= to) {
68
88
  if (nodeType && nodeType === node.type) {
69
- trimmedFrom = Math.max(pos, from)
70
- trimmedTo = Math.min(pos + node.nodeSize, to)
71
- lastPos = pos
72
- lastNode = node
73
- }
74
- })
75
- } else {
76
- state.doc.nodesBetween(from, to, (node: Node, pos: number) => {
77
- if (pos < from && nodeType && nodeType === node.type) {
78
- trimmedFrom = Math.max(pos, from)
79
- trimmedTo = Math.min(pos + node.nodeSize, to)
80
- lastPos = pos
81
- lastNode = node
82
- }
89
+ canUpdate = true
83
90
 
84
- if (pos >= from && pos <= to) {
85
- if (nodeType && nodeType === node.type) {
91
+ if (dispatch) {
86
92
  tr.setNodeMarkup(pos, undefined, {
87
93
  ...node.attrs,
88
94
  ...attributes,
89
95
  })
90
96
  }
97
+ }
98
+
99
+ if (markType && node.marks.length) {
100
+ node.marks.forEach((mark: Mark) => {
101
+ if (markType === mark.type) {
102
+ canUpdate = true
91
103
 
92
- if (markType && node.marks.length) {
93
- node.marks.forEach((mark: Mark) => {
94
- if (markType === mark.type) {
104
+ if (dispatch) {
95
105
  const trimmedFrom2 = Math.max(pos, from)
96
106
  const trimmedTo2 = Math.min(pos + node.nodeSize, to)
97
107
 
@@ -104,37 +114,37 @@ export const updateAttributes: RawCommands['updateAttributes'] =
104
114
  }),
105
115
  )
106
116
  }
107
- })
108
- }
117
+ }
118
+ })
109
119
  }
120
+ }
121
+ })
122
+ }
123
+
124
+ if (lastNode) {
125
+ if (lastPos !== undefined && dispatch) {
126
+ tr.setNodeMarkup(lastPos, undefined, {
127
+ ...lastNode.attrs,
128
+ ...attributes,
110
129
  })
111
130
  }
112
131
 
113
- if (lastNode) {
114
- if (lastPos !== undefined) {
115
- tr.setNodeMarkup(lastPos, undefined, {
116
- ...lastNode.attrs,
117
- ...attributes,
118
- })
119
- }
120
-
121
- if (markType && lastNode.marks.length) {
122
- lastNode.marks.forEach((mark: Mark) => {
123
- if (markType === mark.type) {
124
- tr.addMark(
125
- trimmedFrom,
126
- trimmedTo,
127
- markType.create({
128
- ...mark.attrs,
129
- ...attributes,
130
- }),
131
- )
132
- }
133
- })
134
- }
132
+ if (markType && lastNode.marks.length) {
133
+ lastNode.marks.forEach((mark: Mark) => {
134
+ if (markType === mark.type && dispatch) {
135
+ tr.addMark(
136
+ trimmedFrom,
137
+ trimmedTo,
138
+ markType.create({
139
+ ...mark.attrs,
140
+ ...attributes,
141
+ }),
142
+ )
143
+ }
144
+ })
135
145
  }
136
- })
137
- }
146
+ }
147
+ })
138
148
 
139
- return true
149
+ return canUpdate
140
150
  }
@@ -7,3 +7,4 @@ export { FocusEvents, focusEventsPluginKey } from './focusEvents.js'
7
7
  export { Keymap } from './keymap.js'
8
8
  export { Paste } from './paste.js'
9
9
  export { Tabindex } from './tabindex.js'
10
+ export { TextDirection } from './textDirection.js'
@@ -0,0 +1,86 @@
1
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
2
+
3
+ import { Extension } from '../Extension.js'
4
+ import { splitExtensions } from '../helpers/splitExtensions.js'
5
+
6
+ export interface TextDirectionOptions {
7
+ direction: 'ltr' | 'rtl' | 'auto' | undefined
8
+ }
9
+
10
+ /**
11
+ * The TextDirection extension adds support for setting text direction (LTR/RTL/auto)
12
+ * on all nodes in the editor.
13
+ *
14
+ * This extension adds a global `dir` attribute to all node types, which can be used
15
+ * to control bidirectional text rendering. The direction can be set globally via
16
+ * editor options or per-node using commands.
17
+ */
18
+ export const TextDirection = Extension.create<TextDirectionOptions>({
19
+ name: 'textDirection',
20
+
21
+ addOptions() {
22
+ return {
23
+ direction: undefined,
24
+ }
25
+ },
26
+
27
+ addGlobalAttributes() {
28
+ // Only add the dir attribute to nodes if text direction is configured
29
+ // This prevents null/undefined values from appearing in JSON exports
30
+ if (!this.options.direction) {
31
+ return []
32
+ }
33
+
34
+ const { nodeExtensions } = splitExtensions(this.extensions)
35
+
36
+ return [
37
+ {
38
+ types: nodeExtensions.filter(extension => extension.name !== 'text').map(extension => extension.name),
39
+ attributes: {
40
+ dir: {
41
+ default: this.options.direction,
42
+ parseHTML: element => {
43
+ const dir = element.getAttribute('dir')
44
+
45
+ if (dir && (dir === 'ltr' || dir === 'rtl' || dir === 'auto')) {
46
+ return dir
47
+ }
48
+
49
+ return this.options.direction
50
+ },
51
+ renderHTML: attributes => {
52
+ if (!attributes.dir) {
53
+ return {}
54
+ }
55
+
56
+ return {
57
+ dir: attributes.dir,
58
+ }
59
+ },
60
+ },
61
+ },
62
+ },
63
+ ]
64
+ },
65
+
66
+ addProseMirrorPlugins() {
67
+ return [
68
+ new Plugin({
69
+ key: new PluginKey('textDirection'),
70
+ props: {
71
+ attributes: (): { [name: string]: string } => {
72
+ const direction = this.options.direction
73
+
74
+ if (!direction) {
75
+ return {}
76
+ }
77
+
78
+ return {
79
+ dir: direction,
80
+ }
81
+ },
82
+ },
83
+ }),
84
+ ]
85
+ },
86
+ })
@@ -34,8 +34,8 @@ function buildAttributeSpec(
34
34
  ): [string, Record<string, any>] {
35
35
  const spec: Record<string, any> = {}
36
36
 
37
- // Only include 'default' if the attribute is not required and default is defined
38
- if (!extensionAttribute?.attribute?.isRequired && extensionAttribute?.attribute?.default !== undefined) {
37
+ // Only include 'default' if the attribute is not required and default is set on the attribute
38
+ if (!extensionAttribute?.attribute?.isRequired && 'default' in (extensionAttribute?.attribute || {})) {
39
39
  spec.default = extensionAttribute.attribute.default
40
40
  }
41
41
 
package/src/types.ts CHANGED
@@ -292,6 +292,14 @@ export interface EditorOptions {
292
292
  * Whether the editor is editable
293
293
  */
294
294
  editable: boolean
295
+ /**
296
+ * The default text direction for all content in the editor.
297
+ * When set to 'ltr' or 'rtl', all nodes will have the corresponding dir attribute.
298
+ * When set to 'auto', the dir attribute will be set based on content detection.
299
+ * When undefined, no dir attribute will be added.
300
+ * @default undefined
301
+ */
302
+ textDirection?: 'ltr' | 'rtl' | 'auto'
295
303
  /**
296
304
  * The editor's props
297
305
  */
@@ -357,7 +365,8 @@ export interface EditorOptions {
357
365
  | 'tabindex'
358
366
  | 'drop'
359
367
  | 'paste'
360
- | 'delete',
368
+ | 'delete'
369
+ | 'textDirection',
361
370
  false
362
371
  >
363
372
  >
@@ -442,17 +451,71 @@ export interface EditorOptions {
442
451
  export type HTMLContent = string
443
452
 
444
453
  /**
445
- * Loosely describes a JSON representation of a Prosemirror document or node
454
+ * A Tiptap JSON node or document. Tiptap JSON is the standard format for
455
+ * storing and manipulating Tiptap content. It is equivalent to the JSON
456
+ * representation of a Prosemirror node.
457
+ *
458
+ * Tiptap JSON documents are trees of nodes. The root node is usually of type
459
+ * `doc`. Nodes can have other nodes as children. Nodes can also have marks and
460
+ * attributes. Text nodes (nodes with type `text`) have a `text` property and no
461
+ * children.
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * const content: JSONContent = {
466
+ * type: 'doc',
467
+ * content: [
468
+ * {
469
+ * type: 'paragraph',
470
+ * content: [
471
+ * {
472
+ * type: 'text',
473
+ * text: 'Hello ',
474
+ * },
475
+ * {
476
+ * type: 'text',
477
+ * text: 'world',
478
+ * marks: [{ type: 'bold' }],
479
+ * },
480
+ * ],
481
+ * },
482
+ * ],
483
+ * }
484
+ * ```
446
485
  */
447
486
  export type JSONContent = {
487
+ /**
488
+ * The type of the node
489
+ */
448
490
  type?: string
491
+ /**
492
+ * The attributes of the node. Attributes can have any JSON-serializable value.
493
+ */
449
494
  attrs?: Record<string, any> | undefined
495
+ /**
496
+ * The children of the node. A node can have other nodes as children.
497
+ */
450
498
  content?: JSONContent[]
499
+ /**
500
+ * A list of marks of the node. Inline nodes can have marks.
501
+ */
451
502
  marks?: {
503
+ /**
504
+ * The type of the mark
505
+ */
452
506
  type: string
507
+ /**
508
+ * The attributes of the mark. Attributes can have any JSON-serializable value.
509
+ */
453
510
  attrs?: Record<string, any>
454
511
  [key: string]: any
455
512
  }[]
513
+ /**
514
+ * The text content of the node. This property is only present on text nodes
515
+ * (i.e. nodes with `type: 'text'`).
516
+ *
517
+ * Text nodes cannot have children, but they can have marks.
518
+ */
456
519
  text?: string
457
520
  [key: string]: any
458
521
  }
@@ -103,6 +103,7 @@ export function parseIndentedBlocks(
103
103
  break
104
104
  } else if (currentLine.trim() === '') {
105
105
  i += 1
106
+ totalRaw = `${totalRaw}${currentLine}\n`
106
107
  continue
107
108
  } else {
108
109
  return undefined
@@ -188,6 +189,6 @@ export function parseIndentedBlocks(
188
189
 
189
190
  return {
190
191
  items,
191
- raw: totalRaw.trim(),
192
+ raw: totalRaw,
192
193
  }
193
194
  }