|
1 | | -import { mergeAttributes, Node } from '@tiptap/core' |
| 1 | +import { type Editor, mergeAttributes, Node } from '@tiptap/core' |
2 | 2 | import { TextSelection } from '@tiptap/pm/state' |
3 | 3 | import { |
4 | 4 | type DOMOutputSpec, |
@@ -86,6 +86,69 @@ function expandElement( |
86 | 86 | elem.addEventListener('transitionend', callback) |
87 | 87 | } |
88 | 88 |
|
| 89 | +function handleSelectionBackspace( |
| 90 | + editor: Editor, |
| 91 | + selection: { $anchor: ResolvedPos; $head: ResolvedPos } |
| 92 | +): boolean { |
| 93 | + const { $anchor, $head } = selection |
| 94 | + |
| 95 | + logger.info('[Heading][Backspace]: Delete selected range') |
| 96 | + |
| 97 | + if ( |
| 98 | + $anchor.parent.type.name === TIPTAP_NODES.CONTENT_HEADING_TYPE && |
| 99 | + $head.parent.type.name === TIPTAP_NODES.CONTENT_HEADING_TYPE |
| 100 | + ) { |
| 101 | + return false |
| 102 | + } |
| 103 | + |
| 104 | + if (isEntireDocumentSelected(editor.state.doc, $anchor.pos, $head.pos)) return false |
| 105 | + |
| 106 | + return deleteSelectedRange(editor) |
| 107 | +} |
| 108 | + |
| 109 | +function mergeFirstBlockIntoHeading( |
| 110 | + editor: Editor, |
| 111 | + blockRange: { start: number; end: number } |
| 112 | +): boolean { |
| 113 | + const { selection, schema, tr } = editor.state |
| 114 | + const { $anchor } = selection |
| 115 | + |
| 116 | + const paragraphNode = getClosestAncestorNodeByTypeName($anchor, TIPTAP_NODES.PARAGRAPH_TYPE) |
| 117 | + if (!paragraphNode) return false |
| 118 | + const paragraphContent = paragraphNode.content.toJSON() |
| 119 | + |
| 120 | + const filteredContent = paragraphContent.filter( |
| 121 | + (node: { type?: string }) => node.type !== TIPTAP_NODES.HARD_BREAK_TYPE |
| 122 | + ) |
| 123 | + |
| 124 | + const newNode = schema.nodeFromJSON({ |
| 125 | + type: TIPTAP_NODES.PARAGRAPH_TYPE, |
| 126 | + content: filteredContent |
| 127 | + }) |
| 128 | + |
| 129 | + tr.delete(blockRange.start, blockRange.end) |
| 130 | + // contentHeading only accepts inline content, so insert the fragment, not the block node |
| 131 | + tr.insert(blockRange.start - 2, newNode.content) |
| 132 | + tr.setSelection(new TextSelection(tr.doc.resolve(blockRange.start - 2))) |
| 133 | + |
| 134 | + editor.view.dispatch(tr) |
| 135 | + return true |
| 136 | +} |
| 137 | + |
| 138 | +function deleteEmptyFirstBlock( |
| 139 | + editor: Editor, |
| 140 | + blockRange: { start: number; end: number } |
| 141 | +): boolean { |
| 142 | + const { tr } = editor.state |
| 143 | + |
| 144 | + tr.delete(blockRange.start, blockRange.end) |
| 145 | + tr.setSelection(new TextSelection(tr.doc.resolve(blockRange.start - 2))) |
| 146 | + tr.scrollIntoView() |
| 147 | + |
| 148 | + editor.view.dispatch(tr) |
| 149 | + return true |
| 150 | +} |
| 151 | + |
89 | 152 | /** |
90 | 153 | * ContentWrapper — the body container inside each heading section. |
91 | 154 | * Holds block content (paragraphs, lists, etc.) followed by child headings. |
@@ -213,96 +276,41 @@ const ContentWrapper = Node.create({ |
213 | 276 | }, |
214 | 277 | addKeyboardShortcuts() { |
215 | 278 | return { |
216 | | - //TODO: refactor needed |
217 | 279 | Backspace: (): boolean => { |
218 | 280 | const { editor } = this |
219 | | - const { schema, selection, tr } = editor.state |
| 281 | + const { schema, selection } = editor.state |
220 | 282 | const { $anchor, $from, $head, $to } = selection |
221 | | - const blockRange = $from.blockRange($to) |
222 | 283 |
|
223 | | - // if we have selection, node range || multiple lines block |
224 | 284 | if ($anchor.pos !== $head.pos) { |
225 | | - logger.info('[Heading][Backspace]: Delete selected range') |
226 | | - // Check if both $anchor and $head parents are content headings |
227 | | - if ( |
228 | | - $anchor.parent.type.name === TIPTAP_NODES.CONTENT_HEADING_TYPE && |
229 | | - $head.parent.type.name === TIPTAP_NODES.CONTENT_HEADING_TYPE |
230 | | - ) { |
231 | | - return false |
232 | | - } |
233 | | - |
234 | | - if (isEntireDocumentSelected(editor.state.doc, $anchor.pos, $head.pos)) return false |
235 | | - |
236 | | - return deleteSelectedRange(editor) |
| 285 | + return handleSelectionBackspace(editor, selection) |
237 | 286 | } |
238 | 287 |
|
239 | | - // If backspace hit not at the start of the node, do nothing |
240 | 288 | // TODO: Revise this condition, maybe it's better to use "textOffset" |
241 | 289 | if ($anchor.parentOffset !== 0) return false |
242 | 290 |
|
243 | | - const contentWrapper = $anchor.doc?.nodeAt($from?.before(blockRange!.depth)) |
244 | | - const nodeType = contentWrapper!.type.name |
| 291 | + const blockRange = $from.blockRange($to) |
| 292 | + if (!blockRange) return false |
| 293 | + |
| 294 | + const contentWrapper = $anchor.doc?.nodeAt($from?.before(blockRange.depth)) |
| 295 | + if (!contentWrapper) return false |
245 | 296 |
|
246 | | - // If the Backspace is not in the contentWrapper, do nothing |
247 | 297 | if ( |
248 | | - nodeType === schema.nodes.contentHeading.name || |
249 | | - nodeType !== schema.nodes.contentWrapper.name |
| 298 | + contentWrapper.type.name === schema.nodes.contentHeading.name || |
| 299 | + contentWrapper.type.name !== schema.nodes.contentWrapper.name |
250 | 300 | ) { |
251 | 301 | return false |
252 | 302 | } |
253 | 303 |
|
254 | | - // Get the contentWrapper node pos |
255 | 304 | const contentWrapperPos = $from.before(2) |
256 | 305 |
|
257 | | - // When cursor is in the first line of contentWrapper |
258 | | - if (blockRange!.start - 1 === contentWrapperPos) { |
259 | | - // If there is no previous node in the selection (i.e., current node is the first node of the contentWrapper) |
260 | | - if ($anchor.nodeBefore === null) { |
261 | | - // If there's a text node following the current node |
262 | | - if ($anchor.nodeAfter?.type.name === TIPTAP_NODES.TEXT_TYPE) { |
263 | | - const paragraphNode = getClosestAncestorNodeByTypeName( |
264 | | - $anchor, |
265 | | - TIPTAP_NODES.PARAGRAPH_TYPE |
266 | | - ) |
267 | | - if (!paragraphNode) return false |
268 | | - const paragraphContent = paragraphNode.content.toJSON() |
269 | | - |
270 | | - // Filter out the "hardBreak" nodes from the paragraph content |
271 | | - const filteredContent = paragraphContent.filter( |
272 | | - (node: { type?: string }) => node.type !== TIPTAP_NODES.HARD_BREAK_TYPE |
273 | | - ) |
274 | | - |
275 | | - const cloneCurrentNodeAsParagraph = { |
276 | | - type: TIPTAP_NODES.PARAGRAPH_TYPE, |
277 | | - content: filteredContent |
278 | | - } |
279 | | - |
280 | | - const newNode = editor.state.schema.nodeFromJSON(cloneCurrentNodeAsParagraph) |
281 | | - |
282 | | - tr.delete(blockRange!.start, blockRange!.end) |
283 | | - |
284 | | - // we can not append block node to contentHeading node, so we just append the inline node |
285 | | - tr.insert(blockRange!.start - 2, newNode.content) |
286 | | - |
287 | | - const newSelection = new TextSelection(tr.doc.resolve(blockRange!.start - 2)) |
288 | | - tr.setSelection(newSelection) |
289 | | - |
290 | | - editor.view.dispatch(tr) |
291 | | - return true |
292 | | - } else { |
293 | | - // If no text node is following, just delete the current node and move the cursor to the end of the heading |
294 | | - this.editor |
295 | | - .chain() |
296 | | - .deleteRange({ from: blockRange!.start, to: blockRange!.end }) |
297 | | - .setTextSelection(blockRange!.start - 2) |
298 | | - .scrollIntoView() |
299 | | - .run() |
300 | | - return true |
301 | | - } |
302 | | - } |
| 306 | + if (blockRange.start - 1 !== contentWrapperPos) return false |
| 307 | + if ($anchor.nodeBefore !== null) return false |
| 308 | + |
| 309 | + if ($anchor.nodeAfter?.type.name === TIPTAP_NODES.TEXT_TYPE) { |
| 310 | + return mergeFirstBlockIntoHeading(editor, blockRange) |
303 | 311 | } |
304 | 312 |
|
305 | | - return false |
| 313 | + return deleteEmptyFirstBlock(editor, blockRange) |
306 | 314 | }, |
307 | 315 | // When the cursor is in the heading zone of a contentWrapper (after child |
308 | 316 | // headings), Enter should create a new sibling heading — not a paragraph. |
|
0 commit comments