@tiptap/extension-details 2.22.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/src/details.ts ADDED
@@ -0,0 +1,451 @@
1
+ import {
2
+ defaultBlockAt,
3
+ findChildren,
4
+ findParentNode,
5
+ isActive,
6
+ mergeAttributes,
7
+ Node,
8
+ } from '@tiptap/core'
9
+ import {
10
+ Plugin,
11
+ PluginKey,
12
+ Selection,
13
+ TextSelection,
14
+ } from '@tiptap/pm/state'
15
+ import type { ViewMutationRecord } from '@tiptap/pm/view'
16
+
17
+ import { findClosestVisibleNode } from './helpers/findClosestVisibleNode.js'
18
+ import { isNodeVisible } from './helpers/isNodeVisible.js'
19
+ import { setGapCursor } from './helpers/setGapCursor.js'
20
+
21
+ export interface DetailsOptions {
22
+ /**
23
+ * Specify if the open status should be saved in the document. Defaults to `false`.
24
+ */
25
+ persist: boolean,
26
+ /**
27
+ * Specifies a CSS class that is set when toggling the content. Defaults to `is-open`.
28
+ */
29
+ openClassName: string,
30
+ /**
31
+ * Custom HTML attributes that should be added to the rendered HTML tag.
32
+ */
33
+ HTMLAttributes: {
34
+ [key: string]: any
35
+ },
36
+ }
37
+
38
+ declare module '@tiptap/core' {
39
+ interface Commands<ReturnType> {
40
+ details: {
41
+ /**
42
+ * Set a details node
43
+ */
44
+ setDetails: () => ReturnType,
45
+ /**
46
+ * Unset a details node
47
+ */
48
+ unsetDetails: () => ReturnType,
49
+ }
50
+ }
51
+ }
52
+
53
+ export const Details = Node.create<DetailsOptions>({
54
+ name: 'details',
55
+
56
+ content: 'detailsSummary detailsContent',
57
+
58
+ group: 'block',
59
+
60
+ defining: true,
61
+
62
+ isolating: true,
63
+
64
+ allowGapCursor: false,
65
+
66
+ addOptions() {
67
+ return {
68
+ persist: false,
69
+ openClassName: 'is-open',
70
+ HTMLAttributes: {},
71
+ }
72
+ },
73
+
74
+ addAttributes() {
75
+ if (!this.options.persist) {
76
+ return []
77
+ }
78
+
79
+ return {
80
+ open: {
81
+ default: false,
82
+ parseHTML: element => element.hasAttribute('open'),
83
+ renderHTML: ({ open }) => {
84
+ if (!open) {
85
+ return {}
86
+ }
87
+
88
+ return { open: '' }
89
+ },
90
+ },
91
+ }
92
+ },
93
+
94
+ parseHTML() {
95
+ return [
96
+ {
97
+ tag: 'details',
98
+ },
99
+ ]
100
+ },
101
+
102
+ renderHTML({ HTMLAttributes }) {
103
+ return [
104
+ 'details',
105
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
106
+ 0,
107
+ ]
108
+ },
109
+
110
+ addNodeView() {
111
+ return ({
112
+ editor,
113
+ getPos,
114
+ node,
115
+ HTMLAttributes,
116
+ }) => {
117
+ const dom = document.createElement('div')
118
+ const attributes = mergeAttributes(
119
+ this.options.HTMLAttributes,
120
+ HTMLAttributes,
121
+ {
122
+ 'data-type': this.name,
123
+ },
124
+ )
125
+
126
+ Object.entries(attributes).forEach(([key, value]) => dom.setAttribute(key, value))
127
+
128
+ const toggle = document.createElement('button')
129
+
130
+ toggle.type = 'button'
131
+
132
+ dom.append(toggle)
133
+
134
+ const content = document.createElement('div')
135
+
136
+ dom.append(content)
137
+
138
+ const toggleDetailsContent = (setToValue?: boolean) => {
139
+ if (setToValue !== undefined) {
140
+ if (setToValue) {
141
+ if (dom.classList.contains(this.options.openClassName)) {
142
+ return
143
+ }
144
+ dom.classList.add(this.options.openClassName)
145
+ } else {
146
+ if (!dom.classList.contains(this.options.openClassName)) {
147
+ return
148
+ }
149
+ dom.classList.remove(this.options.openClassName)
150
+ }
151
+ } else {
152
+ dom.classList.toggle(this.options.openClassName)
153
+ }
154
+
155
+ const event = new Event('toggleDetailsContent')
156
+ const detailsContent = content.querySelector(':scope > div[data-type="detailsContent"]')
157
+
158
+ detailsContent?.dispatchEvent(event)
159
+ }
160
+
161
+ if (node.attrs.open) {
162
+ setTimeout(() => toggleDetailsContent())
163
+ }
164
+
165
+ toggle.addEventListener('click', () => {
166
+ toggleDetailsContent()
167
+
168
+ if (!this.options.persist) {
169
+ editor.commands
170
+ .focus(undefined, { scrollIntoView: false })
171
+
172
+ return
173
+ }
174
+
175
+ if (editor.isEditable && typeof getPos === 'function') {
176
+ const { from, to } = editor.state.selection
177
+
178
+ editor
179
+ .chain()
180
+ .command(({ tr }) => {
181
+ const pos = getPos()
182
+ const currentNode = tr.doc.nodeAt(pos)
183
+
184
+ if (currentNode?.type !== this.type) {
185
+ return false
186
+ }
187
+
188
+ tr.setNodeMarkup(pos, undefined, {
189
+ open: !currentNode.attrs.open,
190
+ })
191
+
192
+ return true
193
+ })
194
+ .setTextSelection({
195
+ from,
196
+ to,
197
+ })
198
+ .focus(undefined, { scrollIntoView: false })
199
+ .run()
200
+ }
201
+ })
202
+
203
+ return {
204
+ dom,
205
+ contentDOM: content,
206
+ ignoreMutation(mutation: ViewMutationRecord) {
207
+ if (mutation.type === 'selection') {
208
+ return false
209
+ }
210
+
211
+ return !dom.contains(mutation.target) || dom === mutation.target
212
+ },
213
+ update: updatedNode => {
214
+ if (updatedNode.type !== this.type) {
215
+ return false
216
+ }
217
+
218
+ // Only update the open state if set
219
+ if (updatedNode.attrs.open !== undefined) {
220
+ toggleDetailsContent(updatedNode.attrs.open)
221
+ }
222
+
223
+ return true
224
+ },
225
+ }
226
+ }
227
+ },
228
+
229
+ addCommands() {
230
+ return {
231
+ setDetails: () => ({ state, chain }) => {
232
+ const { schema, selection } = state
233
+ const { $from, $to } = selection
234
+ const range = $from.blockRange($to)
235
+
236
+ if (!range) {
237
+ return false
238
+ }
239
+
240
+ const slice = state.doc.slice(range.start, range.end)
241
+ const match = schema.nodes.detailsContent.contentMatch.matchFragment(slice.content)
242
+
243
+ if (!match) {
244
+ return false
245
+ }
246
+
247
+ const content = slice.toJSON()?.content || []
248
+
249
+ return chain()
250
+ .insertContentAt({ from: range.start, to: range.end }, {
251
+ type: this.name,
252
+ content: [
253
+ {
254
+ type: 'detailsSummary',
255
+ },
256
+ {
257
+ type: 'detailsContent',
258
+ content,
259
+ },
260
+ ],
261
+ })
262
+ .setTextSelection(range.start + 2)
263
+ .run()
264
+ },
265
+
266
+ unsetDetails: () => ({ state, chain }) => {
267
+ const { selection, schema } = state
268
+ const details = findParentNode(node => node.type === this.type)(selection)
269
+
270
+ if (!details) {
271
+ return false
272
+ }
273
+
274
+ const detailsSummaries = findChildren(details.node, node => node.type === schema.nodes.detailsSummary)
275
+ const detailsContents = findChildren(details.node, node => node.type === schema.nodes.detailsContent)
276
+
277
+ if (!detailsSummaries.length || !detailsContents.length) {
278
+ return false
279
+ }
280
+
281
+ const detailsSummary = detailsSummaries[0]
282
+ const detailsContent = detailsContents[0]
283
+ const from = details.pos
284
+ const $from = state.doc.resolve(from)
285
+ const to = from + details.node.nodeSize
286
+ const range = { from, to }
287
+ const content = detailsContent.node.content.toJSON() as [] || []
288
+ const defaultTypeForSummary = $from.parent.type.contentMatch.defaultType
289
+
290
+ // TODO: this may break for some custom schemas
291
+ const summaryContent = defaultTypeForSummary?.create(null, detailsSummary.node.content).toJSON()
292
+ const mergedContent = [
293
+ summaryContent,
294
+ ...content,
295
+ ]
296
+
297
+ return chain()
298
+ .insertContentAt(range, mergedContent)
299
+ .setTextSelection(from + 1)
300
+ .run()
301
+ },
302
+ }
303
+ },
304
+
305
+ addKeyboardShortcuts() {
306
+ return {
307
+ Backspace: () => {
308
+ const { schema, selection } = this.editor.state
309
+ const { empty, $anchor } = selection
310
+
311
+ if (!empty || $anchor.parent.type !== schema.nodes.detailsSummary) {
312
+ return false
313
+ }
314
+
315
+ // for some reason safari removes the whole text content within a `<summary>`tag on backspace
316
+ // so we have to remove the text manually
317
+ // see: https://discuss.prosemirror.net/t/safari-backspace-bug-with-details-tag/4223
318
+ if ($anchor.parentOffset !== 0) {
319
+ return this.editor.commands.command(({ tr }) => {
320
+ const from = $anchor.pos - 1
321
+ const to = $anchor.pos
322
+
323
+ tr.delete(from, to)
324
+
325
+ return true
326
+ })
327
+ }
328
+
329
+ return this.editor.commands.unsetDetails()
330
+ },
331
+
332
+ // Creates a new node below it if it is closed.
333
+ // Otherwise inside `DetailsContent`.
334
+ Enter: ({ editor }) => {
335
+ const { state, view } = editor
336
+ const { schema, selection } = state
337
+ const { $head } = selection
338
+
339
+ if ($head.parent.type !== schema.nodes.detailsSummary) {
340
+ return false
341
+ }
342
+
343
+ const isVisible = isNodeVisible($head.after() + 1, editor)
344
+ const above = isVisible
345
+ ? state.doc.nodeAt($head.after())
346
+ : $head.node(-2)
347
+
348
+ if (!above) {
349
+ return false
350
+ }
351
+
352
+ const after = isVisible
353
+ ? 0
354
+ : $head.indexAfter(-1)
355
+ const type = defaultBlockAt(above.contentMatchAt(after))
356
+
357
+ if (!type || !above.canReplaceWith(after, after, type)) {
358
+ return false
359
+ }
360
+
361
+ const node = type.createAndFill()
362
+
363
+ if (!node) {
364
+ return false
365
+ }
366
+
367
+ const pos = isVisible
368
+ ? $head.after() + 1
369
+ : $head.after(-1)
370
+ const tr = state.tr.replaceWith(pos, pos, node)
371
+ const $pos = tr.doc.resolve(pos)
372
+ const newSelection = Selection.near($pos, 1)
373
+
374
+ tr.setSelection(newSelection)
375
+ tr.scrollIntoView()
376
+ view.dispatch(tr)
377
+
378
+ return true
379
+ },
380
+
381
+ // The default gapcursor implementation can’t handle hidden content, so we need to fix this.
382
+ ArrowRight: ({ editor }) => {
383
+ return setGapCursor(editor, 'right')
384
+ },
385
+
386
+ // The default gapcursor implementation can’t handle hidden content, so we need to fix this.
387
+ ArrowDown: ({ editor }) => {
388
+ return setGapCursor(editor, 'down')
389
+ },
390
+ }
391
+ },
392
+
393
+ addProseMirrorPlugins() {
394
+ return [
395
+ // This plugin prevents text selections within the hidden content in `DetailsContent`.
396
+ // The cursor is moved to the next visible position.
397
+ new Plugin({
398
+ key: new PluginKey('detailsSelection'),
399
+ appendTransaction: (transactions, oldState, newState) => {
400
+ const { editor, type } = this
401
+ const selectionSet = transactions.some(transaction => transaction.selectionSet)
402
+
403
+ if (
404
+ !selectionSet
405
+ || !oldState.selection.empty
406
+ || !newState.selection.empty
407
+ ) {
408
+ return
409
+ }
410
+
411
+ const detailsIsActive = isActive(newState, type.name)
412
+
413
+ if (!detailsIsActive) {
414
+ return
415
+ }
416
+
417
+ const { $from } = newState.selection
418
+ const isVisible = isNodeVisible($from.pos, editor)
419
+
420
+ if (isVisible) {
421
+ return
422
+ }
423
+
424
+ const details = findClosestVisibleNode($from, node => node.type === type, editor)
425
+
426
+ if (!details) {
427
+ return
428
+ }
429
+
430
+ const detailsSummaries = findChildren(details.node, node => node.type === newState.schema.nodes.detailsSummary)
431
+
432
+ if (!detailsSummaries.length) {
433
+ return
434
+ }
435
+
436
+ const detailsSummary = detailsSummaries[0]
437
+ const selectionDirection = oldState.selection.from < newState.selection.from
438
+ ? 'forward'
439
+ : 'backward'
440
+ const correctedPosition = selectionDirection === 'forward'
441
+ ? details.start + detailsSummary.pos
442
+ : details.pos + detailsSummary.pos + detailsSummary.node.nodeSize
443
+ const selection = TextSelection.create(newState.doc, correctedPosition)
444
+ const transaction = newState.tr.setSelection(selection)
445
+
446
+ return transaction
447
+ },
448
+ }),
449
+ ]
450
+ },
451
+ })
@@ -0,0 +1,26 @@
1
+ import { Editor, Predicate } from '@tiptap/core'
2
+ import { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model'
3
+
4
+ import { isNodeVisible } from './isNodeVisible.js'
5
+
6
+ export const findClosestVisibleNode = ($pos: ResolvedPos, predicate: Predicate, editor: Editor): ({
7
+ pos: number,
8
+ start: number,
9
+ depth: number,
10
+ node: ProseMirrorNode,
11
+ } | undefined) => {
12
+ for (let i = $pos.depth; i > 0; i -= 1) {
13
+ const node = $pos.node(i)
14
+ const match = predicate(node)
15
+ const isVisible = isNodeVisible($pos.start(i), editor)
16
+
17
+ if (match && isVisible) {
18
+ return {
19
+ pos: i > 0 ? $pos.before(i) : 0,
20
+ start: $pos.start(i),
21
+ depth: i,
22
+ node,
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ import { Editor } from '@tiptap/core'
2
+
3
+ export const isNodeVisible = (position: number, editor: Editor): boolean => {
4
+ const node = editor.view.domAtPos(position).node as HTMLElement
5
+ const isOpen = node.offsetParent !== null
6
+
7
+ return isOpen
8
+ }
@@ -0,0 +1,62 @@
1
+ import { Editor, findChildren, findParentNode } from '@tiptap/core'
2
+ import { GapCursor } from '@tiptap/pm/gapcursor'
3
+ import { ResolvedPos } from '@tiptap/pm/model'
4
+ import { Selection } from '@tiptap/pm/state'
5
+
6
+ import { isNodeVisible } from './isNodeVisible.js'
7
+
8
+ export const setGapCursor = (editor: Editor, direction: 'down' | 'right') => {
9
+ const { state, view, extensionManager } = editor
10
+ const { schema, selection } = state
11
+ const { empty, $anchor } = selection
12
+ const hasGapCursorExtension = !!extensionManager.extensions.find(extension => extension.name === 'gapCursor')
13
+
14
+ if (
15
+ !empty
16
+ || $anchor.parent.type !== schema.nodes.detailsSummary
17
+ || !hasGapCursorExtension
18
+ ) {
19
+ return false
20
+ }
21
+
22
+ if (
23
+ direction === 'right'
24
+ && $anchor.parentOffset !== ($anchor.parent.nodeSize - 2)
25
+ ) {
26
+ return false
27
+ }
28
+
29
+ const details = findParentNode(node => node.type === schema.nodes.details)(selection)
30
+
31
+ if (!details) {
32
+ return false
33
+ }
34
+
35
+ const detailsContent = findChildren(details.node, node => node.type === schema.nodes.detailsContent)
36
+
37
+ if (!detailsContent.length) {
38
+ return false
39
+ }
40
+
41
+ const isOpen = isNodeVisible(details.start + detailsContent[0].pos + 1, editor)
42
+
43
+ if (isOpen) {
44
+ return false
45
+ }
46
+
47
+ const $position = state.doc.resolve(details.pos + details.node.nodeSize)
48
+ const $validPosition = GapCursor.findFrom($position, 1, false) as unknown as (null | ResolvedPos)
49
+
50
+ if (!$validPosition) {
51
+ return false
52
+ }
53
+
54
+ const { tr } = state
55
+ const gapCursorSelection = new GapCursor($validPosition) as Selection
56
+
57
+ tr.setSelection(gapCursorSelection)
58
+ tr.scrollIntoView()
59
+ view.dispatch(tr)
60
+
61
+ return true
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { Details } from './details.js'
2
+
3
+ export * from './details.js'
4
+
5
+ export default Details