|
1 | | -import { Mark, Node, Schema, Slice } from "@tiptap/pm/model"; |
| 1 | +import { Node, Schema, Slice } from "@tiptap/pm/model"; |
2 | 2 | import type { Block } from "../../blocks/defaultBlocks.js"; |
3 | 3 | import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; |
4 | 4 | import type { |
@@ -135,206 +135,147 @@ export function contentNodeToTableContent< |
135 | 135 | return ret; |
136 | 136 | } |
137 | 137 |
|
| 138 | +/** |
| 139 | + * Extract styles from a PM node's marks, separating link href from style marks. |
| 140 | + */ |
| 141 | +function extractMarks<S extends StyleSchema>( |
| 142 | + node: Node, |
| 143 | + styleSchema: S, |
| 144 | +): { styles: Styles<S>; href: string | undefined } { |
| 145 | + const styles: Styles<S> = {}; |
| 146 | + let href: string | undefined; |
| 147 | + |
| 148 | + for (const mark of node.marks) { |
| 149 | + if (mark.type.name === "link") { |
| 150 | + href = mark.attrs.href; |
| 151 | + } else { |
| 152 | + const config = styleSchema[mark.type.name]; |
| 153 | + if (!config) { |
| 154 | + if (mark.type.spec.blocknoteIgnore) { |
| 155 | + continue; |
| 156 | + } |
| 157 | + throw new Error(`style ${mark.type.name} not found in styleSchema`); |
| 158 | + } |
| 159 | + if (config.propSchema === "boolean") { |
| 160 | + (styles as any)[config.type] = true; |
| 161 | + } else if (config.propSchema === "string") { |
| 162 | + (styles as any)[config.type] = mark.attrs.stringValue; |
| 163 | + } else { |
| 164 | + throw new UnreachableCaseError(config.propSchema); |
| 165 | + } |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + return { styles, href }; |
| 170 | +} |
| 171 | + |
| 172 | +// A flattened record representing one PM text node's contribution. |
| 173 | +type FlatTextRecord<S extends StyleSchema> = { |
| 174 | + kind: "text"; |
| 175 | + text: string; |
| 176 | + styles: Styles<S>; |
| 177 | + href: string | undefined; |
| 178 | +}; |
| 179 | + |
| 180 | +type FlatRecord<S extends StyleSchema> = |
| 181 | + | FlatTextRecord<S> |
| 182 | + | { kind: "custom"; node: Node }; |
| 183 | + |
138 | 184 | /** |
139 | 185 | * Converts an internal (prosemirror) content node to a BlockNote InlineContent array. |
| 186 | + * |
| 187 | + * Two-pass approach: |
| 188 | + * 1. Flatten each PM child node into a simple record (text + styles + optional href, or custom node) |
| 189 | + * 2. Merge consecutive records with the same href/styles into StyledText or Link objects |
140 | 190 | */ |
141 | 191 | export function contentNodeToInlineContent< |
142 | 192 | I extends InlineContentSchema, |
143 | 193 | S extends StyleSchema, |
144 | 194 | >(contentNode: Node, inlineContentSchema: I, styleSchema: S) { |
145 | | - const content: InlineContent<any, S>[] = []; |
146 | | - let currentContent: InlineContent<any, S> | undefined = undefined; |
| 195 | + // Pass 1: Flatten PM nodes into records |
| 196 | + const records: FlatRecord<S>[] = []; |
147 | 197 |
|
148 | | - // Most of the logic below is for handling links because in ProseMirror links are marks |
149 | | - // while in BlockNote links are a type of inline content |
150 | 198 | contentNode.content.forEach((node) => { |
151 | | - // hardBreak nodes do not have an InlineContent equivalent, instead we |
152 | | - // add a newline to the previous node. |
153 | 199 | if (node.type.name === "hardBreak") { |
154 | | - if (currentContent) { |
155 | | - // Current content exists. |
156 | | - if (isStyledTextInlineContent(currentContent)) { |
157 | | - // Current content is text. |
158 | | - currentContent.text += "\n"; |
159 | | - } else if (isLinkInlineContent(currentContent)) { |
160 | | - // Current content is a link. |
161 | | - currentContent.content[currentContent.content.length - 1].text += |
162 | | - "\n"; |
163 | | - } else { |
164 | | - throw new Error("unexpected"); |
165 | | - } |
| 200 | + // Append newline to the previous text record, or create one |
| 201 | + const last = records[records.length - 1]; |
| 202 | + if (last && last.kind === "text") { |
| 203 | + last.text += "\n"; |
166 | 204 | } else { |
167 | | - // Current content does not exist. |
168 | | - currentContent = { |
169 | | - type: "text", |
| 205 | + records.push({ |
| 206 | + kind: "text", |
170 | 207 | text: "\n", |
171 | | - styles: {}, |
172 | | - }; |
| 208 | + styles: {} as Styles<S>, |
| 209 | + href: undefined, |
| 210 | + }); |
173 | 211 | } |
174 | | - |
175 | 212 | return; |
176 | 213 | } |
177 | 214 |
|
178 | | - if (node.type.name !== "link" && node.type.name !== "text") { |
179 | | - if (!inlineContentSchema[node.type.name]) { |
180 | | - // eslint-disable-next-line no-console |
181 | | - console.warn("unrecognized inline content type", node.type.name); |
182 | | - return; |
183 | | - } |
184 | | - if (currentContent) { |
185 | | - content.push(currentContent); |
186 | | - currentContent = undefined; |
187 | | - } |
188 | | - |
189 | | - content.push( |
190 | | - nodeToCustomInlineContent(node, inlineContentSchema, styleSchema), |
191 | | - ); |
| 215 | + if (node.type.name === "text") { |
| 216 | + const { styles, href } = extractMarks(node, styleSchema); |
| 217 | + records.push({ kind: "text", text: node.textContent, styles, href }); |
| 218 | + return; |
| 219 | + } |
192 | 220 |
|
| 221 | + // Custom inline content node |
| 222 | + if (!inlineContentSchema[node.type.name]) { |
| 223 | + // eslint-disable-next-line no-console |
| 224 | + console.warn("unrecognized inline content type", node.type.name); |
193 | 225 | return; |
194 | 226 | } |
| 227 | + records.push({ kind: "custom", node }); |
| 228 | + }); |
195 | 229 |
|
196 | | - const styles: Styles<S> = {}; |
197 | | - let linkMark: Mark | undefined; |
| 230 | + // Pass 2: Merge consecutive text records into StyledText / Link |
| 231 | + const content: InlineContent<any, S>[] = []; |
198 | 232 |
|
199 | | - for (const mark of node.marks) { |
200 | | - if (mark.type.name === "link") { |
201 | | - linkMark = mark; |
202 | | - } else { |
203 | | - const config = styleSchema[mark.type.name]; |
204 | | - if (!config) { |
205 | | - if (mark.type.spec.blocknoteIgnore) { |
206 | | - // at this point, we don't want to show certain marks (such as comments) |
207 | | - // in the BlockNote JSON output. These marks should be tagged with "blocknoteIgnore" in the spec |
208 | | - continue; |
209 | | - } |
210 | | - throw new Error(`style ${mark.type.name} not found in styleSchema`); |
211 | | - } |
212 | | - if (config.propSchema === "boolean") { |
213 | | - (styles as any)[config.type] = true; |
214 | | - } else if (config.propSchema === "string") { |
215 | | - (styles as any)[config.type] = mark.attrs.stringValue; |
216 | | - } else { |
217 | | - throw new UnreachableCaseError(config.propSchema); |
218 | | - } |
219 | | - } |
| 233 | + for (const record of records) { |
| 234 | + if (record.kind === "custom") { |
| 235 | + content.push( |
| 236 | + nodeToCustomInlineContent(record.node, inlineContentSchema, styleSchema), |
| 237 | + ); |
| 238 | + continue; |
220 | 239 | } |
221 | 240 |
|
222 | | - // Parsing links and text. |
223 | | - // Current content exists. |
224 | | - if (currentContent) { |
225 | | - // Current content is text. |
226 | | - if (isStyledTextInlineContent(currentContent)) { |
227 | | - if (!linkMark) { |
228 | | - // Node is text (same type as current content). |
229 | | - if ( |
230 | | - JSON.stringify(currentContent.styles) === JSON.stringify(styles) |
231 | | - ) { |
232 | | - // Styles are the same. |
233 | | - currentContent.text += node.textContent; |
234 | | - } else { |
235 | | - // Styles are different. |
236 | | - content.push(currentContent); |
237 | | - currentContent = { |
238 | | - type: "text", |
239 | | - text: node.textContent, |
240 | | - styles, |
241 | | - }; |
242 | | - } |
| 241 | + const { text, styles, href } = record; |
| 242 | + const stylesKey = JSON.stringify(styles); |
| 243 | + const last = content[content.length - 1]; |
| 244 | + |
| 245 | + if (href !== undefined) { |
| 246 | + // This text belongs to a link |
| 247 | + if ( |
| 248 | + last && |
| 249 | + isLinkInlineContent(last) && |
| 250 | + last.href === href |
| 251 | + ) { |
| 252 | + // Same link — try to merge with the last StyledText inside it |
| 253 | + const lastChild = last.content[last.content.length - 1]; |
| 254 | + if (JSON.stringify(lastChild.styles) === stylesKey) { |
| 255 | + lastChild.text += text; |
243 | 256 | } else { |
244 | | - // Node is a link (different type to current content). |
245 | | - content.push(currentContent); |
246 | | - currentContent = { |
247 | | - type: "link", |
248 | | - href: linkMark.attrs.href, |
249 | | - content: [ |
250 | | - { |
251 | | - type: "text", |
252 | | - text: node.textContent, |
253 | | - styles, |
254 | | - }, |
255 | | - ], |
256 | | - }; |
257 | | - } |
258 | | - } else if (isLinkInlineContent(currentContent)) { |
259 | | - // Current content is a link. |
260 | | - if (linkMark) { |
261 | | - // Node is a link (same type as current content). |
262 | | - // Link URLs are the same. |
263 | | - if (currentContent.href === linkMark.attrs.href) { |
264 | | - // Styles are the same. |
265 | | - if ( |
266 | | - JSON.stringify( |
267 | | - currentContent.content[currentContent.content.length - 1] |
268 | | - .styles, |
269 | | - ) === JSON.stringify(styles) |
270 | | - ) { |
271 | | - currentContent.content[currentContent.content.length - 1].text += |
272 | | - node.textContent; |
273 | | - } else { |
274 | | - // Styles are different. |
275 | | - currentContent.content.push({ |
276 | | - type: "text", |
277 | | - text: node.textContent, |
278 | | - styles, |
279 | | - }); |
280 | | - } |
281 | | - } else { |
282 | | - // Link URLs are different. |
283 | | - content.push(currentContent); |
284 | | - currentContent = { |
285 | | - type: "link", |
286 | | - href: linkMark.attrs.href, |
287 | | - content: [ |
288 | | - { |
289 | | - type: "text", |
290 | | - text: node.textContent, |
291 | | - styles, |
292 | | - }, |
293 | | - ], |
294 | | - }; |
295 | | - } |
296 | | - } else { |
297 | | - // Node is text (different type to current content). |
298 | | - content.push(currentContent); |
299 | | - currentContent = { |
300 | | - type: "text", |
301 | | - text: node.textContent, |
302 | | - styles, |
303 | | - }; |
| 257 | + last.content.push({ type: "text", text, styles }); |
304 | 258 | } |
305 | 259 | } else { |
306 | | - // TODO |
307 | | - } |
308 | | - } |
309 | | - // Current content does not exist. |
310 | | - else { |
311 | | - // Node is text. |
312 | | - if (!linkMark) { |
313 | | - currentContent = { |
314 | | - type: "text", |
315 | | - text: node.textContent, |
316 | | - styles, |
317 | | - }; |
318 | | - } |
319 | | - // Node is a link. |
320 | | - else { |
321 | | - currentContent = { |
| 260 | + // New link |
| 261 | + content.push({ |
322 | 262 | type: "link", |
323 | | - href: linkMark.attrs.href, |
324 | | - content: [ |
325 | | - { |
326 | | - type: "text", |
327 | | - text: node.textContent, |
328 | | - styles, |
329 | | - }, |
330 | | - ], |
331 | | - }; |
| 263 | + href, |
| 264 | + content: [{ type: "text", text, styles }], |
| 265 | + }); |
| 266 | + } |
| 267 | + } else { |
| 268 | + // Plain text |
| 269 | + if ( |
| 270 | + last && |
| 271 | + isStyledTextInlineContent(last) && |
| 272 | + JSON.stringify(last.styles) === stylesKey |
| 273 | + ) { |
| 274 | + last.text += text; |
| 275 | + } else { |
| 276 | + content.push({ type: "text", text, styles }); |
332 | 277 | } |
333 | 278 | } |
334 | | - }); |
335 | | - |
336 | | - if (currentContent) { |
337 | | - content.push(currentContent); |
338 | 279 | } |
339 | 280 |
|
340 | 281 | return content as InlineContent<I, S>[]; |
|
0 commit comments