@blocknote/core 0.29.0 → 0.30.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.
Files changed (69) hide show
  1. package/README.md +125 -0
  2. package/dist/blocknote.cjs +9 -9
  3. package/dist/blocknote.cjs.map +1 -1
  4. package/dist/blocknote.js +1479 -1339
  5. package/dist/blocknote.js.map +1 -1
  6. package/dist/locales.cjs +1 -1
  7. package/dist/locales.cjs.map +1 -1
  8. package/dist/locales.js +751 -9
  9. package/dist/locales.js.map +1 -1
  10. package/dist/style.css +1 -1
  11. package/dist/tsconfig.tsbuildinfo +1 -1
  12. package/dist/webpack-stats.json +1 -1
  13. package/package.json +3 -6
  14. package/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap +0 -7
  15. package/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap +0 -5
  16. package/src/api/blockManipulation/commands/moveBlocks/__snapshots__/moveBlocks.test.ts.snap +0 -20
  17. package/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap +0 -12
  18. package/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap +0 -6
  19. package/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap +0 -17
  20. package/src/api/clipboard/fromClipboard/pasteExtension.ts +19 -1
  21. package/src/blocks/AudioBlockContent/AudioBlockContent.ts +5 -0
  22. package/src/blocks/CodeBlockContent/CodeBlockContent.ts +32 -18
  23. package/src/blocks/FileBlockContent/FileBlockContent.ts +5 -0
  24. package/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts +9 -2
  25. package/src/blocks/HeadingBlockContent/HeadingBlockContent.ts +3 -0
  26. package/src/blocks/ImageBlockContent/ImageBlockContent.ts +10 -2
  27. package/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +9 -25
  28. package/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts +14 -3
  29. package/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +9 -26
  30. package/src/blocks/ListItemBlockContent/getListItemContent.ts +115 -0
  31. package/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts +6 -2
  32. package/src/blocks/QuoteBlockContent/QuoteBlockContent.ts +6 -1
  33. package/src/blocks/TableBlockContent/TableBlockContent.ts +71 -14
  34. package/src/blocks/VideoBlockContent/VideoBlockContent.ts +10 -2
  35. package/src/blocks/defaultBlockHelpers.ts +16 -0
  36. package/src/editor/Block.css +2 -1
  37. package/src/editor/BlockNoteEditor.ts +103 -60
  38. package/src/editor/BlockNoteExtensions.ts +14 -5
  39. package/src/extensions/Collaboration/CursorPlugin.ts +152 -0
  40. package/src/extensions/Collaboration/SyncPlugin.ts +15 -0
  41. package/src/extensions/Collaboration/UndoPlugin.ts +14 -0
  42. package/src/extensions/FilePanel/FilePanelPlugin.ts +31 -22
  43. package/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +2 -4
  44. package/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +3 -0
  45. package/src/i18n/locales/index.ts +2 -0
  46. package/src/i18n/locales/ru.ts +2 -2
  47. package/src/i18n/locales/sk.ts +355 -0
  48. package/src/i18n/locales/zh-tw.ts +390 -0
  49. package/src/pm-nodes/BlockContainer.ts +7 -6
  50. package/src/schema/blocks/createSpec.ts +1 -1
  51. package/src/schema/blocks/internal.ts +0 -1
  52. package/src/schema/blocks/types.ts +2 -1
  53. package/types/src/api/blockManipulation/setupTestEnv.d.ts +8 -4
  54. package/types/src/blocks/ImageBlockContent/ImageBlockContent.d.ts +8 -4
  55. package/types/src/blocks/ListItemBlockContent/getListItemContent.d.ts +28 -0
  56. package/types/src/blocks/VideoBlockContent/VideoBlockContent.d.ts +8 -4
  57. package/types/src/blocks/defaultBlockHelpers.d.ts +1 -0
  58. package/types/src/blocks/defaultBlocks.d.ts +16 -8
  59. package/types/src/editor/BlockNoteEditor.d.ts +18 -1
  60. package/types/src/extensions/Collaboration/CursorPlugin.d.ts +31 -0
  61. package/types/src/extensions/Collaboration/SyncPlugin.d.ts +7 -0
  62. package/types/src/extensions/Collaboration/UndoPlugin.d.ts +6 -0
  63. package/types/src/extensions/FilePanel/FilePanelPlugin.d.ts +1 -1
  64. package/types/src/i18n/locales/index.d.ts +2 -0
  65. package/types/src/i18n/locales/sk.d.ts +313 -0
  66. package/types/src/i18n/locales/zh-tw.d.ts +2 -0
  67. package/types/src/schema/blocks/types.d.ts +2 -1
  68. package/src/extensions/Collaboration/createCollaborationExtensions.ts +0 -147
  69. package/types/src/extensions/Collaboration/createCollaborationExtensions.d.ts +0 -17
@@ -302,7 +302,6 @@ exports[`Test updateBlock > Revert all props 2`] = `
302
302
  "backgroundColor": "default",
303
303
  "caption": "",
304
304
  "name": "",
305
- "previewWidth": 512,
306
305
  "showPreview": true,
307
306
  "textAlignment": "left",
308
307
  "url": "https://via.placeholder.com/150",
@@ -940,7 +939,6 @@ exports[`Test updateBlock > Revert single prop 2`] = `
940
939
  "backgroundColor": "default",
941
940
  "caption": "",
942
941
  "name": "",
943
- "previewWidth": 512,
944
942
  "showPreview": true,
945
943
  "textAlignment": "left",
946
944
  "url": "https://via.placeholder.com/150",
@@ -1578,7 +1576,6 @@ exports[`Test updateBlock > Update all props 2`] = `
1578
1576
  "backgroundColor": "default",
1579
1577
  "caption": "",
1580
1578
  "name": "",
1581
- "previewWidth": 512,
1582
1579
  "showPreview": true,
1583
1580
  "textAlignment": "left",
1584
1581
  "url": "https://via.placeholder.com/150",
@@ -2216,7 +2213,6 @@ exports[`Test updateBlock > Update children 2`] = `
2216
2213
  "backgroundColor": "default",
2217
2214
  "caption": "",
2218
2215
  "name": "",
2219
- "previewWidth": 512,
2220
2216
  "showPreview": true,
2221
2217
  "textAlignment": "left",
2222
2218
  "url": "https://via.placeholder.com/150",
@@ -2561,7 +2557,6 @@ exports[`Test updateBlock > Update inline content to no content 1`] = `
2561
2557
  "backgroundColor": "default",
2562
2558
  "caption": "",
2563
2559
  "name": "",
2564
- "previewWidth": 512,
2565
2560
  "showPreview": true,
2566
2561
  "textAlignment": "left",
2567
2562
  "url": "",
@@ -2580,7 +2575,6 @@ exports[`Test updateBlock > Update inline content to no content 2`] = `
2580
2575
  "backgroundColor": "default",
2581
2576
  "caption": "",
2582
2577
  "name": "",
2583
- "previewWidth": 512,
2584
2578
  "showPreview": true,
2585
2579
  "textAlignment": "left",
2586
2580
  "url": "",
@@ -2799,7 +2793,6 @@ exports[`Test updateBlock > Update inline content to no content 2`] = `
2799
2793
  "backgroundColor": "default",
2800
2794
  "caption": "",
2801
2795
  "name": "",
2802
- "previewWidth": 512,
2803
2796
  "showPreview": true,
2804
2797
  "textAlignment": "left",
2805
2798
  "url": "https://via.placeholder.com/150",
@@ -3722,7 +3715,6 @@ exports[`Test updateBlock > Update inline content to table content 2`] = `
3722
3715
  "backgroundColor": "default",
3723
3716
  "caption": "",
3724
3717
  "name": "",
3725
- "previewWidth": 512,
3726
3718
  "showPreview": true,
3727
3719
  "textAlignment": "left",
3728
3720
  "url": "https://via.placeholder.com/150",
@@ -7032,7 +7024,6 @@ exports[`Test updateBlock > Update single prop 2`] = `
7032
7024
  "backgroundColor": "default",
7033
7025
  "caption": "",
7034
7026
  "name": "",
7035
- "previewWidth": 512,
7036
7027
  "showPreview": true,
7037
7028
  "textAlignment": "left",
7038
7029
  "url": "https://via.placeholder.com/150",
@@ -7613,7 +7604,6 @@ exports[`Test updateBlock > Update table content to empty inline content 2`] = `
7613
7604
  "backgroundColor": "default",
7614
7605
  "caption": "",
7615
7606
  "name": "",
7616
- "previewWidth": 512,
7617
7607
  "showPreview": true,
7618
7608
  "textAlignment": "left",
7619
7609
  "url": "https://via.placeholder.com/150",
@@ -8026,7 +8016,6 @@ exports[`Test updateBlock > Update table content to inline content 2`] = `
8026
8016
  "backgroundColor": "default",
8027
8017
  "caption": "",
8028
8018
  "name": "",
8029
- "previewWidth": 512,
8030
8019
  "showPreview": true,
8031
8020
  "textAlignment": "left",
8032
8021
  "url": "https://via.placeholder.com/150",
@@ -8203,7 +8192,6 @@ exports[`Test updateBlock > Update table content to no content 1`] = `
8203
8192
  "backgroundColor": "default",
8204
8193
  "caption": "",
8205
8194
  "name": "",
8206
- "previewWidth": 512,
8207
8195
  "showPreview": true,
8208
8196
  "textAlignment": "left",
8209
8197
  "url": "",
@@ -8443,7 +8431,6 @@ exports[`Test updateBlock > Update table content to no content 2`] = `
8443
8431
  "backgroundColor": "default",
8444
8432
  "caption": "",
8445
8433
  "name": "",
8446
- "previewWidth": 512,
8447
8434
  "showPreview": true,
8448
8435
  "textAlignment": "left",
8449
8436
  "url": "https://via.placeholder.com/150",
@@ -8475,7 +8462,6 @@ exports[`Test updateBlock > Update table content to no content 2`] = `
8475
8462
  "backgroundColor": "default",
8476
8463
  "caption": "",
8477
8464
  "name": "",
8478
- "previewWidth": 512,
8479
8465
  "showPreview": true,
8480
8466
  "textAlignment": "left",
8481
8467
  "url": "",
@@ -8910,7 +8896,6 @@ exports[`Test updateBlock > Update type 2`] = `
8910
8896
  "backgroundColor": "default",
8911
8897
  "caption": "",
8912
8898
  "name": "",
8913
- "previewWidth": 512,
8914
8899
  "showPreview": true,
8915
8900
  "textAlignment": "left",
8916
8901
  "url": "https://via.placeholder.com/150",
@@ -9533,7 +9518,6 @@ exports[`Test updateBlock > Update with plain content 2`] = `
9533
9518
  "backgroundColor": "default",
9534
9519
  "caption": "",
9535
9520
  "name": "",
9536
- "previewWidth": 512,
9537
9521
  "showPreview": true,
9538
9522
  "textAlignment": "left",
9539
9523
  "url": "https://via.placeholder.com/150",
@@ -10157,7 +10141,6 @@ exports[`Test updateBlock > Update with styled content 2`] = `
10157
10141
  "backgroundColor": "default",
10158
10142
  "caption": "",
10159
10143
  "name": "",
10160
- "previewWidth": 512,
10161
10144
  "showPreview": true,
10162
10145
  "textAlignment": "left",
10163
10146
  "url": "https://via.placeholder.com/150",
@@ -5,6 +5,7 @@ import type {
5
5
  BlockNoteEditor,
6
6
  BlockNoteEditorOptions,
7
7
  } from "../../../editor/BlockNoteEditor";
8
+ import { isMarkdown } from "../../parsers/markdown/detectMarkdown.js";
8
9
  import {
9
10
  BlockSchema,
10
11
  InlineContentSchema,
@@ -13,7 +14,6 @@ import {
13
14
  import { acceptedMIMETypes } from "./acceptedMIMETypes.js";
14
15
  import { handleFileInsertion } from "./handleFileInsertion.js";
15
16
  import { handleVSCodePaste } from "./handleVSCodePaste.js";
16
- import { isMarkdown } from "../../parsers/markdown/detectMarkdown.js";
17
17
 
18
18
  function defaultPasteHandler({
19
19
  event,
@@ -26,6 +26,24 @@ function defaultPasteHandler({
26
26
  prioritizeMarkdownOverHTML: boolean;
27
27
  plainTextAsMarkdown: boolean;
28
28
  }) {
29
+ // Special case for code blocks, as they do not support any rich text
30
+ // formatting, so we force pasting plain text.
31
+ const isInCodeBlock = editor.transact(
32
+ (tr) =>
33
+ tr.selection.$from.parent.type.spec.code &&
34
+ tr.selection.$to.parent.type.spec.code
35
+ );
36
+
37
+ if (isInCodeBlock) {
38
+ const data = event.clipboardData?.getData("text/plain");
39
+
40
+ if (data) {
41
+ editor.pasteText(data);
42
+
43
+ return true;
44
+ }
45
+ }
46
+
29
47
  let format: (typeof acceptedMIMETypes)[number] | undefined;
30
48
  for (const mimeType of acceptedMIMETypes) {
31
49
  if (event.clipboardData!.types.includes(mimeType)) {
@@ -78,6 +78,11 @@ export const audioParse = (
78
78
  element: HTMLElement
79
79
  ): Partial<Props<typeof audioBlockConfig.propSchema>> | undefined => {
80
80
  if (element.tagName === "AUDIO") {
81
+ // Ignore if parent figure has already been parsed.
82
+ if (element.closest("figure")) {
83
+ return undefined;
84
+ }
85
+
81
86
  return parseAudioElement(element as HTMLAudioElement);
82
87
  }
83
88
 
@@ -126,7 +126,10 @@ const CodeBlockContent = createStronglyTypedTiptapNode({
126
126
  return null;
127
127
  }
128
128
 
129
- return getLanguageId(options.editor.settings.codeBlock, language);
129
+ return (
130
+ getLanguageId(options.editor.settings.codeBlock, language) ??
131
+ language
132
+ );
130
133
  },
131
134
  renderHTML: (attributes) => {
132
135
  return attributes.language
@@ -141,10 +144,12 @@ const CodeBlockContent = createStronglyTypedTiptapNode({
141
144
  },
142
145
  parseHTML() {
143
146
  return [
147
+ // Parse from internal HTML.
144
148
  {
145
149
  tag: "div[data-content-type=" + this.name + "]",
146
- contentElement: "code",
150
+ contentElement: ".bn-inline-content",
147
151
  },
152
+ // Parse from external HTML.
148
153
  {
149
154
  tag: "pre",
150
155
  contentElement: "code",
@@ -251,7 +256,7 @@ const CodeBlockContent = createStronglyTypedTiptapNode({
251
256
  if (process.env.NODE_ENV === "development" && !hasWarned) {
252
257
  // eslint-disable-next-line no-console
253
258
  console.log(
254
- "For syntax highlighting of code blocks, you must provide a highlighter function"
259
+ "For syntax highlighting of code blocks, you must provide a `codeBlock.createHighlighter` function"
255
260
  );
256
261
  hasWarned = true;
257
262
  }
@@ -268,15 +273,22 @@ const CodeBlockContent = createStronglyTypedTiptapNode({
268
273
  }
269
274
  );
270
275
  }
271
-
272
- const language = parserOptions.language;
276
+ const language = getLanguageId(
277
+ options.editor.settings.codeBlock,
278
+ parserOptions.language!
279
+ );
273
280
 
274
281
  if (
275
- language &&
276
- language !== "text" &&
277
- !highlighter.getLoadedLanguages().includes(language) &&
278
- language in options.editor.settings.codeBlock.supportedLanguages
282
+ !language ||
283
+ language === "text" ||
284
+ language === "none" ||
285
+ language === "plaintext" ||
286
+ language === "txt"
279
287
  ) {
288
+ return [];
289
+ }
290
+
291
+ if (!highlighter.getLoadedLanguages().includes(language)) {
280
292
  return highlighter.loadLanguage(language);
281
293
  }
282
294
 
@@ -308,10 +320,9 @@ const CodeBlockContent = createStronglyTypedTiptapNode({
308
320
  const $start = state.doc.resolve(range.from);
309
321
  const languageName = match[1].trim();
310
322
  const attributes = {
311
- language: getLanguageId(
312
- options.editor.settings.codeBlock,
313
- languageName
314
- ),
323
+ language:
324
+ getLanguageId(options.editor.settings.codeBlock, languageName) ??
325
+ languageName,
315
326
  };
316
327
 
317
328
  if (
@@ -422,10 +433,13 @@ export const CodeBlock = createBlockSpecFromStronglyTypedTiptapNode(
422
433
  defaultCodeBlockPropSchema
423
434
  );
424
435
 
425
- function getLanguageId(options: CodeBlockOptions, languageName: string) {
426
- return (
427
- Object.entries(options.supportedLanguages).find(([id, { aliases }]) => {
436
+ function getLanguageId(
437
+ options: CodeBlockOptions,
438
+ languageName: string
439
+ ): string | undefined {
440
+ return Object.entries(options.supportedLanguages).find(
441
+ ([id, { aliases }]) => {
428
442
  return aliases?.includes(languageName) || id === languageName;
429
- })?.[0] || languageName
430
- );
443
+ }
444
+ )?.[0];
431
445
  }
@@ -43,6 +43,11 @@ export const fileRender = (
43
43
 
44
44
  export const fileParse = (element: HTMLElement) => {
45
45
  if (element.tagName === "EMBED") {
46
+ // Ignore if parent figure has already been parsed.
47
+ if (element.closest("figure")) {
48
+ return undefined;
49
+ }
50
+
46
51
  return parseEmbedElement(element as HTMLEmbedElement);
47
52
  }
48
53
 
@@ -19,7 +19,11 @@ export const createResizableFileBlockWrapper = (
19
19
  );
20
20
  const wrapper = dom;
21
21
  if (block.props.url && block.props.showPreview) {
22
- wrapper.style.width = `${block.props.previewWidth}px`;
22
+ if (block.props.previewWidth) {
23
+ wrapper.style.width = `${block.props.previewWidth}px`;
24
+ } else {
25
+ wrapper.style.width = "fit-content";
26
+ }
23
27
  }
24
28
 
25
29
  const leftResizeHandle = document.createElement("div");
@@ -87,7 +91,10 @@ export const createResizableFileBlockWrapper = (
87
91
 
88
92
  // Ensures the element is not wider than the editor and not narrower than a
89
93
  // predetermined minimum width.
90
- width = Math.max(newWidth, minWidth);
94
+ width = Math.min(
95
+ Math.max(newWidth, minWidth),
96
+ editor.domElement?.firstElementChild?.clientWidth || Number.MAX_VALUE
97
+ );
91
98
  wrapper.style.width = `${width}px`;
92
99
  };
93
100
  // Stops mouse movements from resizing the element and updates the block's
@@ -118,9 +118,12 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
118
118
  },
119
119
  parseHTML() {
120
120
  return [
121
+ // Parse from internal HTML.
121
122
  {
122
123
  tag: "div[data-content-type=" + this.name + "]",
124
+ contentElement: ".bn-inline-content",
123
125
  },
126
+ // Parse from external HTML.
124
127
  {
125
128
  tag: "h1",
126
129
  attrs: { level: 1 },
@@ -37,7 +37,8 @@ export const imagePropSchema = {
37
37
  },
38
38
  // File preview width in px.
39
39
  previewWidth: {
40
- default: 512,
40
+ default: undefined,
41
+ type: "number",
41
42
  },
42
43
  } satisfies PropSchema;
43
44
 
@@ -88,6 +89,11 @@ export const imageParse = (
88
89
  element: HTMLElement
89
90
  ): Partial<Props<typeof imageBlockConfig.propSchema>> | undefined => {
90
91
  if (element.tagName === "IMG") {
92
+ // Ignore if parent figure has already been parsed.
93
+ if (element.closest("figure")) {
94
+ return undefined;
95
+ }
96
+
91
97
  return parseImageElement(element as HTMLImageElement);
92
98
  }
93
99
 
@@ -125,7 +131,9 @@ export const imageToExternalHTML = (
125
131
  image = document.createElement("img");
126
132
  image.src = block.props.url;
127
133
  image.alt = block.props.name || block.props.caption || "BlockNote image";
128
- image.width = block.props.previewWidth;
134
+ if (block.props.previewWidth) {
135
+ image.width = block.props.previewWidth;
136
+ }
129
137
  } else {
130
138
  image = document.createElement("a");
131
139
  image.href = block.props.url;
@@ -8,6 +8,7 @@ import {
8
8
  } from "../../../schema/index.js";
9
9
  import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
10
10
  import { defaultProps } from "../../defaultProps.js";
11
+ import { getListItemContent } from "../getListItemContent.js";
11
12
  import { handleEnter } from "../ListItemKeyboardShortcuts.js";
12
13
 
13
14
  export const bulletListItemPropSchema = {
@@ -73,10 +74,12 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({
73
74
 
74
75
  parseHTML() {
75
76
  return [
76
- // Case for regular HTML list structure.
77
+ // Parse from internal HTML.
77
78
  {
78
79
  tag: "div[data-content-type=" + this.name + "]",
80
+ contentElement: ".bn-inline-content",
79
81
  },
82
+ // Parse from external HTML.
80
83
  {
81
84
  tag: "li",
82
85
  getAttrs: (element) => {
@@ -92,36 +95,17 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({
92
95
 
93
96
  if (
94
97
  parent.tagName === "UL" ||
95
- (parent.tagName === "DIV" && parent.parentElement!.tagName === "UL")
98
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
96
99
  ) {
97
100
  return {};
98
101
  }
99
102
 
100
103
  return false;
101
104
  },
102
- node: "bulletListItem",
103
- },
104
- // Case for BlockNote list structure.
105
- {
106
- tag: "p",
107
- getAttrs: (element) => {
108
- if (typeof element === "string") {
109
- return false;
110
- }
111
-
112
- const parent = element.parentElement;
113
-
114
- if (parent === null) {
115
- return false;
116
- }
117
-
118
- if (parent.getAttribute("data-content-type") === "bulletListItem") {
119
- return {};
120
- }
121
-
122
- return false;
123
- },
124
- priority: 300,
105
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
106
+ // into a single one so that ProseMirror can parse everything correctly.
107
+ getContent: (node, schema) =>
108
+ getListItemContent(node, schema, this.name),
125
109
  node: "bulletListItem",
126
110
  },
127
111
  ];
@@ -12,6 +12,7 @@ import {
12
12
  } from "../../../schema/index.js";
13
13
  import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
14
14
  import { defaultProps } from "../../defaultProps.js";
15
+ import { getListItemContent } from "../getListItemContent.js";
15
16
  import { handleEnter } from "../ListItemKeyboardShortcuts.js";
16
17
 
17
18
  export const checkListItemPropSchema = {
@@ -109,10 +110,12 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
109
110
 
110
111
  parseHTML() {
111
112
  return [
113
+ // Parse from internal HTML.
112
114
  {
113
115
  tag: "div[data-content-type=" + this.name + "]",
116
+ contentElement: ".bn-inline-content",
114
117
  },
115
- // Checkbox only.
118
+ // Parse from external HTML.
116
119
  {
117
120
  tag: "input",
118
121
  getAttrs: (element) => {
@@ -120,6 +123,11 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
120
123
  return false;
121
124
  }
122
125
 
126
+ // Ignore if we already parsed an ancestor list item to avoid double-parsing.
127
+ if (element.closest("[data-content-type]") || element.closest("li")) {
128
+ return false;
129
+ }
130
+
123
131
  if ((element as HTMLInputElement).type === "checkbox") {
124
132
  return { checked: (element as HTMLInputElement).checked };
125
133
  }
@@ -128,7 +136,6 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
128
136
  },
129
137
  node: "checkListItem",
130
138
  },
131
- // Container element for checkbox + label.
132
139
  {
133
140
  tag: "li",
134
141
  getAttrs: (element) => {
@@ -144,7 +151,7 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
144
151
 
145
152
  if (
146
153
  parent.tagName === "UL" ||
147
- (parent.tagName === "DIV" && parent.parentElement!.tagName === "UL")
154
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
148
155
  ) {
149
156
  const checkbox =
150
157
  (element.querySelector(
@@ -160,6 +167,10 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
160
167
 
161
168
  return false;
162
169
  },
170
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
171
+ // into a single one so that ProseMirror can parse everything correctly.
172
+ getContent: (node, schema) =>
173
+ getListItemContent(node, schema, this.name),
163
174
  node: "checkListItem",
164
175
  },
165
176
  ];
@@ -9,6 +9,7 @@ import {
9
9
  } from "../../../schema/index.js";
10
10
  import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
11
11
  import { defaultProps } from "../../defaultProps.js";
12
+ import { getListItemContent } from "../getListItemContent.js";
12
13
  import { handleEnter } from "../ListItemKeyboardShortcuts.js";
13
14
  import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin.js";
14
15
 
@@ -101,11 +102,12 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
101
102
 
102
103
  parseHTML() {
103
104
  return [
105
+ // Parse from internal HTML.
104
106
  {
105
107
  tag: "div[data-content-type=" + this.name + "]",
108
+ contentElement: ".bn-inline-content",
106
109
  },
107
- // Case for regular HTML list structure.
108
- // (e.g.: when pasting from other apps)
110
+ // Parse from external HTML.
109
111
  {
110
112
  tag: "li",
111
113
  getAttrs: (element) => {
@@ -121,7 +123,7 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
121
123
 
122
124
  if (
123
125
  parent.tagName === "OL" ||
124
- (parent.tagName === "DIV" && parent.parentElement!.tagName === "OL")
126
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "OL")
125
127
  ) {
126
128
  const startIndex =
127
129
  parseInt(parent.getAttribute("start") || "1") || 1;
@@ -137,29 +139,10 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
137
139
 
138
140
  return false;
139
141
  },
140
- node: "numberedListItem",
141
- },
142
- // Case for BlockNote list structure.
143
- // (e.g.: when pasting from blocknote)
144
- {
145
- tag: "p",
146
- getAttrs: (element) => {
147
- if (typeof element === "string") {
148
- return false;
149
- }
150
-
151
- const parent = element.parentElement;
152
-
153
- if (parent === null) {
154
- return false;
155
- }
156
-
157
- if (parent.getAttribute("data-content-type") === "numberedListItem") {
158
- return {};
159
- }
160
-
161
- return false;
162
- },
142
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
143
+ // into a single one so that ProseMirror can parse everything correctly.
144
+ getContent: (node, schema) =>
145
+ getListItemContent(node, schema, this.name),
163
146
  priority: 300,
164
147
  node: "numberedListItem",
165
148
  },
@@ -0,0 +1,115 @@
1
+ import { DOMParser, Fragment, Schema } from "prosemirror-model";
2
+
3
+ /**
4
+ * This function is used to parse the content of a list item external HTML node.
5
+ *
6
+ * Due to a change in how prosemirror-model handles parsing elements, we have additional flexibility in how we can "fit" content into a list item.
7
+ *
8
+ * We've decided to take an approach that is similar to Notion. The core rules of the algorithm are:
9
+ *
10
+ * - If the first child of an `li` has ONLY text content, take the text content, and flatten it into the list item. Subsequent siblings are carried over as is, as children of the list item.
11
+ * - e.g. `<li><h1>Hello</h1><p>World</p></li> -> <li>Hello<blockGroup><blockContainer><p>World</p></blockContainer></blockGroup></li>`
12
+ * - Else, take the content and insert it as children instead.
13
+ * - e.g. `<li><img src="url" /></li> -> <li><p></p><blockGroup><blockContainer><img src="url" /></blockContainer></blockGroup></li>`
14
+ *
15
+ * This ensures that a list item's content is always valid ProseMirror content. Smoothing over differences between how external HTML may be rendered, and how ProseMirror expects content to be structured.
16
+ */
17
+ export function getListItemContent(
18
+ /**
19
+ * The `li` element to parse.
20
+ */
21
+ _node: Node,
22
+ /**
23
+ * The schema to use for parsing.
24
+ */
25
+ schema: Schema,
26
+ /**
27
+ * The name of the list item node.
28
+ */
29
+ name: string
30
+ ): Fragment {
31
+ /**
32
+ * To actually implement this algorithm, we need to leverage ProseMirror's "fitting" algorithm.
33
+ * Where, if content is parsed which doesn't fit into the current node, it will be moved into the parent node.
34
+ *
35
+ * This allows us to parse multiple pieces of content from within the list item (even though it normally would not match the list item's schema) and "throw" the excess content into the list item's children.
36
+ *
37
+ * The expected return value is a `Fragment` which contains the list item's content as the first element, and the children wrapped in a blockGroup node. Like so:
38
+ * ```
39
+ * Fragment<[Node<Text>, Node<BlockGroup<Node<BlockContainer<any>>>>]>
40
+ * ```
41
+ */
42
+ const parser = DOMParser.fromSchema(schema);
43
+
44
+ // TODO: This will be unnecessary in the future: https://github.com/ProseMirror/prosemirror-model/commit/166188d4f9db96eb86fb7de62e72049c86c9dd79
45
+ const node = _node as HTMLElement;
46
+
47
+ // Move the `li` element's content into a new `div` element
48
+ // This is a hacky workaround to not re-trigger list item parsing,
49
+ // when we are looking to understand what the list item's content actually is, in terms of the schema.
50
+ const clonedNodeDiv = document.createElement("div");
51
+ // Mark the `div` element as a `blockGroup` to make the parsing easier.
52
+ clonedNodeDiv.setAttribute("data-node-type", "blockGroup");
53
+ // Clone all children of the `li` element into the new `div` element
54
+ for (const child of Array.from(node.childNodes)) {
55
+ clonedNodeDiv.appendChild(child.cloneNode(true));
56
+ }
57
+
58
+ // Parses children of the `li` element into a `blockGroup` with `blockContainer` node children
59
+ // This is the structure of list item children, so parsing into this structure allows for
60
+ // easy separation of list item content from child list item content.
61
+ let blockGroupNode = parser.parse(clonedNodeDiv, {
62
+ topNode: schema.nodes.blockGroup.create(),
63
+ });
64
+
65
+ // There is an edge case where a list item's content may contain a `<input>` element.
66
+ // Causing it to be recognized as a `checkListItem`.
67
+ // We want to skip this, and just parse the list item's content as is.
68
+ if (blockGroupNode.firstChild?.firstChild?.type.name === "checkListItem") {
69
+ // We skip the first child, by cutting it out of the `blockGroup` node.
70
+ // and continuing with the rest of the algorithm.
71
+ blockGroupNode = blockGroupNode.copy(
72
+ blockGroupNode.content.cut(
73
+ blockGroupNode.firstChild.firstChild.nodeSize + 2
74
+ )
75
+ );
76
+ }
77
+
78
+ // Structure above is `blockGroup<blockContainer<any>[]>`
79
+ // We want to extract the first `blockContainer` node's content, and see if it is a text block.
80
+ const listItemsFirstChild = blockGroupNode.firstChild?.firstChild;
81
+
82
+ // If the first node is not a text block, then it's first child is not compatible with the list item node.
83
+ if (!listItemsFirstChild?.isTextblock) {
84
+ // So, we do not try inserting anything into the list item, and instead return anything we found as children for the list item.
85
+ return Fragment.from(blockGroupNode);
86
+ }
87
+
88
+ // If it is a text block, then we know it only contains text content.
89
+ // So, we extract it, and insert its content into the `listItemNode`.
90
+ // The remaining nodes in the `blockGroup` stay in-place.
91
+ const listItemNode = schema.nodes[name].create(
92
+ {},
93
+ listItemsFirstChild.content
94
+ );
95
+
96
+ // We have `blockGroup<listItemsFirstChild, ...blockContainer<any>[]>`
97
+ // We want to extract out the rest of the nodes as `<...blockContainer<any>[]>`
98
+ const remainingListItemChildren = blockGroupNode.content.cut(
99
+ // +2 for the `blockGroup` node's start and end markers
100
+ listItemsFirstChild.nodeSize + 2
101
+ );
102
+ const hasRemainingListItemChildren = remainingListItemChildren.size > 0;
103
+
104
+ if (hasRemainingListItemChildren) {
105
+ // Copy the remaining list item children back into the `blockGroup` node.
106
+ // This will make it back into: `blockGroup<...blockContainer<any>[]>`
107
+ const listItemsChildren = blockGroupNode.copy(remainingListItemChildren);
108
+
109
+ // Return the `listItem` node's content, then add the parsed children after to be lifted out by ProseMirror "fitting" algorithm.
110
+ return listItemNode.content.addToEnd(listItemsChildren);
111
+ }
112
+
113
+ // Otherwise, just return the `listItem` node's content.
114
+ return listItemNode.content;
115
+ }