@blocknote/core 0.7.1-alpha.0 → 0.8.1

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 (77) hide show
  1. package/dist/blocknote.js +1711 -1469
  2. package/dist/blocknote.js.map +1 -1
  3. package/dist/blocknote.umd.cjs +6 -2
  4. package/dist/blocknote.umd.cjs.map +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +3 -3
  7. package/src/BlockNoteEditor.ts +104 -53
  8. package/src/BlockNoteExtensions.ts +24 -14
  9. package/src/api/blockManipulation/blockManipulation.test.ts +6 -3
  10. package/src/api/blockManipulation/blockManipulation.ts +7 -6
  11. package/src/api/formatConversions/formatConversions.test.ts +13 -8
  12. package/src/api/formatConversions/formatConversions.ts +15 -12
  13. package/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap +292 -0
  14. package/src/api/nodeConversions/nodeConversions.test.ts +265 -10
  15. package/src/api/nodeConversions/nodeConversions.ts +199 -47
  16. package/src/api/nodeConversions/testUtil.ts +8 -4
  17. package/src/editor.module.css +5 -6
  18. package/src/extensions/Blocks/api/block.ts +229 -0
  19. package/src/extensions/Blocks/api/blockTypes.ts +158 -71
  20. package/src/extensions/Blocks/api/cursorPositionTypes.ts +5 -5
  21. package/src/extensions/Blocks/api/defaultBlocks.ts +44 -0
  22. package/src/extensions/Blocks/api/selectionTypes.ts +3 -3
  23. package/src/extensions/Blocks/api/serialization.ts +29 -0
  24. package/src/extensions/Blocks/index.ts +0 -8
  25. package/src/extensions/Blocks/nodes/Block.module.css +28 -16
  26. package/src/extensions/Blocks/nodes/BlockContainer.ts +8 -4
  27. package/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts +4 -4
  28. package/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +5 -5
  29. package/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +100 -97
  30. package/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts +4 -4
  31. package/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts +11 -9
  32. package/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts +6 -5
  33. package/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts +57 -14
  34. package/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts +21 -16
  35. package/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts +9 -5
  36. package/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +38 -58
  37. package/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts +19 -0
  38. package/src/extensions/Placeholder/PlaceholderExtension.ts +1 -0
  39. package/src/extensions/SlashMenu/BaseSlashMenuItem.ts +5 -2
  40. package/src/extensions/SlashMenu/SlashMenuExtension.ts +37 -33
  41. package/src/extensions/SlashMenu/defaultSlashMenuItems.tsx +14 -10
  42. package/src/extensions/SlashMenu/index.ts +2 -2
  43. package/src/index.ts +4 -0
  44. package/src/shared/plugins/suggestion/SuggestionPlugin.ts +29 -13
  45. package/types/src/BlockNoteEditor.d.ts +38 -23
  46. package/types/src/BlockNoteExtensions.d.ts +15 -8
  47. package/types/src/api/blockManipulation/blockManipulation.d.ts +4 -4
  48. package/types/src/api/formatConversions/formatConversions.d.ts +5 -5
  49. package/types/src/api/nodeConversions/nodeConversions.d.ts +3 -3
  50. package/types/src/api/nodeConversions/testUtil.d.ts +2 -2
  51. package/types/src/extensions/Blocks/api/block.d.ts +2 -4
  52. package/types/src/extensions/Blocks/api/blockTypes.d.ts +77 -33
  53. package/types/src/extensions/Blocks/api/cursorPositionTypes.d.ts +5 -5
  54. package/types/src/extensions/Blocks/api/defaultBlocks.d.ts +4 -4
  55. package/types/src/extensions/Blocks/api/selectionTypes.d.ts +3 -3
  56. package/types/src/extensions/Blocks/api/serialization.d.ts +2 -0
  57. package/types/src/extensions/Blocks/nodes/BlockContainer.d.ts +3 -3
  58. package/types/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.d.ts +1 -2
  59. package/types/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.d.ts +1 -2
  60. package/types/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.d.ts +1 -2
  61. package/types/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.d.ts +1 -2
  62. package/types/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.d.ts +7 -7
  63. package/types/src/extensions/DraggableBlocks/DraggableBlocksExtension.d.ts +5 -4
  64. package/types/src/extensions/DraggableBlocks/DraggableBlocksPlugin.d.ts +12 -11
  65. package/types/src/extensions/FormattingToolbar/FormattingToolbarExtension.d.ts +6 -5
  66. package/types/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.d.ts +4 -3
  67. package/types/src/extensions/FormattingToolbar/FormattingToolbarPlugin.d.ts +16 -19
  68. package/types/src/extensions/Placeholder/localisation/index.d.ts +2 -0
  69. package/types/src/extensions/Placeholder/localisation/translation.d.ts +51 -0
  70. package/types/src/extensions/SlashMenu/BaseSlashMenuItem.d.ts +4 -3
  71. package/types/src/extensions/SlashMenu/SlashMenuExtension.d.ts +5 -4
  72. package/types/src/extensions/SlashMenu/defaultSlashMenuItems.d.ts +66 -1
  73. package/types/src/extensions/SlashMenu/index.d.ts +2 -2
  74. package/types/src/index.d.ts +4 -0
  75. package/types/src/shared/plugins/suggestion/SuggestionPlugin.d.ts +5 -4
  76. package/types/src/extensions/Blocks/api/alertBlock.d.ts +0 -13
  77. package/types/src/extensions/Blocks/api/alertBlock2.d.ts +0 -13
@@ -2,13 +2,14 @@ import { Mark } from "@tiptap/pm/model";
2
2
  import { Node, Schema } from "prosemirror-model";
3
3
  import {
4
4
  Block,
5
- blockProps,
5
+ BlockSchema,
6
6
  PartialBlock,
7
7
  } from "../../extensions/Blocks/api/blockTypes";
8
+
9
+ import { defaultProps } from "../../extensions/Blocks/api/defaultBlocks";
8
10
  import {
9
11
  ColorStyle,
10
12
  InlineContent,
11
- Link,
12
13
  PartialInlineContent,
13
14
  PartialLink,
14
15
  StyledText,
@@ -32,7 +33,7 @@ const colorStyles = new Set<ColorStyle>(["textColor", "backgroundColor"]);
32
33
  * Convert a StyledText inline element to a
33
34
  * prosemirror text node with the appropriate marks
34
35
  */
35
- function styledTextToNode(styledText: StyledText, schema: Schema): Node {
36
+ function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
36
37
  const marks: Mark[] = [];
37
38
 
38
39
  for (const [style, value] of Object.entries(styledText.styles)) {
@@ -43,7 +44,22 @@ function styledTextToNode(styledText: StyledText, schema: Schema): Node {
43
44
  }
44
45
  }
45
46
 
46
- return schema.text(styledText.text, marks);
47
+ return (
48
+ styledText.text
49
+ // Splits text & line breaks.
50
+ .split(/(\n)/g)
51
+ // If the content ends with a line break, an empty string is added to the
52
+ // end, which this removes.
53
+ .filter((text) => text.length > 0)
54
+ // Converts text & line breaks to nodes.
55
+ .map((text) => {
56
+ if (text === "\n") {
57
+ return schema.nodes["hardBreak"].create();
58
+ } else {
59
+ return schema.text(text, marks);
60
+ }
61
+ })
62
+ );
47
63
  }
48
64
 
49
65
  /**
@@ -56,7 +72,14 @@ function linkToNodes(link: PartialLink, schema: Schema): Node[] {
56
72
  });
57
73
 
58
74
  return styledTextArrayToNodes(link.content, schema).map((node) => {
59
- return node.mark([...node.marks, linkMark]);
75
+ if (node.type.name === "text") {
76
+ return node.mark([...node.marks, linkMark]);
77
+ }
78
+
79
+ if (node.type.name === "hardBreak") {
80
+ return node;
81
+ }
82
+ throw new Error("unexpected node type");
60
83
  });
61
84
  }
62
85
 
@@ -71,12 +94,14 @@ function styledTextArrayToNodes(
71
94
  let nodes: Node[] = [];
72
95
 
73
96
  if (typeof content === "string") {
74
- nodes.push(schema.text(content));
97
+ nodes.push(
98
+ ...styledTextToNodes({ type: "text", text: content, styles: {} }, schema)
99
+ );
75
100
  return nodes;
76
101
  }
77
102
 
78
103
  for (const styledText of content) {
79
- nodes.push(styledTextToNode(styledText, schema));
104
+ nodes.push(...styledTextToNodes(styledText, schema));
80
105
  }
81
106
  return nodes;
82
107
  }
@@ -105,7 +130,10 @@ export function inlineContentToNodes(
105
130
  /**
106
131
  * Converts a BlockNote block to a TipTap node.
107
132
  */
108
- export function blockToNode(block: PartialBlock, schema: Schema) {
133
+ export function blockToNode<BSchema extends BlockSchema>(
134
+ block: PartialBlock<BSchema>,
135
+ schema: Schema
136
+ ) {
109
137
  let id = block.id;
110
138
 
111
139
  if (id === undefined) {
@@ -156,15 +184,39 @@ export function blockToNode(block: PartialBlock, schema: Schema) {
156
184
  */
157
185
  function contentNodeToInlineContent(contentNode: Node) {
158
186
  const content: InlineContent[] = [];
159
-
160
- let currentLink: Link | undefined = undefined;
187
+ let currentContent: InlineContent | undefined = undefined;
161
188
 
162
189
  // Most of the logic below is for handling links because in ProseMirror links are marks
163
190
  // while in BlockNote links are a type of inline content
164
191
  contentNode.content.forEach((node) => {
165
- const styles: Styles = {};
192
+ // hardBreak nodes do not have an InlineContent equivalent, instead we
193
+ // add a newline to the previous node.
194
+ if (node.type.name === "hardBreak") {
195
+ if (currentContent) {
196
+ // Current content exists.
197
+ if (currentContent.type === "text") {
198
+ // Current content is text.
199
+ currentContent.text += "\n";
200
+ } else if (currentContent.type === "link") {
201
+ // Current content is a link.
202
+ currentContent.content[currentContent.content.length - 1].text +=
203
+ "\n";
204
+ }
205
+ } else {
206
+ // Current content does not exist.
207
+ currentContent = {
208
+ type: "text",
209
+ text: "\n",
210
+ styles: {},
211
+ };
212
+ }
213
+
214
+ return;
215
+ }
166
216
 
217
+ const styles: Styles = {};
167
218
  let linkMark: Mark | undefined;
219
+
168
220
  for (const mark of node.marks) {
169
221
  if (mark.type.name === "link") {
170
222
  linkMark = mark;
@@ -177,47 +229,132 @@ function contentNodeToInlineContent(contentNode: Node) {
177
229
  }
178
230
  }
179
231
 
180
- if (linkMark && currentLink && linkMark.attrs.href === currentLink.href) {
181
- // if the node is a link that matches the current link, add it to the current link
182
- currentLink.content.push({
183
- type: "text",
184
- text: node.textContent,
185
- styles,
186
- });
187
- } else if (linkMark) {
188
- // if the node is a link that doesn't match the current link, create a new link
189
- currentLink = {
190
- type: "link",
191
- href: linkMark.attrs.href,
192
- content: [
193
- {
232
+ // Parsing links and text.
233
+ // Current content exists.
234
+ if (currentContent) {
235
+ // Current content is text.
236
+ if (currentContent.type === "text") {
237
+ if (!linkMark) {
238
+ // Node is text (same type as current content).
239
+ if (
240
+ JSON.stringify(currentContent.styles) === JSON.stringify(styles)
241
+ ) {
242
+ // Styles are the same.
243
+ currentContent.text += node.textContent;
244
+ } else {
245
+ // Styles are different.
246
+ content.push(currentContent);
247
+ currentContent = {
248
+ type: "text",
249
+ text: node.textContent,
250
+ styles,
251
+ };
252
+ }
253
+ } else {
254
+ // Node is a link (different type to current content).
255
+ content.push(currentContent);
256
+ currentContent = {
257
+ type: "link",
258
+ href: linkMark.attrs.href,
259
+ content: [
260
+ {
261
+ type: "text",
262
+ text: node.textContent,
263
+ styles,
264
+ },
265
+ ],
266
+ };
267
+ }
268
+ } else if (currentContent.type === "link") {
269
+ // Current content is a link.
270
+ if (linkMark) {
271
+ // Node is a link (same type as current content).
272
+ // Link URLs are the same.
273
+ if (currentContent.href === linkMark.attrs.href) {
274
+ // Styles are the same.
275
+ if (
276
+ JSON.stringify(
277
+ currentContent.content[currentContent.content.length - 1].styles
278
+ ) === JSON.stringify(styles)
279
+ ) {
280
+ currentContent.content[currentContent.content.length - 1].text +=
281
+ node.textContent;
282
+ } else {
283
+ // Styles are different.
284
+ currentContent.content.push({
285
+ type: "text",
286
+ text: node.textContent,
287
+ styles,
288
+ });
289
+ }
290
+ } else {
291
+ // Link URLs are different.
292
+ content.push(currentContent);
293
+ currentContent = {
294
+ type: "link",
295
+ href: linkMark.attrs.href,
296
+ content: [
297
+ {
298
+ type: "text",
299
+ text: node.textContent,
300
+ styles,
301
+ },
302
+ ],
303
+ };
304
+ }
305
+ } else {
306
+ // Node is text (different type to current content).
307
+ content.push(currentContent);
308
+ currentContent = {
194
309
  type: "text",
195
310
  text: node.textContent,
196
311
  styles,
197
- },
198
- ],
199
- };
200
- content.push(currentLink);
201
- } else {
202
- // if the node is not a link, add it to the content
203
- content.push({
204
- type: "text",
205
- text: node.textContent,
206
- styles,
207
- });
208
- currentLink = undefined;
312
+ };
313
+ }
314
+ }
315
+ }
316
+ // Current content does not exist.
317
+ else {
318
+ // Node is text.
319
+ if (!linkMark) {
320
+ currentContent = {
321
+ type: "text",
322
+ text: node.textContent,
323
+ styles,
324
+ };
325
+ }
326
+ // Node is a link.
327
+ else {
328
+ currentContent = {
329
+ type: "link",
330
+ href: linkMark.attrs.href,
331
+ content: [
332
+ {
333
+ type: "text",
334
+ text: node.textContent,
335
+ styles,
336
+ },
337
+ ],
338
+ };
339
+ }
209
340
  }
210
341
  });
342
+
343
+ if (currentContent) {
344
+ content.push(currentContent);
345
+ }
346
+
211
347
  return content;
212
348
  }
213
349
 
214
350
  /**
215
351
  * Convert a TipTap node to a BlockNote block.
216
352
  */
217
- export function nodeToBlock(
353
+ export function nodeToBlock<BSchema extends BlockSchema>(
218
354
  node: Node,
219
- blockCache?: WeakMap<Node, Block>
220
- ): Block {
355
+ blockSchema: BSchema,
356
+ blockCache?: WeakMap<Node, Block<BSchema>>
357
+ ): Block<BSchema> {
221
358
  if (node.type.name !== "blockContainer") {
222
359
  throw Error(
223
360
  "Node must be of type blockContainer, but is of type" +
@@ -246,29 +383,44 @@ export function nodeToBlock(
246
383
  ...blockInfo.node.attrs,
247
384
  ...blockInfo.contentNode.attrs,
248
385
  })) {
249
- if (!(blockInfo.contentType.name in blockProps)) {
386
+ const blockSpec = blockSchema[blockInfo.contentType.name];
387
+ if (!blockSpec) {
250
388
  throw Error(
251
389
  "Block is of an unrecognized type: " + blockInfo.contentType.name
252
390
  );
253
391
  }
254
392
 
255
- const validAttrs = blockProps[blockInfo.contentType.name as Block["type"]];
393
+ const propSchema = blockSpec.propSchema;
256
394
 
257
- if (validAttrs.has(attr)) {
395
+ if (attr in propSchema) {
258
396
  props[attr] = value;
259
397
  }
398
+ // Block ids are stored as node attributes the same way props are, so we
399
+ // need to ensure we don't attempt to read block ids as props.
400
+
401
+ // the second check is for the backgroundColor & textColor props.
402
+ // Since we want them to be inherited by child blocks, we can't put them on the blockContent node,
403
+ // and instead have to put them on the blockContainer node.
404
+ // The blockContainer node is the same for all block types, but some custom blocks might not use backgroundColor & textColor,
405
+ // so these 2 props are technically unexpected but we shouldn't log a warning.
406
+ // (this is a bit hacky)
407
+ else if (attr !== "id" && !(attr in defaultProps)) {
408
+ console.warn("Block has an unrecognized attribute: " + attr);
409
+ }
260
410
  }
261
411
 
262
412
  const content = contentNodeToInlineContent(blockInfo.contentNode);
263
413
 
264
- const children: Block[] = [];
414
+ const children: Block<BSchema>[] = [];
265
415
  for (let i = 0; i < blockInfo.numChildBlocks; i++) {
266
- children.push(nodeToBlock(blockInfo.node.lastChild!.child(i)));
416
+ children.push(
417
+ nodeToBlock(blockInfo.node.lastChild!.child(i), blockSchema, blockCache)
418
+ );
267
419
  }
268
420
 
269
- const block: Block = {
421
+ const block: Block<BSchema> = {
270
422
  id,
271
- type: blockInfo.contentType.name as Block["type"],
423
+ type: blockInfo.contentType.name,
272
424
  props,
273
425
  content,
274
426
  children,
@@ -1,4 +1,8 @@
1
- import { Block, PartialBlock } from "../../extensions/Blocks/api/blockTypes";
1
+ import {
2
+ Block,
3
+ BlockSchema,
4
+ PartialBlock,
5
+ } from "../../extensions/Blocks/api/blockTypes";
2
6
  import {
3
7
  InlineContent,
4
8
  PartialInlineContent,
@@ -39,9 +43,9 @@ function partialContentToInlineContent(
39
43
  });
40
44
  }
41
45
 
42
- export function partialBlockToBlockForTesting(
43
- partialBlock: PartialBlock
44
- ): Block {
46
+ export function partialBlockToBlockForTesting<BSchema extends BlockSchema>(
47
+ partialBlock: PartialBlock<BSchema>
48
+ ): Block<BSchema> {
45
49
  const withDefaults = {
46
50
  id: "",
47
51
  type: "paragraph" as any,
@@ -2,7 +2,7 @@
2
2
 
3
3
  .bnEditor {
4
4
  outline: none;
5
- padding-inline: 50px;
5
+ padding-inline: 54px;
6
6
  border-radius: 8px;
7
7
 
8
8
  /* Define a set of colors to be used throughout the app for consistency
@@ -38,7 +38,6 @@ Tippy popups that are appended to document.body directly
38
38
  .defaultStyles h3,
39
39
  .defaultStyles li {
40
40
  all: unset !important;
41
- flex-grow: 1 !important;
42
41
  margin: 0;
43
42
  padding: 0;
44
43
  font-size: inherit;
@@ -57,13 +56,13 @@ Tippy popups that are appended to document.body directly
57
56
  }
58
57
 
59
58
  [data-theme="light"] {
60
- background-color: #ffffff;
61
- color: #444444;
59
+ background-color: #FFFFFF;
60
+ color: #3F3F3F;
62
61
  }
63
62
 
64
63
  [data-theme="dark"] {
65
- background-color: #444444;
66
- color: #dddddd;
64
+ background-color: #1F1F1F;
65
+ color: #CFCFCF;
67
66
  }
68
67
 
69
68
  .dragPreview {
@@ -0,0 +1,229 @@
1
+ import { Attribute, Node } from "@tiptap/core";
2
+ import { BlockNoteEditor } from "../../..";
3
+ import styles from "../nodes/Block.module.css";
4
+ import {
5
+ BlockConfig,
6
+ BlockSchema,
7
+ BlockSpec,
8
+ PropSchema,
9
+ TipTapNode,
10
+ TipTapNodeConfig,
11
+ } from "./blockTypes";
12
+
13
+ export function camelToDataKebab(str: string): string {
14
+ return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
15
+ }
16
+
17
+ // Function that uses the 'propSchema' of a blockConfig to create a TipTap
18
+ // node's `addAttributes` property.
19
+ export function propsToAttributes<
20
+ BType extends string,
21
+ PSchema extends PropSchema,
22
+ ContainsInlineContent extends boolean,
23
+ BSchema extends BlockSchema
24
+ >(
25
+ blockConfig: Omit<
26
+ BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>,
27
+ "render"
28
+ >
29
+ ) {
30
+ const tiptapAttributes: Record<string, Attribute> = {};
31
+
32
+ Object.entries(blockConfig.propSchema).forEach(([name, spec]) => {
33
+ tiptapAttributes[name] = {
34
+ default: spec.default,
35
+ keepOnSplit: true,
36
+ // Props are displayed in kebab-case as HTML attributes. If a prop's
37
+ // value is the same as its default, we don't display an HTML
38
+ // attribute for it.
39
+ parseHTML: (element) => element.getAttribute(camelToDataKebab(name)),
40
+ renderHTML: (attributes) =>
41
+ attributes[name] !== spec.default
42
+ ? {
43
+ [camelToDataKebab(name)]: attributes[name],
44
+ }
45
+ : {},
46
+ };
47
+ });
48
+
49
+ return tiptapAttributes;
50
+ }
51
+
52
+ // Function that uses the 'parse' function of a blockConfig to create a
53
+ // TipTap node's `parseHTML` property. This is only used for parsing content
54
+ // from the clipboard.
55
+ export function parse<
56
+ BType extends string,
57
+ PSchema extends PropSchema,
58
+ ContainsInlineContent extends boolean,
59
+ BSchema extends BlockSchema
60
+ >(
61
+ blockConfig: Omit<
62
+ BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>,
63
+ "render"
64
+ >
65
+ ) {
66
+ return [
67
+ {
68
+ tag: "div[data-content-type=" + blockConfig.type + "]",
69
+ },
70
+ ];
71
+ }
72
+
73
+ // Function that uses the 'render' function of a blockConfig to create a
74
+ // TipTap node's `renderHTML` property. Since custom blocks use node views,
75
+ // this is only used for serializing content to the clipboard.
76
+ export function render<
77
+ BType extends string,
78
+ PSchema extends PropSchema,
79
+ ContainsInlineContent extends boolean,
80
+ BSchema extends BlockSchema
81
+ >(
82
+ blockConfig: Omit<
83
+ BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>,
84
+ "render"
85
+ >,
86
+ HTMLAttributes: Record<string, any>
87
+ ) {
88
+ // Create blockContent element
89
+ const blockContent = document.createElement("div");
90
+ // Add blockContent HTML attribute
91
+ blockContent.setAttribute("data-content-type", blockConfig.type);
92
+ // Add props as HTML attributes in kebab-case with "data-" prefix
93
+ for (const [attribute, value] of Object.entries(HTMLAttributes)) {
94
+ blockContent.setAttribute(attribute, value);
95
+ }
96
+
97
+ // TODO: This only works for content copied within BlockNote.
98
+ // Creates contentDOM element to serialize inline content into.
99
+ let contentDOM: HTMLDivElement | undefined;
100
+ if (blockConfig.containsInlineContent) {
101
+ contentDOM = document.createElement("div");
102
+ blockContent.appendChild(contentDOM);
103
+ } else {
104
+ contentDOM = undefined;
105
+ }
106
+
107
+ return contentDOM !== undefined
108
+ ? {
109
+ dom: blockContent,
110
+ contentDOM: contentDOM,
111
+ }
112
+ : {
113
+ dom: blockContent,
114
+ };
115
+ }
116
+
117
+ // A function to create custom block for API consumers
118
+ // we want to hide the tiptap node from API consumers and provide a simpler API surface instead
119
+ export function createBlockSpec<
120
+ BType extends string,
121
+ PSchema extends PropSchema,
122
+ ContainsInlineContent extends boolean,
123
+ BSchema extends BlockSchema
124
+ >(
125
+ blockConfig: BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>
126
+ ): BlockSpec<BType, PSchema> {
127
+ const node = createTipTapBlock<BType>({
128
+ name: blockConfig.type,
129
+ content: blockConfig.containsInlineContent ? "inline*" : "",
130
+ selectable: blockConfig.containsInlineContent,
131
+
132
+ addOptions() {
133
+ return {
134
+ editor: undefined,
135
+ };
136
+ },
137
+
138
+ addAttributes() {
139
+ return propsToAttributes(blockConfig);
140
+ },
141
+
142
+ parseHTML() {
143
+ return parse(blockConfig);
144
+ },
145
+
146
+ renderHTML({ HTMLAttributes }) {
147
+ return render(blockConfig, HTMLAttributes);
148
+ },
149
+
150
+ addNodeView() {
151
+ return ({ HTMLAttributes, getPos }) => {
152
+ // Create blockContent element
153
+ const blockContent = document.createElement("div");
154
+ // Sets blockContent class
155
+ blockContent.className = styles.blockContent;
156
+ // Add blockContent HTML attribute
157
+ blockContent.setAttribute("data-content-type", blockConfig.type);
158
+ // Add props as HTML attributes in kebab-case with "data-" prefix
159
+ for (const [attribute, value] of Object.entries(HTMLAttributes)) {
160
+ blockContent.setAttribute(attribute, value);
161
+ }
162
+
163
+ // Gets BlockNote editor instance
164
+ const editor = this.options.editor! as BlockNoteEditor<
165
+ BSchema & { [k in BType]: BlockSpec<BType, PSchema> }
166
+ >;
167
+ // Gets position of the node
168
+ if (typeof getPos === "boolean") {
169
+ throw new Error(
170
+ "Cannot find node position as getPos is a boolean, not a function."
171
+ );
172
+ }
173
+ const pos = getPos();
174
+ // Gets TipTap editor instance
175
+ const tipTapEditor = editor._tiptapEditor;
176
+ // Gets parent blockContainer node
177
+ const blockContainer = tipTapEditor.state.doc.resolve(pos!).node();
178
+ // Gets block identifier
179
+ const blockIdentifier = blockContainer.attrs.id;
180
+
181
+ // Get the block
182
+ const block = editor.getBlock(blockIdentifier)!;
183
+ if (block.type !== blockConfig.type) {
184
+ throw new Error("Block type does not match");
185
+ }
186
+
187
+ // Render elements
188
+ const rendered = blockConfig.render(block as any, editor);
189
+ // Add inlineContent class to inline content
190
+ if ("contentDOM" in rendered) {
191
+ rendered.contentDOM.className = `${
192
+ rendered.contentDOM.className
193
+ ? rendered.contentDOM.className + " "
194
+ : ""
195
+ }${styles.inlineContent}`;
196
+ }
197
+ // Add elements to blockContent
198
+ blockContent.appendChild(rendered.dom);
199
+
200
+ return "contentDOM" in rendered
201
+ ? {
202
+ dom: blockContent,
203
+ contentDOM: rendered.contentDOM,
204
+ }
205
+ : {
206
+ dom: blockContent,
207
+ };
208
+ };
209
+ },
210
+ });
211
+
212
+ return {
213
+ node: node,
214
+ propSchema: blockConfig.propSchema,
215
+ };
216
+ }
217
+
218
+ export function createTipTapBlock<Type extends string>(
219
+ config: TipTapNodeConfig<Type>
220
+ ): TipTapNode<Type> {
221
+ // Type cast is needed as Node.name is mutable, though there is basically no
222
+ // reason to change it after creation. Alternative is to wrap Node in a new
223
+ // class, which I don't think is worth it since we'd only be changing 1
224
+ // attribute to be read only.
225
+ return Node.create({
226
+ ...config,
227
+ group: "blockContent",
228
+ }) as TipTapNode<Type>;
229
+ }