Skip to content

Commit 30649e9

Browse files
committed
fix: heading issues
1 parent 392e3eb commit 30649e9

File tree

11 files changed

+150
-58
lines changed

11 files changed

+150
-58
lines changed

packages/webapp/components/TipTap/TipTap.tsx

+33-4
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ import TableCell from '@tiptap/extension-table-cell'
6666
import TableHeader from '@tiptap/extension-table-header'
6767
import TableRow from '@tiptap/extension-table-row'
6868

69+
import History from '@tiptap/extension-history'
70+
6971
import { Indent } from './extentions/indent'
7072

7173
import ChatCommentExtension from './extentions/ChatCommentExtension'
@@ -80,6 +82,9 @@ import {
8082
} from '@docs.plus/extension-hypermultimedia'
8183
// import Placeholders from './placeholders'
8284

85+
import * as Y from 'yjs'
86+
import { IndexeddbPersistence } from 'y-indexeddb'
87+
8388
const lowlight = createLowlight()
8489
lowlight.register('html', html)
8590
lowlight.register('css', css)
@@ -152,11 +157,15 @@ const generatePlaceholderText = (data: any) => {
152157
const Editor = ({
153158
provider,
154159
spellcheck = false,
155-
editable = true
160+
editable = true,
161+
localPersistence = false,
162+
docName = 'example-document'
156163
}: {
157164
provider?: any
158165
spellcheck?: boolean
159166
editable?: boolean
167+
localPersistence?: boolean
168+
docName?: string
160169
}): Partial<UseEditorOptions> => {
161170
const {
162171
settings: {
@@ -213,7 +222,8 @@ const Editor = ({
213222
HyperMultimediaKit.configure({
214223
Image: {
215224
modal: imageModal,
216-
inline: true
225+
inline: true,
226+
allowBase64: true
217227
},
218228
Video: {
219229
modal: youtubeModal,
@@ -250,14 +260,34 @@ const Editor = ({
250260
]
251261

252262
if (!provider) {
263+
// Create local persistence if enabled
264+
const extensions = [
265+
...baseExtensions,
266+
History.configure({
267+
depth: 5
268+
})
269+
]
270+
271+
if (localPersistence && docName) {
272+
const ydoc = new Y.Doc()
273+
new IndexeddbPersistence(docName, ydoc)
274+
275+
extensions.push(
276+
Collaboration.configure({
277+
document: ydoc
278+
})
279+
)
280+
}
281+
253282
return {
254283
editorProps: {
255284
attributes: {
256285
spellcheck: spellcheck.toString()
257286
}
258287
},
288+
immediatelyRender: false,
259289
editable,
260-
extensions: baseExtensions
290+
extensions
261291
}
262292
}
263293

@@ -267,7 +297,6 @@ const Editor = ({
267297
return {
268298
onCreate: scrollDown,
269299
enableContentCheck: true,
270-
content: 'heading',
271300
onContentError({ editor, error, disableCollaboration }) {
272301
console.error('onContentError', error)
273302
},

packages/webapp/components/TipTap/extentions/ContentWrapper.js

+7
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,13 @@ const ContentWrapper = Node.create({
285285
) {
286286
return false
287287
}
288+
289+
// Check if selection covers entire document content
290+
// Due to document schema hierarchy, content starts at position 2 and ends at docSize - 3
291+
const docSize = editor.state.doc.content.size
292+
const isEntireDocument = $anchor.pos === 2 && $head.pos === docSize - 3
293+
if (isEntireDocument) return false
294+
288295
return deleteSelectedRange(editor)
289296
}
290297

packages/webapp/components/TipTap/extentions/Heading.js

+35-19
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ const Heading = Node.create({
7171
update: (updatedNode) => {
7272
if (updatedNode.type.name !== this.name) return false
7373

74+
// Update the level attribute when it changes
75+
const newLevel = updatedNode.firstChild?.attrs.level
76+
if (newLevel && dom.getAttribute('level') !== newLevel.toString()) {
77+
dom.setAttribute('level', newLevel)
78+
}
79+
7480
const prevHeadingId = dom.getAttribute('data-id')
7581
const newHeadingId = updatedNode.attrs.id
7682

@@ -142,6 +148,12 @@ const Heading = Node.create({
142148

143149
console.info('[Heading] Delete key pressed')
144150

151+
// Check if selection covers entire document content
152+
// Due to document schema hierarchy, content starts at position 2 and ends at docSize - 3
153+
const docSize = this.editor.state.doc.content.size
154+
const isEntireDocument = $anchor.pos === 2 && $head.pos === docSize - 3
155+
if (isEntireDocument) return false
156+
145157
// if user select a range of content, then hit any key, remove the selected content
146158
return deleteSelectedRange(this.editor)
147159
},
@@ -220,7 +232,9 @@ const Heading = Node.create({
220232
}
221233
}),
222234
{}
223-
)
235+
),
236+
// Add shortcut for normal text (level 0)
237+
'Mod-Alt-0': () => this.editor.commands.normalText()
224238
}
225239
},
226240
addInputRules() {
@@ -254,23 +268,25 @@ const Heading = Node.create({
254268
addProseMirrorPlugins() {
255269
let domeEvent
256270
return [
257-
new Plugin({
258-
key: new PluginKey('ensureContentWrapperPlugin'),
259-
appendTransaction: (transactions, oldState, newState) => {
260-
let tr = newState.tr
261-
let modified = false
262-
263-
newState.doc.descendants((node, pos) => {
264-
if (node.type.name === ENUMS.NODES.HEADING_TYPE && !node.childCount) {
265-
const contentWrapper = newState.schema.nodes.contentWrapper.create()
266-
tr = tr.insert(pos + node.nodeSize, contentWrapper)
267-
modified = true
268-
}
269-
})
270-
271-
return modified ? tr : null
272-
}
273-
}),
271+
// new Plugin({
272+
// key: new PluginKey('ensureContentWrapperPlugin'),
273+
// appendTransaction: (transactions, oldState, newState) => {
274+
// console.log('ensureContentWrapperPlugin running, doc size:', newState.doc.nodeSize)
275+
// let tr = newState.tr
276+
// let modified = false
277+
278+
// newState.doc.descendants((node, pos) => {
279+
// if (node.type.name === ENUMS.NODES.HEADING_TYPE && !node.childCount) {
280+
// console.log('Empty heading found at pos:', pos, 'Adding contentWrapper')
281+
// const contentWrapper = newState.schema.nodes.contentWrapper.create()
282+
// tr = tr.insert(pos + node.nodeSize, contentWrapper)
283+
// modified = true
284+
// }
285+
// })
286+
287+
// return modified ? tr : null
288+
// }
289+
// }),
274290
// https://github.com/pageboard/pagecut/blob/bd91a17986978d560cc78642e442655f4e09ce06/src/editor.js#L234-L241
275291
new Plugin({
276292
key: new PluginKey('copy&pasteHeading'),
@@ -285,7 +301,7 @@ const Heading = Node.create({
285301
}
286302
},
287303
// INFO: Div turn confuses the schema service;
288-
// INFO:if there is a div in the clipboard, the docsplus schema will not serialize as a must.
304+
// INFO: if there is a div in the clipboard, the docsplus schema will not serialize as a must.
289305
transformPastedHTML: (html) => html.replace(/div/g, 'span'),
290306
transformPasted: (slice) => clipboardPast(slice, this.editor),
291307
transformCopied: () => {

packages/webapp/components/TipTap/extentions/changeHeadingLevel-backward.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { TextSelection } from '@tiptap/pm/state'
21
import ENUMS from '../enums'
32
import {
43
getRangeBlocks,
@@ -66,8 +65,8 @@ const changeHeadingLevelBackward = (arrg, attributes, asWrapper = false) => {
6665
tr.setSelection(updatedSelection)
6766

6867
const lastH1Inserted = {
69-
startBlockPos: 0,
70-
endBlockPos: 0
68+
startBlockPos: Math.min(tr.mapping.map(titleStartPos), tr.doc.content.size),
69+
endBlockPos: Math.min(tr.mapping.map(titleEndPos), tr.doc.content.size)
7170
}
7271

7372
let totalNodeSize = 0
@@ -77,7 +76,8 @@ const changeHeadingLevelBackward = (arrg, attributes, asWrapper = false) => {
7776

7877
if (comingLevel === 1) {
7978
lastH1Inserted.startBlockPos = insertPos
80-
lastH1Inserted.endBlockPos = insertPos + totalNodeSize
79+
lastH1Inserted.endBlockPos =
80+
tr.doc.nodeAt(lastH1Inserted.startBlockPos).content.size + lastH1Inserted.startBlockPos
8181
}
8282

8383
insertRemainingHeadings({

packages/webapp/components/TipTap/extentions/clipboardPast.js

+23-4
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,21 @@ import {
2525
const insertParagraphsAtPosition = (tr, paragraphs, state, from) => {
2626
if (paragraphs.length === 0) return
2727

28+
// Create content array, ensuring TextNodes are properly wrapped
29+
const contentArray = [...paragraphs]
30+
.map((node) => {
31+
// If it's a TextNode, wrap it in a paragraph
32+
if (node.type === 'text' || node.type?.name === 'text') {
33+
return state.schema.nodes.paragraph.create(null, [node])
34+
}
35+
return node
36+
})
37+
.map((p) => p.toJSON())
38+
2839
// Create a proper slice from the paragraphs
2940
const fragment = state.schema.nodeFromJSON({
3041
type: 'doc',
31-
content: [...paragraphs].map((p) => p.toJSON())
42+
content: contentArray
3243
}).content
3344

3445
const slice = new Slice(fragment, 1, 1) // openStart=1, openEnd=1 to allow merging
@@ -106,7 +117,14 @@ const clipboardPaste = (slice, editor) => {
106117
const [paragraphs, headings] = transformClipboardToStructured(aggregatedContent, state)
107118

108119
// If there are no headings to paste, return the original slice simple content will be handel in contentWrapper node
109-
if (headings.length === 0) return slice
120+
if (headings.length === 0) {
121+
if (paragraphs.length) {
122+
insertParagraphsAtPosition(tr, paragraphs, state, from)
123+
}
124+
tr.setMeta('paste', true)
125+
view.dispatch(tr)
126+
return Slice.empty
127+
}
110128

111129
// Delete all content from the caret to the end of the document
112130
tr.delete(from, titleEndPos)
@@ -116,12 +134,13 @@ const clipboardPaste = (slice, editor) => {
116134
}
117135

118136
let lastBlockPos = paragraphs.length === 0 ? from : tr.mapping.map(from)
137+
119138
const { prevHStartPos } = getPrevHeadingPos(tr.doc, titleStartPos, lastBlockPos)
120139

121140
// Initialize the position of the last H1 heading inserted
122141
let lastH1Inserted = {
123-
startBlockPos: tr.mapping.map(titleStartPos),
124-
endBlockPos: tr.mapping.map(titleEndPos)
142+
startBlockPos: Math.min(tr.mapping.map(titleStartPos), tr.doc.content.size),
143+
endBlockPos: Math.min(tr.mapping.map(titleEndPos), tr.doc.content.size)
125144
}
126145

127146
try {

packages/webapp/components/TipTap/extentions/helper.js

+32-9
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ export const getRangeBlocks = (doc, start, end) => {
191191
export const getHeadingsBlocksMap = (doc, start, end) => {
192192
const titleHMap = []
193193

194-
doc.nodesBetween(start, end, function (node, pos, parent, index) {
194+
const newEnd = Math.min(end, doc.content.size)
195+
196+
doc.nodesBetween(start, newEnd, function (node, pos, parent, index) {
195197
if (node.type.name === ENUMS.NODES.HEADING_TYPE) {
196198
const headingLevel = node.firstChild?.attrs?.level
197199
const depth = doc.resolve(pos).depth
@@ -280,10 +282,11 @@ export const getPrevHeadingPos = (doc, startPos, endPos) => {
280282
let prevHStartPos = 0
281283
let prevHEndPos = 0
282284

283-
doc.nodesBetween(startPos, endPos, function (node, pos) {
285+
// Ensure endPos doesn't exceed document size
286+
const newEndPos = Math.min(endPos, doc.content.size)
287+
doc?.nodesBetween(startPos, newEndPos, function (node, pos) {
284288
if (node.type.name === ENUMS.NODES.HEADING_TYPE) {
285289
const depth = doc.resolve(pos).depth
286-
287290
// INFO: this the trick I've looking for
288291
if (depth === 2) {
289292
prevHStartPos = pos
@@ -410,7 +413,7 @@ export const insertRemainingHeadings = ({
410413
mapHPost.at(0).startBlockPos,
411414
mapHPost.length === 1 && mapHPost.at(0).le === 1
412415
? mapHPost.at(0).endBlockPos
413-
: tr.mapping.map(mapHPost.at(0).endBlockPos)
416+
: Math.max(tr.mapping.map(mapHPost.at(0).endBlockPos), mapHPost.at(0).endBlockPos)
414417
)
415418

416419
const prevHBlock = getPrevHeadingPos(tr.doc, titleStartPos, tr.mapping.map(titleEndPos))
@@ -823,8 +826,7 @@ export const insertHeadingsByNodeBlocks = (
823826
// Iterate over each heading and insert it into the correct position
824827
headings.forEach((heading) => {
825828
const comingLevel = heading.attrs.level || heading.content.firstChild.attrs.level
826-
const startBlock =
827-
lastH1Inserted.startBlockPos === 0 ? tr.mapping.map(from) : lastH1Inserted.startBlockPos
829+
const startBlock = lastH1Inserted.startBlockPos
828830
const endBlock =
829831
lastH1Inserted.endBlockPos === 0
830832
? tr.mapping.map(from)
@@ -840,10 +842,8 @@ export const insertHeadingsByNodeBlocks = (
840842

841843
// Find the last occurrence of the previous block level if there are duplicates
842844
const robob = mapHPost.filter((x) => prevBlock?.le === x?.le)
843-
if (robob.length > 1) {
844-
prevBlock = robob.at(-1)
845-
}
846845

846+
if (robob.length > 1) prevBlock = robob.at(-1)
847847
lastBlockPos = prevBlock?.endBlockPos
848848

849849
// Update the previous heading start position if the previous block is at depth 2
@@ -863,3 +863,26 @@ export const insertHeadingsByNodeBlocks = (
863863

864864
return { lastBlockPos, prevHStartPos }
865865
}
866+
867+
const removeBoldMark = (node) => {
868+
if (!node.marks) return node
869+
return {
870+
...node,
871+
marks: node.marks.filter((mark) => mark.type !== 'bold')
872+
}
873+
}
874+
875+
const convertContentBlockToParagraph = (contentBlock) => {
876+
if (!contentBlock.level) return contentBlock
877+
878+
return {
879+
...contentBlock,
880+
type: 'paragraph',
881+
content: contentBlock.content?.map(removeBoldMark) || contentBlock.content
882+
}
883+
}
884+
885+
export const convertHeadingsToParagraphs = (contentBlocks) => {
886+
if (!Array.isArray(contentBlocks)) return []
887+
return contentBlocks.map(convertContentBlockToParagraph)
888+
}

packages/webapp/components/TipTap/extentions/normalText/onHeading.js

+7-10
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
getRangeBlocks,
44
getPrevHeadingList,
55
findPrevBlock,
6-
getSelectionRangeBlocks
6+
getSelectionRangeBlocks,
7+
convertHeadingsToParagraphs
78
} from '../helper'
89
import ENUMS from '../../enums'
910

@@ -45,15 +46,6 @@ const normalizeHeading = (heading) => {
4546
return heading
4647
}
4748

48-
// Convert selected content blocks to paragraphs if they are headings
49-
const convertHeadingsToParagraphs = (contentBlocks) => {
50-
return contentBlocks.map((contentBlock) =>
51-
contentBlock.level
52-
? { ...contentBlock, type: 'paragraph', content: contentBlock.content }
53-
: contentBlock
54-
)
55-
}
56-
5749
// Separate content blocks into paragraphs and headings
5850
const partitionContentBlocks = (contentBlocks) => {
5951
const paragraphs = contentBlocks.filter((block) => block.type !== ENUMS.NODES.HEADING_TYPE)
@@ -66,6 +58,11 @@ const onHeading = (args) => {
6658
const { selection, doc } = state
6759
const { $from, $to, from, to } = selection
6860

61+
if ($from.pos - $from.textOffset === 2) {
62+
console.warn('[NormalText]: First heading title, skipping')
63+
return
64+
}
65+
6966
// Calculate the position at the end of the title section
7067
const titleSectionEndPos = $to.end(1)
7168

0 commit comments

Comments
 (0)