@tiptap/extensions 3.0.0-next.3 → 3.0.0-next.5

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.
Files changed (39) hide show
  1. package/LICENSE.md +1 -1
  2. package/dist/character-count/index.cjs +129 -0
  3. package/dist/character-count/index.cjs.map +1 -0
  4. package/dist/character-count/index.d.cts +62 -0
  5. package/dist/character-count/index.d.ts +62 -0
  6. package/dist/character-count/index.js +102 -0
  7. package/dist/character-count/index.js.map +1 -0
  8. package/dist/drop-cursor/index.cjs +47 -0
  9. package/dist/drop-cursor/index.cjs.map +1 -0
  10. package/dist/drop-cursor/index.d.cts +31 -0
  11. package/dist/drop-cursor/index.d.ts +31 -0
  12. package/dist/drop-cursor/index.js +20 -0
  13. package/dist/drop-cursor/index.js.map +1 -0
  14. package/dist/history/index.cjs +66 -0
  15. package/dist/history/index.cjs.map +1 -0
  16. package/dist/history/index.d.cts +44 -0
  17. package/dist/history/index.d.ts +44 -0
  18. package/dist/history/index.js +39 -0
  19. package/dist/history/index.js.map +1 -0
  20. package/dist/index.cjs +222 -23
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +157 -2
  23. package/dist/index.d.ts +157 -2
  24. package/dist/index.js +218 -22
  25. package/dist/index.js.map +1 -1
  26. package/dist/placeholder/index.cjs +88 -0
  27. package/dist/placeholder/index.cjs.map +1 -0
  28. package/dist/placeholder/index.d.cts +59 -0
  29. package/dist/placeholder/index.d.ts +59 -0
  30. package/dist/placeholder/index.js +61 -0
  31. package/dist/placeholder/index.js.map +1 -0
  32. package/package.json +27 -6
  33. package/src/character-count/character-count.ts +195 -0
  34. package/src/character-count/index.ts +1 -0
  35. package/src/history/history.ts +86 -0
  36. package/src/history/index.ts +1 -0
  37. package/src/index.ts +3 -0
  38. package/src/placeholder/index.ts +1 -0
  39. package/src/placeholder/placeholder.ts +128 -0
@@ -0,0 +1,195 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { Node as ProseMirrorNode } from '@tiptap/pm/model'
3
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
4
+
5
+ export interface CharacterCountOptions {
6
+ /**
7
+ * The maximum number of characters that should be allowed. Defaults to `0`.
8
+ * @default null
9
+ * @example 180
10
+ */
11
+ limit: number | null | undefined
12
+ /**
13
+ * The mode by which the size is calculated. If set to `textSize`, the textContent of the document is used.
14
+ * If set to `nodeSize`, the nodeSize of the document is used.
15
+ * @default 'textSize'
16
+ * @example 'textSize'
17
+ */
18
+ mode: 'textSize' | 'nodeSize'
19
+ /**
20
+ * The text counter function to use. Defaults to a simple character count.
21
+ * @default (text) => text.length
22
+ * @example (text) => [...new Intl.Segmenter().segment(text)].length
23
+ */
24
+ textCounter: (text: string) => number
25
+ /**
26
+ * The word counter function to use. Defaults to a simple word count.
27
+ * @default (text) => text.split(' ').filter(word => word !== '').length
28
+ * @example (text) => text.split(/\s+/).filter(word => word !== '').length
29
+ */
30
+ wordCounter: (text: string) => number
31
+ }
32
+
33
+ export interface CharacterCountStorage {
34
+ /**
35
+ * Get the number of characters for the current document.
36
+ * @param options The options for the character count. (optional)
37
+ * @param options.node The node to get the characters from. Defaults to the current document.
38
+ * @param options.mode The mode by which the size is calculated. If set to `textSize`, the textContent of the document is used.
39
+ */
40
+ characters: (options?: { node?: ProseMirrorNode; mode?: 'textSize' | 'nodeSize' }) => number
41
+
42
+ /**
43
+ * Get the number of words for the current document.
44
+ * @param options The options for the character count. (optional)
45
+ * @param options.node The node to get the words from. Defaults to the current document.
46
+ */
47
+ words: (options?: { node?: ProseMirrorNode }) => number
48
+ }
49
+
50
+ declare module '@tiptap/core' {
51
+ interface Storage {
52
+ characterCount: CharacterCountStorage
53
+ }
54
+ }
55
+
56
+ /**
57
+ * This extension allows you to count the characters and words of your document.
58
+ * @see https://tiptap.dev/api/extensions/character-count
59
+ */
60
+ export const CharacterCount = Extension.create<CharacterCountOptions, CharacterCountStorage>({
61
+ name: 'characterCount',
62
+
63
+ addOptions() {
64
+ return {
65
+ limit: null,
66
+ mode: 'textSize',
67
+ textCounter: text => text.length,
68
+ wordCounter: text => text.split(' ').filter(word => word !== '').length,
69
+ }
70
+ },
71
+
72
+ addStorage() {
73
+ return {
74
+ characters: () => 0,
75
+ words: () => 0,
76
+ }
77
+ },
78
+
79
+ onBeforeCreate() {
80
+ this.storage.characters = options => {
81
+ const node = options?.node || this.editor.state.doc
82
+ const mode = options?.mode || this.options.mode
83
+
84
+ if (mode === 'textSize') {
85
+ const text = node.textBetween(0, node.content.size, undefined, ' ')
86
+
87
+ return this.options.textCounter(text)
88
+ }
89
+
90
+ return node.nodeSize
91
+ }
92
+
93
+ this.storage.words = options => {
94
+ const node = options?.node || this.editor.state.doc
95
+ const text = node.textBetween(0, node.content.size, ' ', ' ')
96
+
97
+ return this.options.wordCounter(text)
98
+ }
99
+ },
100
+
101
+ addProseMirrorPlugins() {
102
+ let initialEvaluationDone = false
103
+
104
+ return [
105
+ new Plugin({
106
+ key: new PluginKey('characterCount'),
107
+ appendTransaction: (transactions, oldState, newState) => {
108
+ if (initialEvaluationDone) {
109
+ return
110
+ }
111
+
112
+ const limit = this.options.limit
113
+
114
+ if (limit === null || limit === undefined || limit === 0) {
115
+ initialEvaluationDone = true
116
+ return
117
+ }
118
+
119
+ const initialContentSize = this.storage.characters({ node: newState.doc })
120
+
121
+ if (initialContentSize > limit) {
122
+ const over = initialContentSize - limit
123
+ const from = 0
124
+ const to = over
125
+
126
+ console.warn(
127
+ `[CharacterCount] Initial content exceeded limit of ${limit} characters. Content was automatically trimmed.`,
128
+ )
129
+ const tr = newState.tr.deleteRange(from, to)
130
+
131
+ initialEvaluationDone = true
132
+ return tr
133
+ }
134
+
135
+ initialEvaluationDone = true
136
+ },
137
+ filterTransaction: (transaction, state) => {
138
+ const limit = this.options.limit
139
+
140
+ // Nothing has changed or no limit is defined. Ignore it.
141
+ if (!transaction.docChanged || limit === 0 || limit === null || limit === undefined) {
142
+ return true
143
+ }
144
+
145
+ const oldSize = this.storage.characters({ node: state.doc })
146
+ const newSize = this.storage.characters({ node: transaction.doc })
147
+
148
+ // Everything is in the limit. Good.
149
+ if (newSize <= limit) {
150
+ return true
151
+ }
152
+
153
+ // The limit has already been exceeded but will be reduced.
154
+ if (oldSize > limit && newSize > limit && newSize <= oldSize) {
155
+ return true
156
+ }
157
+
158
+ // The limit has already been exceeded and will be increased further.
159
+ if (oldSize > limit && newSize > limit && newSize > oldSize) {
160
+ return false
161
+ }
162
+
163
+ const isPaste = transaction.getMeta('paste')
164
+
165
+ // Block all exceeding transactions that were not pasted.
166
+ if (!isPaste) {
167
+ return false
168
+ }
169
+
170
+ // For pasted content, we try to remove the exceeding content.
171
+ const pos = transaction.selection.$head.pos
172
+ const over = newSize - limit
173
+ const from = pos - over
174
+ const to = pos
175
+
176
+ // It’s probably a bad idea to mutate transactions within `filterTransaction`
177
+ // but for now this is working fine.
178
+ transaction.deleteRange(from, to)
179
+
180
+ // In some situations, the limit will continue to be exceeded after trimming.
181
+ // This happens e.g. when truncating within a complex node (e.g. table)
182
+ // and ProseMirror has to close this node again.
183
+ // If this is the case, we prevent the transaction completely.
184
+ const updatedSize = this.storage.characters({ node: transaction.doc })
185
+
186
+ if (updatedSize > limit) {
187
+ return false
188
+ }
189
+
190
+ return true
191
+ },
192
+ }),
193
+ ]
194
+ },
195
+ })
@@ -0,0 +1 @@
1
+ export * from './character-count.js'
@@ -0,0 +1,86 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { history, redo, undo } from '@tiptap/pm/history'
3
+
4
+ export interface HistoryOptions {
5
+ /**
6
+ * The amount of history events that are collected before the oldest events are discarded.
7
+ * @default 100
8
+ * @example 50
9
+ */
10
+ depth: number
11
+
12
+ /**
13
+ * The delay (in milliseconds) between changes after which a new group should be started.
14
+ * @default 500
15
+ * @example 1000
16
+ */
17
+ newGroupDelay: number
18
+ }
19
+
20
+ declare module '@tiptap/core' {
21
+ interface Commands<ReturnType> {
22
+ history: {
23
+ /**
24
+ * Undo recent changes
25
+ * @example editor.commands.undo()
26
+ */
27
+ undo: () => ReturnType
28
+ /**
29
+ * Reapply reverted changes
30
+ * @example editor.commands.redo()
31
+ */
32
+ redo: () => ReturnType
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * This extension allows you to undo and redo recent changes.
39
+ * @see https://www.tiptap.dev/api/extensions/history
40
+ *
41
+ * **Important**: If the `@tiptap/extension-collaboration` package is used, make sure to remove
42
+ * the `history` extension, as it is not compatible with the `collaboration` extension.
43
+ *
44
+ * `@tiptap/extension-collaboration` uses its own history implementation.
45
+ */
46
+ export const History = Extension.create<HistoryOptions>({
47
+ name: 'history',
48
+
49
+ addOptions() {
50
+ return {
51
+ depth: 100,
52
+ newGroupDelay: 500,
53
+ }
54
+ },
55
+
56
+ addCommands() {
57
+ return {
58
+ undo:
59
+ () =>
60
+ ({ state, dispatch }) => {
61
+ return undo(state, dispatch)
62
+ },
63
+ redo:
64
+ () =>
65
+ ({ state, dispatch }) => {
66
+ return redo(state, dispatch)
67
+ },
68
+ }
69
+ },
70
+
71
+ addProseMirrorPlugins() {
72
+ return [history(this.options)]
73
+ },
74
+
75
+ addKeyboardShortcuts() {
76
+ return {
77
+ 'Mod-z': () => this.editor.commands.undo(),
78
+ 'Shift-Mod-z': () => this.editor.commands.redo(),
79
+ 'Mod-y': () => this.editor.commands.redo(),
80
+
81
+ // Russian keyboard layouts
82
+ 'Mod-я': () => this.editor.commands.undo(),
83
+ 'Shift-Mod-я': () => this.editor.commands.redo(),
84
+ }
85
+ },
86
+ })
@@ -0,0 +1 @@
1
+ export * from './history.js'
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
+ export * from './character-count/index.js'
1
2
  export * from './drop-cursor/index.js'
2
3
  export * from './focus/index.js'
3
4
  export * from './gap-cursor/index.js'
5
+ export * from './history/index.js'
6
+ export * from './placeholder/index.js'
4
7
  export * from './selection/index.js'
5
8
  export * from './trailing-node/index.js'
@@ -0,0 +1 @@
1
+ export * from './placeholder.js'
@@ -0,0 +1,128 @@
1
+ import { Editor, Extension, isNodeEmpty } from '@tiptap/core'
2
+ import { Node as ProsemirrorNode } from '@tiptap/pm/model'
3
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
4
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
5
+
6
+ export interface PlaceholderOptions {
7
+ /**
8
+ * **The class name for the empty editor**
9
+ * @default 'is-editor-empty'
10
+ */
11
+ emptyEditorClass: string
12
+
13
+ /**
14
+ * **The class name for empty nodes**
15
+ * @default 'is-empty'
16
+ */
17
+ emptyNodeClass: string
18
+
19
+ /**
20
+ * **The placeholder content**
21
+ *
22
+ * You can use a function to return a dynamic placeholder or a string.
23
+ * @default 'Write something …'
24
+ */
25
+ placeholder:
26
+ | ((PlaceholderProps: { editor: Editor; node: ProsemirrorNode; pos: number; hasAnchor: boolean }) => string)
27
+ | string
28
+
29
+ /**
30
+ * **Checks if the placeholder should be only shown when the editor is editable.**
31
+ *
32
+ * If true, the placeholder will only be shown when the editor is editable.
33
+ * If false, the placeholder will always be shown.
34
+ * @default true
35
+ */
36
+ showOnlyWhenEditable: boolean
37
+
38
+ /**
39
+ * **Checks if the placeholder should be only shown when the current node is empty.**
40
+ *
41
+ * If true, the placeholder will only be shown when the current node is empty.
42
+ * If false, the placeholder will be shown when any node is empty.
43
+ * @default true
44
+ */
45
+ showOnlyCurrent: boolean
46
+
47
+ /**
48
+ * **Controls if the placeholder should be shown for all descendents.**
49
+ *
50
+ * If true, the placeholder will be shown for all descendents.
51
+ * If false, the placeholder will only be shown for the current node.
52
+ * @default false
53
+ */
54
+ includeChildren: boolean
55
+ }
56
+
57
+ /**
58
+ * This extension allows you to add a placeholder to your editor.
59
+ * A placeholder is a text that appears when the editor or a node is empty.
60
+ * @see https://www.tiptap.dev/api/extensions/placeholder
61
+ */
62
+ export const Placeholder = Extension.create<PlaceholderOptions>({
63
+ name: 'placeholder',
64
+
65
+ addOptions() {
66
+ return {
67
+ emptyEditorClass: 'is-editor-empty',
68
+ emptyNodeClass: 'is-empty',
69
+ placeholder: 'Write something …',
70
+ showOnlyWhenEditable: true,
71
+ showOnlyCurrent: true,
72
+ includeChildren: false,
73
+ }
74
+ },
75
+
76
+ addProseMirrorPlugins() {
77
+ return [
78
+ new Plugin({
79
+ key: new PluginKey('placeholder'),
80
+ props: {
81
+ decorations: ({ doc, selection }) => {
82
+ const active = this.editor.isEditable || !this.options.showOnlyWhenEditable
83
+ const { anchor } = selection
84
+ const decorations: Decoration[] = []
85
+
86
+ if (!active) {
87
+ return null
88
+ }
89
+
90
+ const isEmptyDoc = this.editor.isEmpty
91
+
92
+ doc.descendants((node, pos) => {
93
+ const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
94
+ const isEmpty = !node.isLeaf && isNodeEmpty(node)
95
+
96
+ if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
97
+ const classes = [this.options.emptyNodeClass]
98
+
99
+ if (isEmptyDoc) {
100
+ classes.push(this.options.emptyEditorClass)
101
+ }
102
+
103
+ const decoration = Decoration.node(pos, pos + node.nodeSize, {
104
+ class: classes.join(' '),
105
+ 'data-placeholder':
106
+ typeof this.options.placeholder === 'function'
107
+ ? this.options.placeholder({
108
+ editor: this.editor,
109
+ node,
110
+ pos,
111
+ hasAnchor,
112
+ })
113
+ : this.options.placeholder,
114
+ })
115
+
116
+ decorations.push(decoration)
117
+ }
118
+
119
+ return this.options.includeChildren
120
+ })
121
+
122
+ return DecorationSet.create(doc, decorations)
123
+ },
124
+ },
125
+ }),
126
+ ]
127
+ },
128
+ })