Skip to content

Commit 2486109

Browse files
committed
refactor(editor): decompose ContentWrapper Backspace handler and add null guards
Extract three focused helper functions from the monolithic 60-line Backspace keyboard shortcut handler in ContentWrapper: - handleSelectionBackspace: selection-range deletion with contentHeading and full-document guards - mergeFirstBlockIntoHeading: merges first paragraph content into the preceding contentHeading, filtering hardBreak nodes - deleteEmptyFirstBlock: removes an empty first block and repositions the cursor Additional safety improvements: - Add null guard for blockRange ($from.blockRange can return null) - Add null guard for contentWrapper (nodeAt can return null) - Remove all non-null assertions (!) from the handler - Unify transaction dispatch pattern across both helpers - Remove redundant state parameter (read from editor.state directly) No behavior changes. Prevents potential crashes from null dereferences in edge cases (concurrent edits, unusual selections). Made-with: Cursor
1 parent 276bd01 commit 2486109

1 file changed

Lines changed: 79 additions & 71 deletions

File tree

packages/webapp/src/components/TipTap/nodes/ContentWrapper.ts

Lines changed: 79 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mergeAttributes, Node } from '@tiptap/core'
1+
import { type Editor, mergeAttributes, Node } from '@tiptap/core'
22
import { TextSelection } from '@tiptap/pm/state'
33
import {
44
type DOMOutputSpec,
@@ -86,6 +86,69 @@ function expandElement(
8686
elem.addEventListener('transitionend', callback)
8787
}
8888

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+
89152
/**
90153
* ContentWrapper — the body container inside each heading section.
91154
* Holds block content (paragraphs, lists, etc.) followed by child headings.
@@ -213,96 +276,41 @@ const ContentWrapper = Node.create({
213276
},
214277
addKeyboardShortcuts() {
215278
return {
216-
//TODO: refactor needed
217279
Backspace: (): boolean => {
218280
const { editor } = this
219-
const { schema, selection, tr } = editor.state
281+
const { schema, selection } = editor.state
220282
const { $anchor, $from, $head, $to } = selection
221-
const blockRange = $from.blockRange($to)
222283

223-
// if we have selection, node range || multiple lines block
224284
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)
237286
}
238287

239-
// If backspace hit not at the start of the node, do nothing
240288
// TODO: Revise this condition, maybe it's better to use "textOffset"
241289
if ($anchor.parentOffset !== 0) return false
242290

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
245296

246-
// If the Backspace is not in the contentWrapper, do nothing
247297
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
250300
) {
251301
return false
252302
}
253303

254-
// Get the contentWrapper node pos
255304
const contentWrapperPos = $from.before(2)
256305

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)
303311
}
304312

305-
return false
313+
return deleteEmptyFirstBlock(editor, blockRange)
306314
},
307315
// When the cursor is in the heading zone of a contentWrapper (after child
308316
// headings), Enter should create a new sibling heading — not a paragraph.

0 commit comments

Comments
 (0)