\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html
new file mode 100644
index 0000000000..efec8f89d3
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html
@@ -0,0 +1 @@
+
Heading 2
Paragraph
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html
new file mode 100644
index 0000000000..1930c65a95
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html
@@ -0,0 +1 @@
+
Hello World
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html
new file mode 100644
index 0000000000..46cfe14e45
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html
@@ -0,0 +1 @@
+
Custom Paragraph
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html
new file mode 100644
index 0000000000..d1017bf473
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html
@@ -0,0 +1 @@
+
Hello World
Hello World
Hello World
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html
new file mode 100644
index 0000000000..7688d5c7c0
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html
@@ -0,0 +1 @@
+
Custom Paragraph
Nested Custom Paragraph 1
Nested Custom Paragraph 2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html
new file mode 100644
index 0000000000..1930c65a95
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html
@@ -0,0 +1 @@
+
Hello World
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html
new file mode 100644
index 0000000000..aec9bca191
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html
@@ -0,0 +1 @@
+
Plain Red Text Blue Background Mixed Colors
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html
new file mode 100644
index 0000000000..bc3cb38f5c
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html
@@ -0,0 +1 @@
+
This is text with a custom fontSize
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html
new file mode 100644
index 0000000000..717b1ad7d4
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html
@@ -0,0 +1 @@
+
This is text with a custom fontSize
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html
new file mode 100644
index 0000000000..d9af93c752
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html
@@ -0,0 +1 @@
+
Text1 Text2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html
new file mode 100644
index 0000000000..a88858f652
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html
@@ -0,0 +1 @@
+
Text1 Text2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html
new file mode 100644
index 0000000000..bb3c90b25c
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html
new file mode 100644
index 0000000000..f710f08741
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html
new file mode 100644
index 0000000000..755d65be05
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html
@@ -0,0 +1 @@
+
Text1
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html
new file mode 100644
index 0000000000..d441ef69af
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html
@@ -0,0 +1 @@
+
Text1
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html
new file mode 100644
index 0000000000..70d35a5d8c
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html
new file mode 100644
index 0000000000..eb0b99808d
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html
new file mode 100644
index 0000000000..db553727c0
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html
@@ -0,0 +1 @@
+
Text1 Text2 Text3
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html
new file mode 100644
index 0000000000..5ae6ac8b30
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html
@@ -0,0 +1 @@
+
Text1 Text2 Text3
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html
new file mode 100644
index 0000000000..82093bacd3
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html
new file mode 100644
index 0000000000..c78443c0ac
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html
new file mode 100644
index 0000000000..550b2b88d2
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html
@@ -0,0 +1 @@
+
Text1
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html
new file mode 100644
index 0000000000..436596e499
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html
@@ -0,0 +1 @@
+
Text1
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html
new file mode 100644
index 0000000000..193b4d61aa
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html
@@ -0,0 +1 @@
+
Text1 Text2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html
new file mode 100644
index 0000000000..f08d9c579f
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html
@@ -0,0 +1 @@
+
Text1 Text2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html
new file mode 100644
index 0000000000..f214a9a441
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html
@@ -0,0 +1 @@
+Caption
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html
new file mode 100644
index 0000000000..080ccf3ce4
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html
new file mode 100644
index 0000000000..de77120ebf
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html
@@ -0,0 +1 @@
+
Add Image
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html
new file mode 100644
index 0000000000..39de1869c4
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html
new file mode 100644
index 0000000000..1a4a0986a2
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html
@@ -0,0 +1 @@
+CaptionCaption
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html
new file mode 100644
index 0000000000..5c81aa0f04
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html
new file mode 100644
index 0000000000..8876f46341
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html
new file mode 100644
index 0000000000..e11c631cac
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html
new file mode 100644
index 0000000000..1b68f7c926
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html
new file mode 100644
index 0000000000..5d7d50c2bc
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html
new file mode 100644
index 0000000000..36a369a5e4
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html
new file mode 100644
index 0000000000..84e54b7e4a
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html
new file mode 100644
index 0000000000..ac270828c3
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html
@@ -0,0 +1 @@
+
I enjoy working with @Matthew
diff --git a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html
new file mode 100644
index 0000000000..fa3e3e8414
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html
@@ -0,0 +1 @@
+
I enjoy working with @Matthew
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html
new file mode 100644
index 0000000000..76bbb30e4d
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html
@@ -0,0 +1 @@
+
Paragraph
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html
new file mode 100644
index 0000000000..7a7fe019c2
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html
@@ -0,0 +1 @@
+
Paragraph
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html
new file mode 100644
index 0000000000..c659260f6e
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html
new file mode 100644
index 0000000000..96547312cd
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html
new file mode 100644
index 0000000000..9dc893acc1
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html
@@ -0,0 +1 @@
+
Paragraph
Nested Paragraph 1
Nested Paragraph 2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html
new file mode 100644
index 0000000000..79557fb3a3
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html
@@ -0,0 +1 @@
+
Paragraph
Nested Paragraph 1
Nested Paragraph 2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html
new file mode 100644
index 0000000000..49d98e41d3
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html
@@ -0,0 +1 @@
+
Plain Red Text Blue Background Mixed Colors
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html
new file mode 100644
index 0000000000..fa01c74894
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html
new file mode 100644
index 0000000000..d8f1f1cf02
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html
@@ -0,0 +1 @@
+
Custom Paragraph
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html
new file mode 100644
index 0000000000..8ca20343ba
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html
@@ -0,0 +1 @@
+
Custom Paragraph
Nested Custom Paragraph 1
Nested Custom Paragraph 2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html
new file mode 100644
index 0000000000..8dfb4fdd8a
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html
@@ -0,0 +1 @@
+
Custom Paragraph
Nested Custom Paragraph 1
Nested Custom Paragraph 2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html
new file mode 100644
index 0000000000..684688bb9d
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html
@@ -0,0 +1 @@
+
Plain Red Text Blue Background Mixed Colors
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html
new file mode 100644
index 0000000000..c0922dcb84
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html
@@ -0,0 +1 @@
+
Plain Red Text Blue Background Mixed Colors
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html
new file mode 100644
index 0000000000..b9aa7c2551
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html
new file mode 100644
index 0000000000..305da277ef
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html
new file mode 100644
index 0000000000..68a66d027a
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html
new file mode 100644
index 0000000000..07a332a5f4
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html
new file mode 100644
index 0000000000..c1cd943c29
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html
new file mode 100644
index 0000000000..114116544a
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html
new file mode 100644
index 0000000000..50ef98b2ce
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html
@@ -0,0 +1 @@
+
This is a small text
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html
new file mode 100644
index 0000000000..2c4b0446df
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html
@@ -0,0 +1 @@
+
This is a small text
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html
new file mode 100644
index 0000000000..c243b63eb2
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html
@@ -0,0 +1 @@
+
I love #BlockNote
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html
new file mode 100644
index 0000000000..dcb80c2f33
--- /dev/null
+++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html
@@ -0,0 +1 @@
+
I love #BlockNote
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
new file mode 100644
index 0000000000..43b591b610
--- /dev/null
+++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
@@ -0,0 +1,98 @@
+import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model";
+import rehypeParse from "rehype-parse";
+import rehypeStringify from "rehype-stringify";
+import { unified } from "unified";
+
+import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
+import {
+ BlockSchema,
+ InlineContentSchema,
+ PartialBlock,
+ StyleSchema,
+} from "../../../schema";
+import { blockToNode } from "../../nodeConversions/nodeConversions";
+import {
+ serializeNodeInner,
+ serializeProseMirrorFragment,
+} from "./util/sharedHTMLConversion";
+import { simplifyBlocks } from "./util/simplifyBlocksRehypePlugin";
+
+// Used to export BlockNote blocks and ProseMirror nodes to HTML for use outside
+// the editor. Blocks are exported using the `toExternalHTML` method in their
+// `blockSpec`, or `toInternalHTML` if `toExternalHTML` is not defined.
+//
+// The HTML created by this serializer is different to what's rendered by the
+// editor to the DOM. This also means that data is likely to be lost when
+// converting back to original blocks. The differences in the output HTML are:
+// 1. It doesn't include the `blockGroup` and `blockContainer` wrappers meaning
+// that nesting is not preserved for non-list-item blocks.
+// 2. `li` items in the output HTML are wrapped in `ul` or `ol` elements.
+// 3. While nesting for list items is preserved, other types of blocks nested
+// inside a list are un-nested and a new list is created after them.
+// 4. The HTML is wrapped in a single `div` element.
+//
+// The serializer has 2 main methods:
+// `exportBlocks`: Exports an array of blocks to HTML.
+// `exportFragment`: Exports a ProseMirror fragment to HTML. This is mostly
+// useful if you want to export a selection which may not start/end at the
+// start/end of a block.
+export interface ExternalHTMLExporter<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+> {
+ exportBlocks: (blocks: PartialBlock[]) => string;
+ exportProseMirrorFragment: (fragment: Fragment) => string;
+}
+
+export const createExternalHTMLExporter = <
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ schema: Schema,
+ editor: BlockNoteEditor
+): ExternalHTMLExporter => {
+ const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & {
+ serializeNodeInner: (
+ node: Node,
+ options: { document?: Document }
+ ) => HTMLElement;
+ // TODO: Should not be async, but is since we're using a rehype plugin to
+ // convert internal HTML to external HTML.
+ exportProseMirrorFragment: (fragment: Fragment) => string;
+ exportBlocks: (blocks: PartialBlock[]) => string;
+ };
+
+ serializer.serializeNodeInner = (
+ node: Node,
+ options: { document?: Document }
+ ) => serializeNodeInner(node, options, serializer, editor, true);
+
+ // Like the `internalHTMLSerializer`, also uses `serializeProseMirrorFragment`
+ // but additionally runs it through the `simplifyBlocks` rehype plugin to
+ // convert the internal HTML to external.
+ serializer.exportProseMirrorFragment = (fragment) => {
+ const externalHTML = unified()
+ .use(rehypeParse, { fragment: true })
+ .use(simplifyBlocks, {
+ orderedListItemBlockTypes: new Set(["numberedListItem"]),
+ unorderedListItemBlockTypes: new Set(["bulletListItem"]),
+ })
+ .use(rehypeStringify)
+ .processSync(serializeProseMirrorFragment(fragment, serializer));
+
+ return externalHTML.value as string;
+ };
+
+ serializer.exportBlocks = (blocks: PartialBlock[]) => {
+ const nodes = blocks.map((block) =>
+ blockToNode(block, schema, editor.styleSchema)
+ );
+ const blockGroup = schema.nodes["blockGroup"].create(null, nodes);
+
+ return serializer.exportProseMirrorFragment(Fragment.from(blockGroup));
+ };
+
+ return serializer;
+};
diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts
new file mode 100644
index 0000000000..6671037f19
--- /dev/null
+++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts
@@ -0,0 +1,100 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
+
+import { addIdsToBlocks, partialBlocksToBlocksForTesting } from "../../..";
+import { BlockSchema, PartialBlock } from "../../../schema/blocks/types";
+import { InlineContentSchema } from "../../../schema/inlineContent/types";
+import { StyleSchema } from "../../../schema/styles/types";
+import { customBlocksTestCases } from "../../testUtil/cases/customBlocks";
+import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent";
+import { customStylesTestCases } from "../../testUtil/cases/customStyles";
+import { defaultSchemaTestCases } from "../../testUtil/cases/defaultSchema";
+import { createExternalHTMLExporter } from "./externalHTMLExporter";
+import { createInternalHTMLSerializer } from "./internalHTMLSerializer";
+
+async function convertToHTMLAndCompareSnapshots<
+ B extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+ snapshotDirectory: string,
+ snapshotName: string
+) {
+ addIdsToBlocks(blocks);
+ const serializer = createInternalHTMLSerializer(
+ editor._tiptapEditor.schema,
+ editor
+ );
+ const internalHTML = serializer.serializeBlocks(blocks);
+ const internalHTMLSnapshotPath =
+ "./__snapshots__/" +
+ snapshotDirectory +
+ "/" +
+ snapshotName +
+ "/internal.html";
+ expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath);
+
+ // turn the internalHTML back into blocks, and make sure no data was lost
+ const fullBlocks = partialBlocksToBlocksForTesting(
+ editor.blockSchema,
+ blocks
+ );
+ const parsed = await editor.tryParseHTMLToBlocks(internalHTML);
+
+ expect(parsed).toStrictEqual(fullBlocks);
+
+ // Create the "external" HTML, which is a cleaned up HTML representation, but lossy
+ const exporter = createExternalHTMLExporter(
+ editor._tiptapEditor.schema,
+ editor
+ );
+ const externalHTML = exporter.exportBlocks(blocks);
+ const externalHTMLSnapshotPath =
+ "./__snapshots__/" +
+ snapshotDirectory +
+ "/" +
+ snapshotName +
+ "/external.html";
+ expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath);
+}
+
+const testCases = [
+ defaultSchemaTestCases,
+ customBlocksTestCases,
+ customStylesTestCases,
+ customInlineContentTestCases,
+];
+
+describe("Test HTML conversion", () => {
+ for (const testCase of testCases) {
+ describe("Case: " + testCase.name, () => {
+ let editor: BlockNoteEditor;
+
+ beforeEach(() => {
+ editor = testCase.createEditor();
+ });
+
+ afterEach(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+
+ delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+ });
+
+ for (const document of testCase.documents) {
+ // eslint-disable-next-line no-loop-func
+ it("Convert " + document.name + " to HTML", async () => {
+ const nameSplit = document.name.split("/");
+ await convertToHTMLAndCompareSnapshots(
+ editor,
+ document.blocks,
+ nameSplit[0],
+ nameSplit[1]
+ );
+ });
+ }
+ });
+ }
+});
diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
new file mode 100644
index 0000000000..a635819caa
--- /dev/null
+++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
@@ -0,0 +1,80 @@
+import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model";
+import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
+import {
+ BlockSchema,
+ InlineContentSchema,
+ PartialBlock,
+ StyleSchema,
+} from "../../../schema";
+import { blockToNode } from "../../nodeConversions/nodeConversions";
+import {
+ serializeNodeInner,
+ serializeProseMirrorFragment,
+} from "./util/sharedHTMLConversion";
+
+// Used to serialize BlockNote blocks and ProseMirror nodes to HTML without
+// losing data. Blocks are exported using the `toInternalHTML` method in their
+// `blockSpec`.
+//
+// The HTML created by this serializer is the same as what's rendered by the
+// editor to the DOM. This means that it retains the same structure as the
+// editor, including the `blockGroup` and `blockContainer` wrappers. This also
+// means that it can be converted back to the original blocks without any data
+// loss.
+//
+// The serializer has 2 main methods:
+// `serializeFragment`: Serializes a ProseMirror fragment to HTML. This is
+// mostly useful if you want to serialize a selection which may not start/end at
+// the start/end of a block.
+// `serializeBlocks`: Serializes an array of blocks to HTML.
+export interface InternalHTMLSerializer<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+> {
+ // TODO: Ideally we would expand the BlockNote API to support partial
+ // selections so we don't need this.
+ serializeProseMirrorFragment: (fragment: Fragment) => string;
+ serializeBlocks: (blocks: PartialBlock[]) => string;
+}
+
+export const createInternalHTMLSerializer = <
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ schema: Schema,
+ editor: BlockNoteEditor
+): InternalHTMLSerializer => {
+ const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & {
+ serializeNodeInner: (
+ node: Node,
+ options: { document?: Document }
+ ) => HTMLElement;
+ serializeBlocks: (blocks: PartialBlock[]) => string;
+ serializeProseMirrorFragment: (
+ fragment: Fragment,
+ options?: { document?: Document | undefined } | undefined,
+ target?: HTMLElement | DocumentFragment | undefined
+ ) => string;
+ };
+
+ serializer.serializeNodeInner = (
+ node: Node,
+ options: { document?: Document }
+ ) => serializeNodeInner(node, options, serializer, editor, false);
+
+ serializer.serializeProseMirrorFragment = (fragment: Fragment) =>
+ serializeProseMirrorFragment(fragment, serializer);
+
+ serializer.serializeBlocks = (blocks: PartialBlock[]) => {
+ const nodes = blocks.map((block) =>
+ blockToNode(block, schema, editor.styleSchema)
+ );
+ const blockGroup = schema.nodes["blockGroup"].create(null, nodes);
+
+ return serializer.serializeProseMirrorFragment(Fragment.from(blockGroup));
+ };
+
+ return serializer;
+};
diff --git a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts
new file mode 100644
index 0000000000..4c89a45ebe
--- /dev/null
+++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts
@@ -0,0 +1,128 @@
+import { DOMSerializer, Fragment, Node } from "prosemirror-model";
+
+import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor";
+import {
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../../../schema";
+import { nodeToBlock } from "../../../nodeConversions/nodeConversions";
+
+function doc(options: { document?: Document }) {
+ return options.document || window.document;
+}
+
+// Used to implement `serializeNodeInner` for the `internalHTMLSerializer` and
+// `externalHTMLExporter`. Changes how the content of `blockContainer` nodes is
+// serialized vs the default `DOMSerializer` implementation. For the
+// `blockContent` node, the `toInternalHTML` or `toExternalHTML` function of its
+// corresponding block is used for serialization instead of the node's
+// `renderHTML` method.
+export const serializeNodeInner = <
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ node: Node,
+ options: { document?: Document },
+ serializer: DOMSerializer,
+ editor: BlockNoteEditor,
+ toExternalHTML: boolean
+) => {
+ if (!serializer.nodes[node.type.name]) {
+ throw new Error("Serializer is missing a node type: " + node.type.name);
+ }
+ const { dom, contentDOM } = DOMSerializer.renderSpec(
+ doc(options),
+ serializer.nodes[node.type.name](node)
+ );
+
+ if (contentDOM) {
+ if (node.isLeaf) {
+ throw new RangeError("Content hole not allowed in a leaf node spec");
+ }
+
+ // Handles converting `blockContainer` nodes to HTML.
+ if (node.type.name === "blockContainer") {
+ const blockContentNode =
+ node.childCount > 0 &&
+ node.firstChild!.type.spec.group === "blockContent"
+ ? node.firstChild!
+ : undefined;
+ const blockGroupNode =
+ node.childCount > 0 && node.lastChild!.type.spec.group === "blockGroup"
+ ? node.lastChild!
+ : undefined;
+
+ // Converts `blockContent` node using the custom `blockSpec`'s
+ // `toExternalHTML` or `toInternalHTML` function.
+ // Note: While `blockContainer` nodes should always contain a
+ // `blockContent` node according to the schema, PM Fragments don't always
+ // conform to the schema. This is unintuitive but important as it occurs
+ // when copying only nested blocks.
+ if (blockContentNode !== undefined) {
+ const impl =
+ editor.blockImplementations[blockContentNode.type.name]
+ .implementation;
+ const toHTML = toExternalHTML
+ ? impl.toExternalHTML
+ : impl.toInternalHTML;
+ const blockContent = toHTML(
+ nodeToBlock(
+ node,
+ editor.blockSchema,
+ editor.inlineContentSchema,
+ editor.styleSchema,
+ editor.blockCache
+ ),
+ editor as any
+ );
+
+ // Converts inline nodes in the `blockContent` node's content to HTML
+ // using their `renderHTML` methods.
+ if (blockContent.contentDOM !== undefined) {
+ if (node.isLeaf) {
+ throw new RangeError(
+ "Content hole not allowed in a leaf node spec"
+ );
+ }
+
+ blockContent.contentDOM.appendChild(
+ serializer.serializeFragment(blockContentNode.content, options)
+ );
+ }
+
+ contentDOM.appendChild(blockContent.dom);
+ }
+
+ // Converts `blockGroup` node to HTML using its `renderHTML` method.
+ if (blockGroupNode !== undefined) {
+ serializer.serializeFragment(
+ Fragment.from(blockGroupNode),
+ options,
+ contentDOM
+ );
+ }
+ } else {
+ // Converts the node normally, i.e. using its `renderHTML method`.
+ serializer.serializeFragment(node.content, options, contentDOM);
+ }
+ }
+
+ return dom as HTMLElement;
+};
+
+// Used to implement `serializeProseMirrorFragment` for the
+// `internalHTMLSerializer` and `externalHTMLExporter`. Does basically the same
+// thing as `serializer.serializeFragment`, but takes fewer arguments and
+// returns a string instead, to make it easier to use.
+export const serializeProseMirrorFragment = (
+ fragment: Fragment,
+ serializer: DOMSerializer
+) => {
+ const internalHTML = serializer.serializeFragment(fragment);
+ const parent = document.createElement("div");
+ parent.appendChild(internalHTML);
+
+ return parent.innerHTML;
+};
diff --git a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts
similarity index 91%
rename from packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts
rename to packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts
index 13fa69e783..ba025dc5b0 100644
--- a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts
+++ b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts
@@ -22,6 +22,19 @@ export function simplifyBlocks(options: SimplifyBlocksOptions) {
]);
const simplifyBlocksHelper = (tree: HASTParent) => {
+ // Checks whether blocks in the tree are wrapped by a parent `blockGroup`
+ // element, in which case the `blockGroup`'s children are lifted out, and it
+ // is removed.
+ if (
+ tree.children.length === 1 &&
+ (tree.children[0] as HASTElement).properties?.["dataNodeType"] ===
+ "blockGroup"
+ ) {
+ const blockGroup = tree.children[0] as HASTElement;
+ tree.children.pop();
+ tree.children.push(...blockGroup.children);
+ }
+
let numChildElements = tree.children.length;
let activeList: HASTElement | undefined;
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/complex/misc/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/complex/misc/markdown.md
new file mode 100644
index 0000000000..c0e8ed3d9b
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/complex/misc/markdown.md
@@ -0,0 +1,5 @@
+## **Heading ***~~2~~*
+
+Paragraph
+
+*
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/basic/markdown.md
new file mode 100644
index 0000000000..557db03de9
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/basic/markdown.md
@@ -0,0 +1 @@
+Hello World
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/nested/markdown.md
new file mode 100644
index 0000000000..f4f110c5fb
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/nested/markdown.md
@@ -0,0 +1,5 @@
+Hello World
+
+Hello World
+
+Hello World
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/styled/markdown.md
new file mode 100644
index 0000000000..557db03de9
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/styled/markdown.md
@@ -0,0 +1 @@
+Hello World
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/fontSize/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/fontSize/basic/markdown.md
new file mode 100644
index 0000000000..a14913bf9b
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/fontSize/basic/markdown.md
@@ -0,0 +1 @@
+This is text with a custom fontSize
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/basic/markdown.md
new file mode 100644
index 0000000000..0fe906b288
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/basic/markdown.md
@@ -0,0 +1,2 @@
+Text1\
+Text2
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/between-links/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/between-links/markdown.md
new file mode 100644
index 0000000000..3f74feb726
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/between-links/markdown.md
@@ -0,0 +1,2 @@
+[Link1](https://www.website.com)\
+[Link2](https://www.website2.com)
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/end/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/end/markdown.md
new file mode 100644
index 0000000000..9d80a6ba66
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/end/markdown.md
@@ -0,0 +1 @@
+Text1
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/link/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/link/markdown.md
new file mode 100644
index 0000000000..95a590abea
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/link/markdown.md
@@ -0,0 +1,2 @@
+[Link1](https://www.website.com)\
+[Link1](https://www.website.com)
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/multiple/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/multiple/markdown.md
new file mode 100644
index 0000000000..f7e9c54f1f
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/multiple/markdown.md
@@ -0,0 +1,3 @@
+Text1\
+Text2\
+Text3
diff --git a/packages/core/src/shared/EditorElement.ts b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/only/markdown.md
similarity index 100%
rename from packages/core/src/shared/EditorElement.ts
rename to packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/only/markdown.md
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/start/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/start/markdown.md
new file mode 100644
index 0000000000..9d80a6ba66
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/start/markdown.md
@@ -0,0 +1 @@
+Text1
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/styles/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/styles/markdown.md
new file mode 100644
index 0000000000..f92fc1d40e
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/styles/markdown.md
@@ -0,0 +1,2 @@
+Text1\
+**Text2**
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/image/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/image/basic/markdown.md
new file mode 100644
index 0000000000..dda13c76fa
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/image/basic/markdown.md
@@ -0,0 +1,3 @@
+
+
+Caption
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/image/button/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/image/button/markdown.md
new file mode 100644
index 0000000000..4f8610b831
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/image/button/markdown.md
@@ -0,0 +1 @@
+Add Image
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/image/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/image/nested/markdown.md
new file mode 100644
index 0000000000..d2d1ce4de4
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/image/nested/markdown.md
@@ -0,0 +1,7 @@
+
+
+Caption
+
+
+
+Caption
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/link/adjacent/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/link/adjacent/markdown.md
new file mode 100644
index 0000000000..4fe44186fa
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/link/adjacent/markdown.md
@@ -0,0 +1 @@
+[Website](https://www.website.com)[Website2](https://www.website2.com)
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/link/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/link/basic/markdown.md
new file mode 100644
index 0000000000..bc9d83b3da
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/link/basic/markdown.md
@@ -0,0 +1 @@
+[Website](https://www.website.com)
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/link/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/link/styled/markdown.md
new file mode 100644
index 0000000000..ad7b143e27
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/link/styled/markdown.md
@@ -0,0 +1 @@
+**[Web](https://www.website.com)**[site](https://www.website.com)
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/mention/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/mention/basic/markdown.md
new file mode 100644
index 0000000000..b6a2ae25b3
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/mention/basic/markdown.md
@@ -0,0 +1 @@
+I enjoy working with @Matthew
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/basic/markdown.md
new file mode 100644
index 0000000000..07e18e6d30
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/basic/markdown.md
@@ -0,0 +1 @@
+Paragraph
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/empty/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/empty/markdown.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/nested/markdown.md
new file mode 100644
index 0000000000..af7d1348ad
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/nested/markdown.md
@@ -0,0 +1,5 @@
+Paragraph
+
+Nested Paragraph 1
+
+Nested Paragraph 2
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/styled/markdown.md
new file mode 100644
index 0000000000..4f45e63c5c
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/styled/markdown.md
@@ -0,0 +1 @@
+Plain Red Text Blue Background Mixed Colors
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/basic/markdown.md
new file mode 100644
index 0000000000..fd50b044f8
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/basic/markdown.md
@@ -0,0 +1 @@
+Custom Paragraph
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/nested/markdown.md
new file mode 100644
index 0000000000..147effc747
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/nested/markdown.md
@@ -0,0 +1,5 @@
+Custom Paragraph
+
+Nested Custom Paragraph 1
+
+Nested Custom Paragraph 2
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/styled/markdown.md
new file mode 100644
index 0000000000..4f45e63c5c
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/styled/markdown.md
@@ -0,0 +1 @@
+Plain Red Text Blue Background Mixed Colors
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/basic/markdown.md
new file mode 100644
index 0000000000..e90136ab90
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/basic/markdown.md
@@ -0,0 +1 @@
+
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md
new file mode 100644
index 0000000000..d642ea87c6
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md
@@ -0,0 +1 @@
+![placeholder]()
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/nested/markdown.md
new file mode 100644
index 0000000000..7d84311ed4
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/nested/markdown.md
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/small/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/small/basic/markdown.md
new file mode 100644
index 0000000000..02738ab95b
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/small/basic/markdown.md
@@ -0,0 +1 @@
+This is a small text
diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/tag/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/tag/basic/markdown.md
new file mode 100644
index 0000000000..8adc77839a
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/__snapshots__/tag/basic/markdown.md
@@ -0,0 +1 @@
+I love #BlockNote
diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.test.ts b/packages/core/src/api/exporters/markdown/markdownExporter.test.ts
new file mode 100644
index 0000000000..a4f391bc11
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/markdownExporter.test.ts
@@ -0,0 +1,85 @@
+import fs from "node:fs";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
+import { BlockSchema, PartialBlock } from "../../../schema/blocks/types";
+import { InlineContentSchema } from "../../../schema/inlineContent/types";
+import { StyleSchema } from "../../../schema/styles/types";
+import { partialBlocksToBlocksForTesting } from "../../testUtil/partialBlockTestUtil";
+import { customBlocksTestCases } from "../../testUtil/cases/customBlocks";
+import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent";
+import { customStylesTestCases } from "../../testUtil/cases/customStyles";
+import { defaultSchemaTestCases } from "../../testUtil/cases/defaultSchema";
+
+async function convertToMarkdownAndCompareSnapshots<
+ B extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+ snapshotDirectory: string,
+ snapshotName: string
+) {
+ const fullBlocks = partialBlocksToBlocksForTesting(
+ editor.blockSchema,
+ blocks
+ );
+ const md = await editor.blocksToMarkdownLossy(fullBlocks);
+ const snapshotPath =
+ "./__snapshots__/" +
+ snapshotDirectory +
+ "/" +
+ snapshotName +
+ "/markdown.md";
+
+ // vitest empty snapshots are broken on CI. might be fixed on next vitest, use workaround for now
+ if (!md.length && process.env.CI) {
+ if (
+ fs.readFileSync(path.join(__dirname, snapshotPath), "utf8").length === 0
+ ) {
+ // both are empty, so it's fine
+ return;
+ }
+ }
+ expect(md).toMatchFileSnapshot(snapshotPath);
+}
+
+const testCases = [
+ defaultSchemaTestCases,
+ customBlocksTestCases,
+ customStylesTestCases,
+ customInlineContentTestCases,
+];
+
+describe("markdownExporter", () => {
+ for (const testCase of testCases) {
+ describe("Case: " + testCase.name, () => {
+ let editor: BlockNoteEditor;
+
+ beforeEach(() => {
+ editor = testCase.createEditor();
+ });
+
+ afterEach(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+
+ delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+ });
+
+ for (const document of testCase.documents) {
+ // eslint-disable-next-line no-loop-func
+ it("Convert " + document.name + " to HTML", async () => {
+ const nameSplit = document.name.split("/");
+ await convertToMarkdownAndCompareSnapshots(
+ editor,
+ document.blocks,
+ nameSplit[0],
+ nameSplit[1]
+ );
+ });
+ }
+ });
+ }
+});
diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts
new file mode 100644
index 0000000000..841ff6381e
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts
@@ -0,0 +1,42 @@
+import { Schema } from "prosemirror-model";
+import rehypeParse from "rehype-parse";
+import rehypeRemark from "rehype-remark";
+import remarkGfm from "remark-gfm";
+import remarkStringify from "remark-stringify";
+import { unified } from "unified";
+import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
+import {
+ Block,
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../../schema";
+import { createExternalHTMLExporter } from "../html/externalHTMLExporter";
+import { removeUnderlines } from "./removeUnderlinesRehypePlugin";
+
+export function cleanHTMLToMarkdown(cleanHTMLString: string) {
+ const markdownString = unified()
+ .use(rehypeParse, { fragment: true })
+ .use(removeUnderlines)
+ .use(rehypeRemark)
+ .use(remarkGfm)
+ .use(remarkStringify)
+ .processSync(cleanHTMLString);
+
+ return markdownString.value as string;
+}
+
+export function blocksToMarkdown<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ blocks: Block[],
+ schema: Schema,
+ editor: BlockNoteEditor
+): string {
+ const exporter = createExternalHTMLExporter(schema, editor);
+ const externalHTML = exporter.exportBlocks(blocks);
+
+ return cleanHTMLToMarkdown(externalHTML);
+}
diff --git a/packages/core/src/api/formatConversions/removeUnderlinesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts
similarity index 100%
rename from packages/core/src/api/formatConversions/removeUnderlinesRehypePlugin.ts
rename to packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts
diff --git a/packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap b/packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap
deleted file mode 100644
index a3342cc7df..0000000000
--- a/packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap
+++ /dev/null
@@ -1,346 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`Complex Block/HTML/Markdown Conversions > Convert complex blocks to HTML 1`] = `"
Heading 1
Heading 2
Heading 3
Paragraph
Paragraph
Paragraph
Bullet List Item
Bullet List Item
Bullet List Item
Bullet List Item
Paragraph
Numbered List Item
Numbered List Item
Numbered List Item
Numbered List Item
Bullet List Item
Bullet List Item
Bullet List Item
"`;
-
-exports[`Complex Block/HTML/Markdown Conversions > Convert complex blocks to Markdown 1`] = `
-"# Heading 1
-
-## Heading 2
-
-### Heading 3
-
-Paragraph
-
-P**ara***grap*h
-
-Para~~grap~~h
-
-* Bullet List Item
-
-* Bullet List Item
-
- * Bullet List Item
-
- * Bullet List Item
-
- Paragraph
-
- 1. Numbered List Item
-
- 2. Numbered List Item
-
- 3. Numbered List Item
-
- 1. Numbered List Item
-
- * Bullet List Item
-
- * Bullet List Item
-
-* Bullet List Item
-"
-`;
-
-exports[`Nested Block/HTML/Markdown Conversions > Convert nested blocks to HTML 1`] = `"
`;
+
+ await parseHTMLAndCompareSnapshots(html, "parse-fake-image-caption");
+ });
+
+ // TODO: this one fails
+ it.skip("Parse deep nested content", async () => {
+ const html = `
+ Outer 1 Div Before
+
+ Outer 2 Div Before
+
+ Outer 3 Div Before
+
+ Outer 4 Div Before
+
Heading 1
+
Heading 2
+
Heading 3
+
Paragraph
+ Image Caption
+
BoldItalicUnderlineStrikethroughAll
+ Outer 4 Div After
+
+ Outer 3 Div After
+
+ Outer 2 Div After
+
+ Outer 1 Div After
+
`;
+
+ await parseHTMLAndCompareSnapshots(html, "parse-deep-nested-content");
+ });
+
+ it("Parse div with inline content and nested blocks", async () => {
+ const html = `
+ None Bold Italic Underline Strikethrough All
+
Nested Div
+
Nested Paragraph
+
`;
+
+ await parseHTMLAndCompareSnapshots(html, "parse-div-with-inline-content");
+ });
+
+ it("Parse Notion HTML", async () => {
+ // A few notes on Notion output HTML:
+ // - Does not preserve text/background colors
+ // - Does not preserve non-list-item block nesting
+ // - Hard breaks are represented using white space, not ` ` elements
+ // - Images are converted to links with a "!" at the start
+ // - Cells in first row of a table are converted to `th` elements, regardless
+ // of if the row is set as a header row
+
+ const html = `
+`;
+
+ await parseHTMLAndCompareSnapshots(html, "parse-notion-html");
+ });
+
+ // Currently breaking, seems related to parsing `` elements
+ it.skip("Parse Google Docs HTML", async () => {
+ // A few notes on Google Docs output HTML:
+ // - All inline markup is represented as `` elements with inline
+ // styles (bold, italic, etc.)
+ // - The nested list structure is not valid, i.e. `
` elements are not
+ // placed within `
` elements
+ // - Images are wrapped in two spans and a paragraph
+ // - Everything is nested within a `` element
+
+ const html = `
+
+
+
element to put
+ // them in to be semantically correct, which we can't have due to the
+ // schema.
+ "p",
+ {
+ ...(this.options.domAttributes?.blockContent || {}),
+ ...HTMLAttributes,
+ },
+ this.options.domAttributes?.inlineContent || {}
+ );
},
});
-export const BulletListItem = {
- node: BulletListItemBlockContent,
- propSchema: bulletListItemPropSchema,
-} satisfies BlockSpec<"bulletListItem", typeof bulletListItemPropSchema, true>;
+export const BulletListItem = createBlockSpecFromStronglyTypedTiptapNode(
+ BulletListItemBlockContent,
+ bulletListItemPropSchema
+);
diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
similarity index 94%
rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/ListItemKeyboardShortcuts.ts
rename to packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
index 01f7474eab..b51c6b5294 100644
--- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/ListItemKeyboardShortcuts.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
@@ -1,5 +1,5 @@
import { Editor } from "@tiptap/core";
-import { getBlockInfoFromPos } from "../../../helpers/getBlockInfoFromPos";
+import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos";
export const handleEnter = (editor: Editor) => {
const { node, contentType } = getBlockInfoFromPos(
diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
similarity index 97%
rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
rename to packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
index 95c7dd4e9e..5ee327fbbc 100644
--- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
@@ -1,5 +1,5 @@
import { Plugin, PluginKey } from "prosemirror-state";
-import { getBlockInfoFromPos } from "../../../../helpers/getBlockInfoFromPos";
+import { getBlockInfoFromPos } from "../../../api/getBlockInfoFromPos";
// ProseMirror Plugin which automatically assigns indices to ordered list items per nesting level.
const PLUGIN_KEY = new PluginKey(`numbered-list-indexing`);
diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
similarity index 57%
rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
rename to packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
index 5972e215c0..7e8752a27e 100644
--- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
@@ -1,23 +1,22 @@
-import { InputRule, mergeAttributes } from "@tiptap/core";
-import { defaultProps } from "../../../../api/defaultProps";
-import { createTipTapBlock } from "../../../../api/block";
-import { BlockSpec, PropSchema } from "../../../../api/blockTypes";
-import { mergeCSSClasses } from "../../../../../../shared/utils";
+import { InputRule } from "@tiptap/core";
+import {
+ PropSchema,
+ createBlockSpecFromStronglyTypedTiptapNode,
+ createStronglyTypedTiptapNode,
+} from "../../../schema";
+import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
+import { defaultProps } from "../../defaultProps";
import { handleEnter } from "../ListItemKeyboardShortcuts";
import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin";
-import styles from "../../../Block.module.css";
export const numberedListItemPropSchema = {
...defaultProps,
} satisfies PropSchema;
-const NumberedListItemBlockContent = createTipTapBlock<
- "numberedListItem",
- true
->({
+const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
name: "numberedListItem",
content: "inline*",
-
+ group: "blockContent",
addAttributes() {
return {
index: {
@@ -54,13 +53,7 @@ const NumberedListItemBlockContent = createTipTapBlock<
return {
Enter: () => handleEnter(this.editor),
"Mod-Shift-8": () =>
- this.editor.commands.BNUpdateBlock<{
- numberedListItem: BlockSpec<
- "numberedListItem",
- typeof numberedListItemPropSchema,
- true
- >;
- }>(this.editor.state.selection.anchor, {
+ this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
type: "numberedListItem",
props: {},
}),
@@ -73,6 +66,9 @@ const NumberedListItemBlockContent = createTipTapBlock<
parseHTML() {
return [
+ {
+ tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this
+ },
// Case for regular HTML list structure.
// (e.g.: when pasting from other apps)
{
@@ -88,7 +84,10 @@ const NumberedListItemBlockContent = createTipTapBlock<
return false;
}
- if (parent.tagName === "OL") {
+ if (
+ parent.tagName === "OL" ||
+ (parent.tagName === "DIV" && parent.parentElement!.tagName === "OL")
+ ) {
return {};
}
@@ -124,43 +123,22 @@ const NumberedListItemBlockContent = createTipTapBlock<
},
renderHTML({ HTMLAttributes }) {
- const blockContentDOMAttributes =
- this.options.domAttributes?.blockContent || {};
- const inlineContentDOMAttributes =
- this.options.domAttributes?.inlineContent || {};
-
- return [
- "div",
- mergeAttributes(HTMLAttributes, {
- ...blockContentDOMAttributes,
- class: mergeCSSClasses(
- styles.blockContent,
- blockContentDOMAttributes.class
- ),
- "data-content-type": this.name,
- }),
- // we use a
tag, because for
tags we'd need to add a
parent for around siblings to be semantically correct,
- // which would be quite cumbersome
- [
- "p",
- {
- ...inlineContentDOMAttributes,
- class: mergeCSSClasses(
- styles.inlineContent,
- inlineContentDOMAttributes.class
- ),
- },
- 0,
- ],
- ];
+ return createDefaultBlockDOMOutputSpec(
+ this.name,
+ // We use a
tag, because for
tags we'd need an element to
+ // put them in to be semantically correct, which we can't have due to the
+ // schema.
+ "p",
+ {
+ ...(this.options.domAttributes?.blockContent || {}),
+ ...HTMLAttributes,
+ },
+ this.options.domAttributes?.inlineContent || {}
+ );
},
});
-export const NumberedListItem = {
- node: NumberedListItemBlockContent,
- propSchema: numberedListItemPropSchema,
-} satisfies BlockSpec<
- "numberedListItem",
- typeof numberedListItemPropSchema,
- true
->;
+export const NumberedListItem = createBlockSpecFromStronglyTypedTiptapNode(
+ NumberedListItemBlockContent,
+ numberedListItemPropSchema
+);
diff --git a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts
new file mode 100644
index 0000000000..fcb532a8af
--- /dev/null
+++ b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts
@@ -0,0 +1,43 @@
+import {
+ createBlockSpecFromStronglyTypedTiptapNode,
+ createStronglyTypedTiptapNode,
+} from "../../schema";
+import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
+import { defaultProps } from "../defaultProps";
+
+export const paragraphPropSchema = {
+ ...defaultProps,
+};
+
+export const ParagraphBlockContent = createStronglyTypedTiptapNode({
+ name: "paragraph",
+ content: "inline*",
+ group: "blockContent",
+ parseHTML() {
+ return [
+ { tag: "div[data-content-type=" + this.name + "]" },
+ {
+ tag: "p",
+ priority: 200,
+ node: "paragraph",
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return createDefaultBlockDOMOutputSpec(
+ this.name,
+ "p",
+ {
+ ...(this.options.domAttributes?.blockContent || {}),
+ ...HTMLAttributes,
+ },
+ this.options.domAttributes?.inlineContent || {}
+ );
+ },
+});
+
+export const Paragraph = createBlockSpecFromStronglyTypedTiptapNode(
+ ParagraphBlockContent,
+ paragraphPropSchema
+);
diff --git a/packages/core/src/blocks/README.md b/packages/core/src/blocks/README.md
new file mode 100644
index 0000000000..b5a8581591
--- /dev/null
+++ b/packages/core/src/blocks/README.md
@@ -0,0 +1,3 @@
+### @blocknote/core/src/blocks
+
+The default built-in blocks that ship with BlockNote
\ No newline at end of file
diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts
new file mode 100644
index 0000000000..d2bb8c0217
--- /dev/null
+++ b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts
@@ -0,0 +1,74 @@
+import { mergeAttributes, Node } from "@tiptap/core";
+import { TableCell } from "@tiptap/extension-table-cell";
+import { TableHeader } from "@tiptap/extension-table-header";
+import { TableRow } from "@tiptap/extension-table-row";
+import {
+ createBlockSpecFromStronglyTypedTiptapNode,
+ createStronglyTypedTiptapNode,
+} from "../../schema";
+import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
+import { defaultProps } from "../defaultProps";
+import { TableExtension } from "./TableExtension";
+
+export const tablePropSchema = {
+ ...defaultProps,
+};
+
+export const TableBlockContent = createStronglyTypedTiptapNode({
+ name: "table",
+ content: "tableRow+",
+ group: "blockContent",
+ tableRole: "table",
+
+ isolating: true,
+
+ parseHTML() {
+ return [{ tag: "table" }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return createDefaultBlockDOMOutputSpec(
+ this.name,
+ "table",
+ {
+ ...(this.options.domAttributes?.blockContent || {}),
+ ...HTMLAttributes,
+ },
+ this.options.domAttributes?.inlineContent || {}
+ );
+ },
+});
+
+const TableParagraph = Node.create({
+ name: "tableParagraph",
+ group: "tableContent",
+ content: "inline*",
+
+ parseHTML() {
+ return [{ tag: "p" }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "p",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0,
+ ];
+ },
+});
+
+export const Table = createBlockSpecFromStronglyTypedTiptapNode(
+ TableBlockContent,
+ tablePropSchema,
+ [
+ TableExtension,
+ TableParagraph,
+ TableHeader.extend({
+ content: "tableContent",
+ }),
+ TableCell.extend({
+ content: "tableContent",
+ }),
+ TableRow,
+ ]
+);
diff --git a/packages/core/src/blocks/TableBlockContent/TableExtension.ts b/packages/core/src/blocks/TableBlockContent/TableExtension.ts
new file mode 100644
index 0000000000..fb0147d7ae
--- /dev/null
+++ b/packages/core/src/blocks/TableBlockContent/TableExtension.ts
@@ -0,0 +1,63 @@
+import { callOrReturn, Extension, getExtensionField } from "@tiptap/core";
+import { columnResizing, tableEditing } from "prosemirror-tables";
+
+export const TableExtension = Extension.create({
+ name: "BlockNoteTableExtension",
+
+ addProseMirrorPlugins: () => {
+ return [
+ columnResizing({
+ cellMinWidth: 100,
+ }),
+ tableEditing(),
+ ];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ // Makes enter create a new line within the cell.
+ Enter: () => {
+ if (
+ this.editor.state.selection.empty &&
+ this.editor.state.selection.$head.parent.type.name ===
+ "tableParagraph"
+ ) {
+ this.editor.commands.setHardBreak();
+
+ return true;
+ }
+
+ return false;
+ },
+ // Ensures that backspace won't delete the table if the text cursor is at
+ // the start of a cell and the selection is empty.
+ Backspace: () => {
+ const selection = this.editor.state.selection;
+ const selectionIsEmpty = selection.empty;
+ const selectionIsAtStartOfNode = selection.$head.parentOffset === 0;
+ const selectionIsInTableParagraphNode =
+ selection.$head.node().type.name === "tableParagraph";
+
+ return (
+ selectionIsEmpty &&
+ selectionIsAtStartOfNode &&
+ selectionIsInTableParagraphNode
+ );
+ },
+ };
+ },
+
+ extendNodeSchema(extension) {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: extension.storage,
+ };
+
+ return {
+ tableRole: callOrReturn(
+ getExtensionField(extension, "tableRole", context)
+ ),
+ };
+ },
+});
diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts
new file mode 100644
index 0000000000..710ca57aa0
--- /dev/null
+++ b/packages/core/src/blocks/defaultBlockHelpers.ts
@@ -0,0 +1,95 @@
+import { blockToNode } from "../api/nodeConversions/nodeConversions";
+import type { BlockNoteEditor } from "../editor/BlockNoteEditor";
+import type {
+ Block,
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../schema";
+import { mergeCSSClasses } from "../util/browser";
+
+// Function that creates a ProseMirror `DOMOutputSpec` for a default block.
+// Since all default blocks have the same structure (`blockContent` div with a
+// `inlineContent` element inside), this function only needs the block's name
+// for the `data-content-type` attribute of the `blockContent` element and the
+// HTML tag of the `inlineContent` element, as well as any HTML attributes to
+// add to those.
+export function createDefaultBlockDOMOutputSpec(
+ blockName: string,
+ htmlTag: string,
+ blockContentHTMLAttributes: Record,
+ inlineContentHTMLAttributes: Record
+) {
+ const blockContent = document.createElement("div");
+ blockContent.className = mergeCSSClasses(
+ "bn-block-content",
+ blockContentHTMLAttributes.class
+ );
+ blockContent.setAttribute("data-content-type", blockName);
+ for (const [attribute, value] of Object.entries(blockContentHTMLAttributes)) {
+ if (attribute !== "class") {
+ blockContent.setAttribute(attribute, value);
+ }
+ }
+
+ const inlineContent = document.createElement(htmlTag);
+ inlineContent.className = mergeCSSClasses(
+ "bn-inline-content",
+ inlineContentHTMLAttributes.class
+ );
+ for (const [attribute, value] of Object.entries(
+ inlineContentHTMLAttributes
+ )) {
+ if (attribute !== "class") {
+ inlineContent.setAttribute(attribute, value);
+ }
+ }
+
+ blockContent.appendChild(inlineContent);
+
+ return {
+ dom: blockContent,
+ contentDOM: inlineContent,
+ };
+}
+
+// Function used to convert default blocks to HTML. It uses the corresponding
+// node's `renderHTML` method to do the conversion by using a default
+// `DOMSerializer`.
+export const defaultBlockToHTML = <
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ block: Block,
+ editor: BlockNoteEditor
+): {
+ dom: HTMLElement;
+ contentDOM?: HTMLElement;
+} => {
+ const node = blockToNode(
+ block,
+ editor._tiptapEditor.schema,
+ editor.styleSchema
+ ).firstChild!;
+ const toDOM = editor._tiptapEditor.schema.nodes[node.type.name].spec.toDOM;
+
+ if (toDOM === undefined) {
+ throw new Error(
+ "This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`."
+ );
+ }
+
+ const renderSpec = toDOM(node);
+
+ if (typeof renderSpec !== "object" || !("dom" in renderSpec)) {
+ throw new Error(
+ "Cannot use this block's default HTML serialization as its corresponding TipTap node's `renderHTML` function does not return an object with the `dom` property."
+ );
+ }
+
+ return renderSpec as {
+ dom: HTMLElement;
+ contentDOM?: HTMLElement;
+ };
+};
diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts
new file mode 100644
index 0000000000..36ebc50f7d
--- /dev/null
+++ b/packages/core/src/blocks/defaultBlocks.ts
@@ -0,0 +1,60 @@
+import Bold from "@tiptap/extension-bold";
+import Code from "@tiptap/extension-code";
+import Italic from "@tiptap/extension-italic";
+import Strike from "@tiptap/extension-strike";
+import Underline from "@tiptap/extension-underline";
+import { BackgroundColor } from "../extensions/BackgroundColor/BackgroundColorMark";
+import { TextColor } from "../extensions/TextColor/TextColorMark";
+import {
+ BlockSpecs,
+ InlineContentSpecs,
+ StyleSpecs,
+ createStyleSpecFromTipTapMark,
+ getBlockSchemaFromSpecs,
+ getInlineContentSchemaFromSpecs,
+ getStyleSchemaFromSpecs,
+} from "../schema";
+import { Heading } from "./HeadingBlockContent/HeadingBlockContent";
+import { Image } from "./ImageBlockContent/ImageBlockContent";
+import { BulletListItem } from "./ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent";
+import { NumberedListItem } from "./ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent";
+import { Paragraph } from "./ParagraphBlockContent/ParagraphBlockContent";
+import { Table } from "./TableBlockContent/TableBlockContent";
+
+export const defaultBlockSpecs = {
+ paragraph: Paragraph,
+ heading: Heading,
+ bulletListItem: BulletListItem,
+ numberedListItem: NumberedListItem,
+ image: Image,
+ table: Table,
+} satisfies BlockSpecs;
+
+export const defaultBlockSchema = getBlockSchemaFromSpecs(defaultBlockSpecs);
+
+export type DefaultBlockSchema = typeof defaultBlockSchema;
+
+export const defaultStyleSpecs = {
+ bold: createStyleSpecFromTipTapMark(Bold, "boolean"),
+ italic: createStyleSpecFromTipTapMark(Italic, "boolean"),
+ underline: createStyleSpecFromTipTapMark(Underline, "boolean"),
+ strike: createStyleSpecFromTipTapMark(Strike, "boolean"),
+ code: createStyleSpecFromTipTapMark(Code, "boolean"),
+ textColor: TextColor,
+ backgroundColor: BackgroundColor,
+} satisfies StyleSpecs;
+
+export const defaultStyleSchema = getStyleSchemaFromSpecs(defaultStyleSpecs);
+
+export type DefaultStyleSchema = typeof defaultStyleSchema;
+
+export const defaultInlineContentSpecs = {
+ text: { config: "text", implementation: {} as any },
+ link: { config: "link", implementation: {} as any },
+} satisfies InlineContentSpecs;
+
+export const defaultInlineContentSchema = getInlineContentSchemaFromSpecs(
+ defaultInlineContentSpecs
+);
+
+export type DefaultInlineContentSchema = typeof defaultInlineContentSchema;
diff --git a/packages/core/src/blocks/defaultProps.ts b/packages/core/src/blocks/defaultProps.ts
new file mode 100644
index 0000000000..da9add88dd
--- /dev/null
+++ b/packages/core/src/blocks/defaultProps.ts
@@ -0,0 +1,24 @@
+import type { Props, PropSchema } from "../schema";
+
+// TODO: this system should probably be moved / refactored.
+// The dependency from schema on this file doesn't make sense
+
+export const defaultProps = {
+ backgroundColor: {
+ default: "default" as const,
+ },
+ textColor: {
+ default: "default" as const,
+ },
+ textAlignment: {
+ default: "left" as const,
+ values: ["left", "center", "right", "justify"] as const,
+ },
+} satisfies PropSchema;
+
+export type DefaultProps = Props;
+
+// Default props which are set on `blockContainer` nodes rather than
+// `blockContent` nodes. Ensures that they are not redundantly added to
+// a custom block's TipTap node attributes.
+export const inheritedProps = ["backgroundColor", "textColor"];
diff --git a/packages/core/src/extensions/Blocks/nodes/Block.module.css b/packages/core/src/editor/Block.css
similarity index 66%
rename from packages/core/src/extensions/Blocks/nodes/Block.module.css
rename to packages/core/src/editor/Block.css
index f8626d053a..bc5ea21eb2 100644
--- a/packages/core/src/extensions/Blocks/nodes/Block.module.css
+++ b/packages/core/src/editor/Block.css
@@ -2,24 +2,24 @@
BASIC STYLES
*/
-.blockOuter {
+.bn-block-outer {
line-height: 1.5;
transition: margin 0.2s;
}
/*Ensures blocks & block content spans editor width*/
-.block {
+.bn-block {
display: flex;
flex-direction: column;
}
/*Ensures block content inside React node views spans editor width*/
-.reactNodeViewRenderer {
+.bn-react-node-view-renderer {
display: flex;
flex-grow: 1;
}
-.blockContent {
+.bn-block-content {
padding: 3px 0;
flex-grow: 1;
transition: font-size 0.2s;
@@ -30,7 +30,7 @@ BASIC STYLES
*/
}
-.blockContent::before {
+.bn-block-content::before {
/* content: ""; */
transition: all 0.2s;
/*margin: 0px;*/
@@ -40,15 +40,16 @@ BASIC STYLES
NESTED BLOCKS
*/
-.blockGroup .blockGroup {
+.bn-block-group .bn-block-group {
margin-left: 1.5em;
}
-.blockGroup .blockGroup > .blockOuter {
+.bn-block-group .bn-block-group > .bn-block-outer {
position: relative;
}
-.blockGroup .blockGroup > .blockOuter:not([data-prev-depth-changed])::before {
+.bn-block-group .bn-block-group
+ > .bn-block-outer:not([data-prev-depth-changed])::before {
content: " ";
display: inline;
position: absolute;
@@ -57,7 +58,8 @@ NESTED BLOCKS
transition: all 0.2s 0.1s;
}
-.blockGroup .blockGroup > .blockOuter[data-prev-depth-change="-2"]::before {
+.bn-block-group .bn-block-group
+ > .bn-block-outer[data-prev-depth-change="-2"]::before {
height: 0;
}
@@ -95,11 +97,12 @@ NESTED BLOCKS
--x: -5;
}
-.blockOuter[data-prev-depth-change] {
+.bn-block-outer[data-prev-depth-change] {
margin-left: calc(10px * var(--x));
}
-.blockOuter[data-prev-depth-change] .blockOuter[data-prev-depth-change] {
+.bn-block-outer[data-prev-depth-change]
+ .bn-block-outer[data-prev-depth-change] {
margin-left: 0;
}
@@ -124,21 +127,21 @@ NESTED BLOCKS
--prev-level: 1.3em;
}
-.blockOuter[data-prev-type="heading"] > .block > .blockContent {
+.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content {
font-size: var(--prev-level);
font-weight: bold;
}
-.blockOuter:not([data-prev-type])
- > .block
- > .blockContent[data-content-type="heading"] {
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > .bn-block-content[data-content-type="heading"] {
font-size: var(--level);
font-weight: bold;
}
/* LISTS */
-.blockContent::before {
+.bn-block-content::before {
margin-right: 0;
content: "";
}
@@ -152,79 +155,81 @@ NESTED BLOCKS
--prev-index: attr(data-prev-index);
}
-.blockOuter[data-prev-type="numberedListItem"]:not([data-prev-index="none"])
- > .block
- > .blockContent::before {
+.bn-block-outer[data-prev-type="numberedListItem"]:not([data-prev-index="none"])
+ > .bn-block
+ > .bn-block-content::before {
margin-right: 1.2em;
content: var(--prev-index) ".";
}
-.blockOuter:not([data-prev-type])
- > .block
- > .blockContent[data-content-type="numberedListItem"]::before {
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > .bn-block-content[data-content-type="numberedListItem"]::before {
margin-right: 1.2em;
content: var(--index) ".";
}
/* Unordered */
/* No list nesting */
-.blockOuter[data-prev-type="bulletListItem"] > .block > .blockContent::before {
+.bn-block-outer[data-prev-type="bulletListItem"]
+ > .bn-block
+ > .bn-block-content::before {
margin-right: 1.2em;
content: "•";
}
-.blockOuter:not([data-prev-type])
- > .block
- > .blockContent[data-content-type="bulletListItem"]::before {
+.bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > .bn-block-content[data-content-type="bulletListItem"]::before {
margin-right: 1.2em;
content: "•";
}
/* 1 level of list nesting */
[data-content-type="bulletListItem"]
- ~ .blockGroup
- > .blockOuter[data-prev-type="bulletListItem"]
- > .block
- > .blockContent::before {
+ ~ .bn-block-group
+ > .bn-block-outer[data-prev-type="bulletListItem"]
+ > .bn-block
+ > .bn-block-content::before {
margin-right: 1.2em;
content: "◦";
}
[data-content-type="bulletListItem"]
- ~ .blockGroup
- > .blockOuter:not([data-prev-type])
- > .block
- > .blockContent[data-content-type="bulletListItem"]::before {
+ ~ .bn-block-group
+ > .bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > .bn-block-content[data-content-type="bulletListItem"]::before {
margin-right: 1.2em;
content: "◦";
}
/* 2 levels of list nesting */
[data-content-type="bulletListItem"]
- ~ .blockGroup
+ ~ .bn-block-group
[data-content-type="bulletListItem"]
- ~ .blockGroup
- > .blockOuter[data-prev-type="bulletListItem"]
- > .block
- > .blockContent::before {
+ ~ .bn-block-group
+ > .bn-block-outer[data-prev-type="bulletListItem"]
+ > .bn-block
+ > .bn-block-content::before {
margin-right: 1.2em;
content: "▪";
}
[data-content-type="bulletListItem"]
- ~ .blockGroup
+ ~ .bn-block-group
[data-content-type="bulletListItem"]
- ~ .blockGroup
- > .blockOuter:not([data-prev-type])
- > .block
- > .blockContent[data-content-type="bulletListItem"]::before {
+ ~ .bn-block-group
+ > .bn-block-outer:not([data-prev-type])
+ > .bn-block
+ > .bn-block-content[data-content-type="bulletListItem"]::before {
margin-right: 1.2em;
content: "▪";
}
/* IMAGES */
-[data-content-type="image"] .wrapper {
+[data-content-type="image"] .bn-image-block-content-wrapper {
display: flex;
flex-direction: column;
justify-content: center;
@@ -232,7 +237,7 @@ NESTED BLOCKS
width: 100%;
}
-[data-content-type="image"] .addImageButton {
+[data-content-type="image"] .bn-add-image-button {
display: flex;
flex-direction: row;
align-items: center;
@@ -244,27 +249,27 @@ NESTED BLOCKS
width: 100%;
}
-[data-content-type="image"] .addImageButton:hover {
+[data-content-type="image"] .bn-add-image-button:hover {
background-color: gainsboro;
}
-[data-content-type="image"] .addImageButtonIcon {
+[data-content-type="image"] .bn-add-image-button-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%3E%3C/svg%3E");
width: 24px;
height: 24px;
}
-[data-content-type="image"] .addImageButtonText {
+[data-content-type="image"] .bn-add-image-button-text {
color: black;
}
-[data-content-type="image"] .imageAndCaptionWrapper {
+[data-content-type="image"] .bn-image-and-caption-wrapper {
display: flex;
flex-direction: column;
border-radius: 4px;
}
-[data-content-type="image"] .imageWrapper {
+[data-content-type="image"] .bn-image-wrapper {
display: flex;
flex-direction: row;
align-items: center;
@@ -272,12 +277,12 @@ NESTED BLOCKS
width: fit-content;
}
-[data-content-type="image"] .image {
+[data-content-type="image"] .bn-image {
border-radius: 4px;
max-width: 100%;
}
-[data-content-type="image"] .resizeHandle {
+[data-content-type="image"] .bn-image-resize-handle {
display: none;
position: absolute;
width: 8px;
@@ -294,8 +299,8 @@ NESTED BLOCKS
/* PLACEHOLDERS*/
-.isEmpty .inlineContent:before,
-.isFilter .inlineContent:before {
+.bn-is-empty .bn-inline-content:before,
+.bn-is-filter .bn-inline-content:before {
/*float: left; */
content: "";
pointer-events: none;
@@ -307,25 +312,27 @@ NESTED BLOCKS
/* TODO: would be nicer if defined from code */
-.blockContent.isEmpty.hasAnchor .inlineContent:before {
+.bn-block-content.bn-is-empty.bn-has-anchor .bn-inline-content:before {
content: "Enter text or type '/' for commands";
}
-.blockContent.isFilter.hasAnchor .inlineContent:before {
+.bn-block-content.bn-is-filter.bn-has-anchor .bn-inline-content:before {
content: "Type to filter";
}
-.blockContent[data-content-type="heading"].isEmpty .inlineContent:before {
+.bn-block-content[data-content-type="heading"].bn-is-empty
+ .bn-inline-content:before {
content: "Heading";
}
-.blockContent[data-content-type="bulletListItem"].isEmpty .inlineContent:before,
-.blockContent[data-content-type="numberedListItem"].isEmpty
-.inlineContent:before {
+.bn-block-content[data-content-type="bulletListItem"].bn-is-empty
+ .bn-inline-content:before,
+ .bn-block-content[data-content-type="numberedListItem"].bn-is-empty
+ .bn-inline-content:before {
content: "List";
}
-.isEmpty .blockContent[data-content-type="captionedImage"] .inlineContent:before {
+.bn-is-empty .bn-block-content[data-content-type="captionedImage"] .bn-inline-content:before {
content: "Caption";
}
diff --git a/packages/core/src/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts
similarity index 70%
rename from packages/core/src/BlockNoteEditor.test.ts
rename to packages/core/src/editor/BlockNoteEditor.test.ts
index 9c1b60fb12..b4c1e26558 100644
--- a/packages/core/src/BlockNoteEditor.test.ts
+++ b/packages/core/src/editor/BlockNoteEditor.test.ts
@@ -1,12 +1,12 @@
import { expect, it } from "vitest";
import { BlockNoteEditor } from "./BlockNoteEditor";
-import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos";
+import { getBlockInfoFromPos } from "../api/getBlockInfoFromPos";
/**
* @vitest-environment jsdom
*/
it("creates an editor", () => {
- const editor = new BlockNoteEditor({});
+ const editor = BlockNoteEditor.create();
const blockInfo = getBlockInfoFromPos(editor._tiptapEditor.state.doc, 2);
expect(blockInfo?.contentNode.type.name).toEqual("paragraph");
});
diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
similarity index 65%
rename from packages/core/src/BlockNoteEditor.ts
rename to packages/core/src/editor/BlockNoteEditor.ts
index 58491b687c..c888a0f670 100644
--- a/packages/core/src/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -3,56 +3,79 @@ import { Node } from "prosemirror-model";
// import "./blocknote.css";
import { Editor as TiptapEditor } from "@tiptap/core/dist/packages/core/src/Editor";
import * as Y from "yjs";
-import { getBlockNoteExtensions } from "./BlockNoteExtensions";
import {
insertBlocks,
removeBlocks,
replaceBlocks,
updateBlock,
-} from "./api/blockManipulation/blockManipulation";
-import {
- HTMLToBlocks,
- blocksToHTML,
- blocksToMarkdown,
- markdownToBlocks,
-} from "./api/formatConversions/formatConversions";
+} from "../api/blockManipulation/blockManipulation";
+import { createExternalHTMLExporter } from "../api/exporters/html/externalHTMLExporter";
+import { blocksToMarkdown } from "../api/exporters/markdown/markdownExporter";
+import { getBlockInfoFromPos } from "../api/getBlockInfoFromPos";
import {
blockToNode,
nodeToBlock,
-} from "./api/nodeConversions/nodeConversions";
-import { getNodeById } from "./api/util/nodeUtil";
-import styles from "./editor.module.css";
+} from "../api/nodeConversions/nodeConversions";
+import { getNodeById } from "../api/nodeUtil";
+import { HTMLToBlocks } from "../api/parsers/html/parseHTML";
+import { markdownToBlocks } from "../api/parsers/markdown/parseMarkdown";
+import {
+ DefaultBlockSchema,
+ DefaultInlineContentSchema,
+ DefaultStyleSchema,
+ defaultBlockSchema,
+ defaultBlockSpecs,
+ defaultInlineContentSpecs,
+ defaultStyleSpecs,
+} from "../blocks/defaultBlocks";
+import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin";
+import { HyperlinkToolbarProsemirrorPlugin } from "../extensions/HyperlinkToolbar/HyperlinkToolbarPlugin";
+import { ImageToolbarProsemirrorPlugin } from "../extensions/ImageToolbar/ImageToolbarPlugin";
+import { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin";
+import { BaseSlashMenuItem } from "../extensions/SlashMenu/BaseSlashMenuItem";
+import { SlashMenuProsemirrorPlugin } from "../extensions/SlashMenu/SlashMenuPlugin";
+import { getDefaultSlashMenuItems } from "../extensions/SlashMenu/defaultSlashMenuItems";
+import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin";
+import { UniqueID } from "../extensions/UniqueID/UniqueID";
import {
Block,
BlockIdentifier,
BlockNoteDOMAttributes,
BlockSchema,
+ BlockSchemaFromSpecs,
+ BlockSchemaWithBlock,
+ BlockSpecs,
+ InlineContentSchema,
+ InlineContentSchemaFromSpecs,
+ InlineContentSpecs,
PartialBlock,
-} from "./extensions/Blocks/api/blockTypes";
-import {
- DefaultBlockSchema,
- defaultBlockSchema,
-} from "./extensions/Blocks/api/defaultBlocks";
-import {
- ColorStyle,
+ StyleSchema,
+ StyleSchemaFromSpecs,
+ StyleSpecs,
Styles,
- ToggledStyle,
-} from "./extensions/Blocks/api/inlineContentTypes";
-import { Selection } from "./extensions/Blocks/api/selectionTypes";
-import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos";
-
-import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes";
-import { FormattingToolbarProsemirrorPlugin } from "./extensions/FormattingToolbar/FormattingToolbarPlugin";
-import { HyperlinkToolbarProsemirrorPlugin } from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin";
-import { ImageToolbarProsemirrorPlugin } from "./extensions/ImageToolbar/ImageToolbarPlugin";
-import { SideMenuProsemirrorPlugin } from "./extensions/SideMenu/SideMenuPlugin";
-import { BaseSlashMenuItem } from "./extensions/SlashMenu/BaseSlashMenuItem";
-import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlugin";
-import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems";
-import { UniqueID } from "./extensions/UniqueID/UniqueID";
-import { mergeCSSClasses } from "./shared/utils";
-
-export type BlockNoteEditorOptions = {
+ getBlockSchemaFromSpecs,
+ getInlineContentSchemaFromSpecs,
+ getStyleSchemaFromSpecs,
+} from "../schema";
+import { mergeCSSClasses } from "../util/browser";
+import { UnreachableCaseError } from "../util/typescript";
+
+import { getBlockNoteExtensions } from "./BlockNoteExtensions";
+import { TextCursorPosition } from "./cursorPositionTypes";
+
+import { Selection } from "./selectionTypes";
+import { transformPasted } from "./transformPasted";
+
+// CSS
+import "prosemirror-tables/style/tables.css";
+import "./Block.css";
+import "./editor.css";
+
+export type BlockNoteEditorOptions<
+ BSpecs extends BlockSpecs,
+ ISpecs extends InlineContentSpecs,
+ SSpecs extends StyleSpecs
+> = {
// TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them.
enableBlockNoteExtensions: boolean;
/**
@@ -61,7 +84,7 @@ export type BlockNoteEditorOptions = {
*
* @default defaultSlashMenuItems from `./extensions/SlashMenu`
*/
- slashMenuItems: BaseSlashMenuItem[];
+ slashMenuItems: BaseSlashMenuItem[];
/**
* The HTML element that should be used as the parent element for the editor.
@@ -78,15 +101,33 @@ export type BlockNoteEditorOptions = {
/**
* A callback function that runs when the editor is ready to be used.
*/
- onEditorReady: (editor: BlockNoteEditor) => void;
+ onEditorReady: (
+ editor: BlockNoteEditor<
+ BlockSchemaFromSpecs,
+ InlineContentSchemaFromSpecs,
+ StyleSchemaFromSpecs
+ >
+ ) => void;
/**
* A callback function that runs whenever the editor's contents change.
*/
- onEditorContentChange: (editor: BlockNoteEditor) => void;
+ onEditorContentChange: (
+ editor: BlockNoteEditor<
+ BlockSchemaFromSpecs,
+ InlineContentSchemaFromSpecs,
+ StyleSchemaFromSpecs
+ >
+ ) => void;
/**
* A callback function that runs whenever the text cursor position changes.
*/
- onTextCursorPositionChange: (editor: BlockNoteEditor) => void;
+ onTextCursorPositionChange: (
+ editor: BlockNoteEditor<
+ BlockSchemaFromSpecs,
+ InlineContentSchemaFromSpecs,
+ StyleSchemaFromSpecs
+ >
+ ) => void;
/**
* Locks the editor from being editable by the user if set to `false`.
*/
@@ -94,7 +135,11 @@ export type BlockNoteEditorOptions = {
/**
* The content that should be in the editor when it's created, represented as an array of partial block objects.
*/
- initialContent: PartialBlock[];
+ initialContent: PartialBlock<
+ BlockSchemaFromSpecs,
+ InlineContentSchemaFromSpecs,
+ StyleSchemaFromSpecs
+ >[];
/**
* Use default BlockNote font and reset the styles of
elements etc., that are used in BlockNote.
*
@@ -105,7 +150,11 @@ export type BlockNoteEditorOptions = {
/**
* A list of block types that should be available in the editor.
*/
- blockSchema: BSchema;
+ blockSpecs: BSpecs;
+
+ styleSpecs: SSpecs;
+
+ inlineContentSpecs: ISpecs;
/**
* A custom function to handle file uploads.
@@ -149,52 +198,115 @@ const blockNoteTipTapOptions = {
enableCoreExtensions: false,
};
-export class BlockNoteEditor {
+export class BlockNoteEditor<
+ BSchema extends BlockSchema = DefaultBlockSchema,
+ ISchema extends InlineContentSchema = DefaultInlineContentSchema,
+ SSchema extends StyleSchema = DefaultStyleSchema
+> {
public readonly _tiptapEditor: TiptapEditor & { contentComponent: any };
- public blockCache = new WeakMap>();
- public readonly schema: BSchema;
+ public blockCache = new WeakMap>();
+ public readonly blockSchema: BSchema;
+ public readonly inlineContentSchema: ISchema;
+ public readonly styleSchema: SSchema;
+
+ public readonly blockImplementations: BlockSpecs;
+ public readonly inlineContentImplementations: InlineContentSpecs;
+ public readonly styleImplementations: StyleSpecs;
+
public ready = false;
- public readonly sideMenu: SideMenuProsemirrorPlugin;
- public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin;
- public readonly slashMenu: SlashMenuProsemirrorPlugin;
- public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin;
- public readonly imageToolbar: ImageToolbarProsemirrorPlugin;
+ public readonly sideMenu: SideMenuProsemirrorPlugin<
+ BSchema,
+ ISchema,
+ SSchema
+ >;
+ public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin;
+ public readonly slashMenu: SlashMenuProsemirrorPlugin<
+ BSchema,
+ ISchema,
+ SSchema,
+ any
+ >;
+ public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin<
+ BSchema,
+ ISchema,
+ SSchema
+ >;
+ public readonly imageToolbar: ImageToolbarProsemirrorPlugin<
+ BSchema,
+ ISchema,
+ SSchema
+ >;
+ public readonly tableHandles:
+ | TableHandlesProsemirrorPlugin<
+ BSchema extends BlockSchemaWithBlock<
+ "table",
+ DefaultBlockSchema["table"]
+ >
+ ? BSchema
+ : any,
+ ISchema,
+ SSchema
+ >
+ | undefined;
public readonly uploadFile: ((file: File) => Promise) | undefined;
- constructor(
- private readonly options: Partial> = {}
+ public static create<
+ BSpecs extends BlockSpecs = typeof defaultBlockSpecs,
+ ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs,
+ SSpecs extends StyleSpecs = typeof defaultStyleSpecs
+ >(options: Partial> = {}) {
+ return new BlockNoteEditor(options) as BlockNoteEditor<
+ BlockSchemaFromSpecs,
+ InlineContentSchemaFromSpecs,
+ StyleSchemaFromSpecs
+ >;
+ }
+
+ private constructor(
+ private readonly options: Partial>
) {
// apply defaults
- const newOptions: Omit & {
- defaultStyles: boolean;
- blockSchema: BSchema;
- } = {
+ const newOptions = {
defaultStyles: true,
- // TODO: There's a lot of annoying typing stuff to deal with here. If
- // BSchema is specified, then options.blockSchema should also be required.
- // If BSchema is not specified, then options.blockSchema should also not
- // be defined. Unfortunately, trying to implement these constraints seems
- // to be a huge pain, hence the `as any` casts.
- blockSchema: options.blockSchema || (defaultBlockSchema as any),
+ blockSpecs: options.blockSpecs || defaultBlockSpecs,
+ styleSpecs: options.styleSpecs || defaultStyleSpecs,
+ inlineContentSpecs:
+ options.inlineContentSpecs || defaultInlineContentSpecs,
...options,
};
+ this.blockSchema = getBlockSchemaFromSpecs(newOptions.blockSpecs);
+ this.inlineContentSchema = getInlineContentSchemaFromSpecs(
+ newOptions.inlineContentSpecs
+ );
+ this.styleSchema = getStyleSchemaFromSpecs(newOptions.styleSpecs);
+ this.blockImplementations = newOptions.blockSpecs;
+ this.inlineContentImplementations = newOptions.inlineContentSpecs;
+ this.styleImplementations = newOptions.styleSpecs;
+
this.sideMenu = new SideMenuProsemirrorPlugin(this);
this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this);
this.slashMenu = new SlashMenuProsemirrorPlugin(
this,
newOptions.slashMenuItems ||
- getDefaultSlashMenuItems(newOptions.blockSchema)
+ (getDefaultSlashMenuItems(this.blockSchema) as any)
);
this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this);
this.imageToolbar = new ImageToolbarProsemirrorPlugin(this);
- const extensions = getBlockNoteExtensions({
+ if (this.blockSchema.table === defaultBlockSchema.table) {
+ this.tableHandles = new TableHandlesProsemirrorPlugin(this as any);
+ }
+
+ const extensions = getBlockNoteExtensions({
editor: this,
domAttributes: newOptions.domAttributes || {},
- blockSchema: newOptions.blockSchema,
+ blockSchema: this.blockSchema,
+ blockSpecs: newOptions.blockSpecs,
+ styleSpecs: newOptions.styleSpecs,
+ inlineContentSpecs: newOptions.inlineContentSpecs,
collaboration: newOptions.collaboration,
});
@@ -208,13 +320,12 @@ export class BlockNoteEditor {
this.slashMenu.plugin,
this.hyperlinkToolbar.plugin,
this.imageToolbar.plugin,
+ ...(this.tableHandles ? [this.tableHandles.plugin] : []),
];
},
});
extensions.push(blockNoteUIExtension);
- this.schema = newOptions.blockSchema;
-
this.uploadFile = newOptions.uploadFile;
if (newOptions.collaboration && newOptions.initialContent) {
@@ -233,6 +344,7 @@ export class BlockNoteEditor {
id: UniqueID.options.generateID(),
},
]);
+ const styleSchema = this.styleSchema;
const tiptapOptions: Partial = {
...blockNoteTipTapOptions,
@@ -267,7 +379,11 @@ export class BlockNoteEditor {
"doc",
undefined,
schema.node("blockGroup", undefined, [
- blockToNode({ id: "initialBlockId", type: "paragraph" }, schema),
+ blockToNode(
+ { id: "initialBlockId", type: "paragraph" },
+ schema,
+ styleSchema
+ ),
])
);
editor.editor.options.content = root.toJSON();
@@ -278,7 +394,7 @@ export class BlockNoteEditor {
// initial content, as the schema may contain custom blocks which need
// it to render.
if (initialContent !== undefined) {
- this.replaceBlocks(this.topLevelBlocks, initialContent);
+ this.replaceBlocks(this.topLevelBlocks, initialContent as any);
}
newOptions.onEditorReady?.(this);
@@ -320,13 +436,13 @@ export class BlockNoteEditor {
...newOptions._tiptapOptions?.editorProps?.attributes,
...newOptions.domAttributes?.editor,
class: mergeCSSClasses(
- styles.bnEditor,
- styles.bnRoot,
- newOptions.domAttributes?.editor?.class || "",
- newOptions.defaultStyles ? styles.defaultStyles : "",
+ "bn-root",
+ "bn-editor",
+ newOptions.defaultStyles ? "bn-default-styles" : "",
newOptions.domAttributes?.editor?.class || ""
),
},
+ transformPasted,
},
};
@@ -359,11 +475,19 @@ export class BlockNoteEditor {
* Gets a snapshot of all top-level (non-nested) blocks in the editor.
* @returns A snapshot of all top-level (non-nested) blocks in the editor.
*/
- public get topLevelBlocks(): Block[] {
- const blocks: Block[] = [];
+ public get topLevelBlocks(): Block[] {
+ const blocks: Block[] = [];
this._tiptapEditor.state.doc.firstChild!.descendants((node) => {
- blocks.push(nodeToBlock(node, this.schema, this.blockCache));
+ blocks.push(
+ nodeToBlock(
+ node,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
+ this.blockCache
+ )
+ );
return false;
});
@@ -378,12 +502,12 @@ export class BlockNoteEditor {
*/
public getBlock(
blockIdentifier: BlockIdentifier
- ): Block | undefined {
+ ): Block | undefined {
const id =
typeof blockIdentifier === "string"
? blockIdentifier
: blockIdentifier.id;
- let newBlock: Block | undefined = undefined;
+ let newBlock: Block | undefined = undefined;
this._tiptapEditor.state.doc.firstChild!.descendants((node) => {
if (typeof newBlock !== "undefined") {
@@ -394,7 +518,13 @@ export class BlockNoteEditor {
return true;
}
- newBlock = nodeToBlock(node, this.schema, this.blockCache);
+ newBlock = nodeToBlock(
+ node,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
+ this.blockCache
+ );
return false;
});
@@ -408,7 +538,7 @@ export class BlockNoteEditor {
* @param reverse Whether the blocks should be traversed in reverse order.
*/
public forEachBlock(
- callback: (block: Block) => boolean,
+ callback: (block: Block) => boolean,
reverse = false
): void {
const blocks = this.topLevelBlocks.slice();
@@ -417,7 +547,9 @@ export class BlockNoteEditor {
blocks.reverse();
}
- function traverseBlockArray(blockArray: Block[]): boolean {
+ function traverseBlockArray(
+ blockArray: Block[]
+ ): boolean {
for (const block of blockArray) {
if (!callback(block)) {
return false;
@@ -458,7 +590,11 @@ export class BlockNoteEditor {
* Gets a snapshot of the current text cursor position.
* @returns A snapshot of the current text cursor position.
*/
- public getTextCursorPosition(): TextCursorPosition {
+ public getTextCursorPosition(): TextCursorPosition<
+ BSchema,
+ ISchema,
+ SSchema
+ > {
const { node, depth, startPos, endPos } = getBlockInfoFromPos(
this._tiptapEditor.state.doc,
this._tiptapEditor.state.selection.from
@@ -486,15 +622,33 @@ export class BlockNoteEditor {
}
return {
- block: nodeToBlock(node, this.schema, this.blockCache),
+ block: nodeToBlock(
+ node,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
+ this.blockCache
+ ),
prevBlock:
prevNode === undefined
? undefined
- : nodeToBlock(prevNode, this.schema, this.blockCache),
+ : nodeToBlock(
+ prevNode,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
+ this.blockCache
+ ),
nextBlock:
nextNode === undefined
? undefined
- : nodeToBlock(nextNode, this.schema, this.blockCache),
+ : nodeToBlock(
+ nextNode,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
+ this.blockCache
+ ),
};
}
@@ -516,25 +670,42 @@ export class BlockNoteEditor {
posBeforeNode + 2
)!;
- // For blocks without inline content
- if (contentNode.type.spec.content === "") {
+ const contentType: "none" | "inline" | "table" =
+ this.blockSchema[contentNode.type.name]!.content;
+
+ if (contentType === "none") {
this._tiptapEditor.commands.setNodeSelection(startPos);
return;
}
- if (placement === "start") {
- this._tiptapEditor.commands.setTextSelection(startPos + 1);
+ if (contentType === "inline") {
+ if (placement === "start") {
+ this._tiptapEditor.commands.setTextSelection(startPos + 1);
+ } else {
+ this._tiptapEditor.commands.setTextSelection(
+ startPos + contentNode.nodeSize - 1
+ );
+ }
+ } else if (contentType === "table") {
+ if (placement === "start") {
+ // Need to offset the position as we have to get through the `tableRow`
+ // and `tableCell` nodes to get to the `tableParagraph` node we want to
+ // set the selection in.
+ this._tiptapEditor.commands.setTextSelection(startPos + 4);
+ } else {
+ this._tiptapEditor.commands.setTextSelection(
+ startPos + contentNode.nodeSize - 4
+ );
+ }
} else {
- this._tiptapEditor.commands.setTextSelection(
- startPos + contentNode.nodeSize - 1
- );
+ throw new UnreachableCaseError(contentType);
}
}
/**
* Gets a snapshot of the current selection.
*/
- public getSelection(): Selection | undefined {
+ public getSelection(): Selection | undefined {
// Either the TipTap selection is empty, or it's a node selection. In either
// case, it only spans one block, so we return undefined.
if (
@@ -545,8 +716,10 @@ export class BlockNoteEditor {
return undefined;
}
- const blocks: Block[] = [];
+ const blocks: Block[] = [];
+ // TODO: This adds all child blocks to the same array. Needs to find min
+ // depth and only add blocks at that depth.
this._tiptapEditor.state.doc.descendants((node, pos) => {
if (node.type.spec.group !== "blockContent") {
return true;
@@ -562,7 +735,9 @@ export class BlockNoteEditor {
blocks.push(
nodeToBlock(
this._tiptapEditor.state.doc.resolve(pos).node(),
- this.schema,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
this.blockCache
)
);
@@ -598,11 +773,11 @@ export class BlockNoteEditor {
* `referenceBlock`. Inserts the blocks at the start of the existing block's children if "nested" is used.
*/
public insertBlocks(
- blocksToInsert: PartialBlock[],
+ blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
placement: "before" | "after" | "nested" = "before"
): void {
- insertBlocks(blocksToInsert, referenceBlock, placement, this._tiptapEditor);
+ insertBlocks(blocksToInsert, referenceBlock, placement, this);
}
/**
@@ -614,7 +789,7 @@ export class BlockNoteEditor {
*/
public updateBlock(
blockToUpdate: BlockIdentifier,
- update: PartialBlock
+ update: PartialBlock
) {
updateBlock(blockToUpdate, update, this._tiptapEditor);
}
@@ -636,32 +811,28 @@ export class BlockNoteEditor {
*/
public replaceBlocks(
blocksToRemove: BlockIdentifier[],
- blocksToInsert: PartialBlock[]
+ blocksToInsert: PartialBlock[]
) {
- replaceBlocks(blocksToRemove, blocksToInsert, this._tiptapEditor);
+ replaceBlocks(blocksToRemove, blocksToInsert, this);
}
/**
* Gets the active text styles at the text cursor position or at the end of the current selection if it's active.
*/
public getActiveStyles() {
- const styles: Styles = {};
+ const styles: Styles = {};
const marks = this._tiptapEditor.state.selection.$to.marks();
- const toggleStyles = new Set([
- "bold",
- "italic",
- "underline",
- "strike",
- "code",
- ]);
- const colorStyles = new Set(["textColor", "backgroundColor"]);
-
for (const mark of marks) {
- if (toggleStyles.has(mark.type.name as ToggledStyle)) {
- styles[mark.type.name as ToggledStyle] = true;
- } else if (colorStyles.has(mark.type.name as ColorStyle)) {
- styles[mark.type.name as ColorStyle] = mark.attrs.color;
+ const config = this.styleSchema[mark.type.name];
+ if (!config) {
+ console.warn("mark not found in styleschema", mark.type.name);
+ continue;
+ }
+ if (config.propSchema === "boolean") {
+ (styles as any)[config.type] = true;
+ } else {
+ (styles as any)[config.type] = mark.attrs.stringValue;
}
}
@@ -672,23 +843,20 @@ export class BlockNoteEditor {
* Adds styles to the currently selected content.
* @param styles The styles to add.
*/
- public addStyles(styles: Styles) {
- const toggleStyles = new Set([
- "bold",
- "italic",
- "underline",
- "strike",
- "code",
- ]);
- const colorStyles = new Set(["textColor", "backgroundColor"]);
-
+ public addStyles(styles: Styles) {
this._tiptapEditor.view.focus();
for (const [style, value] of Object.entries(styles)) {
- if (toggleStyles.has(style as ToggledStyle)) {
+ const config = this.styleSchema[style];
+ if (!config) {
+ throw new Error(`style ${style} not found in styleSchema`);
+ }
+ if (config.propSchema === "boolean") {
this._tiptapEditor.commands.setMark(style);
- } else if (colorStyles.has(style as ColorStyle)) {
- this._tiptapEditor.commands.setMark(style, { color: value });
+ } else if (config.propSchema === "string") {
+ this._tiptapEditor.commands.setMark(style, { stringValue: value });
+ } else {
+ throw new UnreachableCaseError(config.propSchema);
}
}
}
@@ -697,7 +865,7 @@ export class BlockNoteEditor {
* Removes styles from the currently selected content.
* @param styles The styles to remove.
*/
- public removeStyles(styles: Styles) {
+ public removeStyles(styles: Styles) {
this._tiptapEditor.view.focus();
for (const style of Object.keys(styles)) {
@@ -709,23 +877,20 @@ export class BlockNoteEditor {
* Toggles styles on the currently selected content.
* @param styles The styles to toggle.
*/
- public toggleStyles(styles: Styles) {
- const toggleStyles = new Set([
- "bold",
- "italic",
- "underline",
- "strike",
- "code",
- ]);
- const colorStyles = new Set(["textColor", "backgroundColor"]);
-
+ public toggleStyles(styles: Styles) {
this._tiptapEditor.view.focus();
for (const [style, value] of Object.entries(styles)) {
- if (toggleStyles.has(style as ToggledStyle)) {
+ const config = this.styleSchema[style];
+ if (!config) {
+ throw new Error(`style ${style} not found in styleSchema`);
+ }
+ if (config.propSchema === "boolean") {
this._tiptapEditor.commands.toggleMark(style);
- } else if (colorStyles.has(style as ColorStyle)) {
- this._tiptapEditor.commands.toggleMark(style, { color: value });
+ } else if (config.propSchema === "string") {
+ this._tiptapEditor.commands.toggleMark(style, { stringValue: value });
+ } else {
+ throw new UnreachableCaseError(config.propSchema);
}
}
}
@@ -810,14 +975,21 @@ export class BlockNoteEditor {
this._tiptapEditor.commands.liftListItem("blockContainer");
}
+ // TODO: Fix when implementing HTML/Markdown import & export
/**
* Serializes blocks into an HTML string. To better conform to HTML standards, children of blocks which aren't list
* items are un-nested in the output HTML.
* @param blocks An array of blocks that should be serialized into HTML.
* @returns The blocks, serialized as an HTML string.
*/
- public async blocksToHTML(blocks: Block[]): Promise {
- return blocksToHTML(blocks, this._tiptapEditor.schema);
+ public async blocksToHTMLLossy(
+ blocks = this.topLevelBlocks
+ ): Promise {
+ const exporter = createExternalHTMLExporter(
+ this._tiptapEditor.schema,
+ this
+ );
+ return exporter.exportBlocks(blocks);
}
/**
@@ -827,8 +999,16 @@ export class BlockNoteEditor {
* @param html The HTML string to parse blocks from.
* @returns The blocks parsed from the HTML string.
*/
- public async HTMLToBlocks(html: string): Promise[]> {
- return HTMLToBlocks(html, this.schema, this._tiptapEditor.schema);
+ public async tryParseHTMLToBlocks(
+ html: string
+ ): Promise[]> {
+ return HTMLToBlocks(
+ html,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
+ this._tiptapEditor.schema
+ );
}
/**
@@ -837,8 +1017,10 @@ export class BlockNoteEditor {
* @param blocks An array of blocks that should be serialized into Markdown.
* @returns The blocks, serialized as a Markdown string.
*/
- public async blocksToMarkdown(blocks: Block[]): Promise {
- return blocksToMarkdown(blocks, this._tiptapEditor.schema);
+ public async blocksToMarkdownLossy(
+ blocks: Block[] = this.topLevelBlocks
+ ): Promise {
+ return blocksToMarkdown(blocks, this._tiptapEditor.schema, this);
}
/**
@@ -848,8 +1030,16 @@ export class BlockNoteEditor {
* @param markdown The Markdown string to parse blocks from.
* @returns The blocks parsed from the Markdown string.
*/
- public async markdownToBlocks(markdown: string): Promise[]> {
- return markdownToBlocks(markdown, this.schema, this._tiptapEditor.schema);
+ public async tryParseMarkdownToBlocks(
+ markdown: string
+ ): Promise[]> {
+ return markdownToBlocks(
+ markdown,
+ this.blockSchema,
+ this.inlineContentSchema,
+ this.styleSchema,
+ this._tiptapEditor.schema
+ );
}
/**
diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts
similarity index 58%
rename from packages/core/src/BlockNoteExtensions.ts
rename to packages/core/src/editor/BlockNoteExtensions.ts
index d328c329b7..ec2277e5b9 100644
--- a/packages/core/src/BlockNoteExtensions.ts
+++ b/packages/core/src/editor/BlockNoteExtensions.ts
@@ -1,45 +1,49 @@
import { Extensions, extensions } from "@tiptap/core";
-import { BlockNoteEditor } from "./BlockNoteEditor";
+import type { BlockNoteEditor } from "./BlockNoteEditor";
-import { Bold } from "@tiptap/extension-bold";
-import { Code } from "@tiptap/extension-code";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import { Dropcursor } from "@tiptap/extension-dropcursor";
import { Gapcursor } from "@tiptap/extension-gapcursor";
import { HardBreak } from "@tiptap/extension-hard-break";
import { History } from "@tiptap/extension-history";
-import { Italic } from "@tiptap/extension-italic";
import { Link } from "@tiptap/extension-link";
-import { Strike } from "@tiptap/extension-strike";
import { Text } from "@tiptap/extension-text";
-import { Underline } from "@tiptap/extension-underline";
import * as Y from "yjs";
-import styles from "./editor.module.css";
-import { BackgroundColorExtension } from "./extensions/BackgroundColor/BackgroundColorExtension";
-import { BackgroundColorMark } from "./extensions/BackgroundColor/BackgroundColorMark";
-import { BlockContainer, BlockGroup, Doc } from "./extensions/Blocks";
+import { createCopyToClipboardExtension } from "../api/exporters/copyExtension";
+import { createPasteFromClipboardExtension } from "../api/parsers/pasteExtension";
+import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension";
+import { Placeholder } from "../extensions/Placeholder/PlaceholderExtension";
+import { TextAlignmentExtension } from "../extensions/TextAlignment/TextAlignmentExtension";
+import { TextColorExtension } from "../extensions/TextColor/TextColorExtension";
+import { TrailingNode } from "../extensions/TrailingNode/TrailingNodeExtension";
+import UniqueID from "../extensions/UniqueID/UniqueID";
+import { BlockContainer, BlockGroup, Doc } from "../pm-nodes";
import {
BlockNoteDOMAttributes,
BlockSchema,
-} from "./extensions/Blocks/api/blockTypes";
-import { CustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization";
-import blockStyles from "./extensions/Blocks/nodes/Block.module.css";
-import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension";
-import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension";
-import { TextColorExtension } from "./extensions/TextColor/TextColorExtension";
-import { TextColorMark } from "./extensions/TextColor/TextColorMark";
-import { TrailingNode } from "./extensions/TrailingNode/TrailingNodeExtension";
-import UniqueID from "./extensions/UniqueID/UniqueID";
+ BlockSpecs,
+ InlineContentSchema,
+ InlineContentSpecs,
+ StyleSchema,
+ StyleSpecs,
+} from "../schema";
/**
* Get all the Tiptap extensions BlockNote is configured with by default
*/
-export const getBlockNoteExtensions = (opts: {
- editor: BlockNoteEditor;
+export const getBlockNoteExtensions = <
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(opts: {
+ editor: BlockNoteEditor;
domAttributes: Partial;
blockSchema: BSchema;
+ blockSpecs: BlockSpecs;
+ inlineContentSpecs: InlineContentSpecs;
+ styleSpecs: StyleSpecs;
collaboration?: {
fragment: Y.XmlFragment;
user: {
@@ -62,9 +66,6 @@ export const getBlockNoteExtensions = (opts: {
// DropCursor,
Placeholder.configure({
- emptyNodeClass: blockStyles.isEmpty,
- hasAnchorClass: blockStyles.hasAnchor,
- isFilterClass: blockStyles.isFilter,
includeChildren: true,
showOnlyCurrent: false,
}),
@@ -78,33 +79,51 @@ export const getBlockNoteExtensions = (opts: {
Text,
// marks:
- Bold,
- Code,
- Italic,
- Strike,
- Underline,
Link,
- TextColorMark,
+ ...Object.values(opts.styleSpecs).map((styleSpec) => {
+ return styleSpec.implementation.mark;
+ }),
+
TextColorExtension,
- BackgroundColorMark,
+
BackgroundColorExtension,
TextAlignmentExtension,
// nodes
Doc,
BlockContainer.configure({
+ editor: opts.editor as any,
domAttributes: opts.domAttributes,
}),
BlockGroup.configure({
domAttributes: opts.domAttributes,
}),
- ...Object.values(opts.blockSchema).map((blockSpec) =>
- blockSpec.node.configure({
- editor: opts.editor,
- domAttributes: opts.domAttributes,
- })
- ),
- CustomBlockSerializerExtension,
+ ...Object.values(opts.inlineContentSpecs)
+ .filter((a) => a.config !== "link" && a.config !== "text")
+ .map((inlineContentSpec) => {
+ return inlineContentSpec.implementation!.node.configure({
+ editor: opts.editor as any,
+ });
+ }),
+
+ ...Object.values(opts.blockSpecs).flatMap((blockSpec) => {
+ return [
+ // dependent nodes (e.g.: tablecell / row)
+ ...(blockSpec.implementation.requiredExtensions || []).map((ext) =>
+ ext.configure({
+ editor: opts.editor,
+ domAttributes: opts.domAttributes,
+ })
+ ),
+ // the actual node itself
+ blockSpec.implementation.node.configure({
+ editor: opts.editor,
+ domAttributes: opts.domAttributes,
+ }),
+ ];
+ }),
+ createCopyToClipboardExtension(opts.editor),
+ createPasteFromClipboardExtension(opts.editor),
Dropcursor.configure({ width: 5, color: "#ddeeff" }),
// This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command),
@@ -122,12 +141,12 @@ export const getBlockNoteExtensions = (opts: {
const defaultRender = (user: { color: string; name: string }) => {
const cursor = document.createElement("span");
- cursor.classList.add(styles["collaboration-cursor__caret"]);
+ cursor.classList.add("collaboration-cursor__caret");
cursor.setAttribute("style", `border-color: ${user.color}`);
const label = document.createElement("span");
- label.classList.add(styles["collaboration-cursor__label"]);
+ label.classList.add("collaboration-cursor__label");
label.setAttribute("style", `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
diff --git a/packages/core/src/editor/README.md b/packages/core/src/editor/README.md
new file mode 100644
index 0000000000..f87722a4e9
--- /dev/null
+++ b/packages/core/src/editor/README.md
@@ -0,0 +1,3 @@
+### @blocknote/core/src/editor
+
+Contains main functions to set up the editor
\ No newline at end of file
diff --git a/packages/core/src/editor/cursorPositionTypes.ts b/packages/core/src/editor/cursorPositionTypes.ts
new file mode 100644
index 0000000000..b7fa932475
--- /dev/null
+++ b/packages/core/src/editor/cursorPositionTypes.ts
@@ -0,0 +1,16 @@
+import {
+ Block,
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../schema";
+
+export type TextCursorPosition<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+> = {
+ block: Block;
+ prevBlock: Block | undefined;
+ nextBlock: Block | undefined;
+};
diff --git a/packages/core/src/editor.module.css b/packages/core/src/editor/editor.css
similarity index 71%
rename from packages/core/src/editor.module.css
rename to packages/core/src/editor/editor.css
index 76c65d9d13..9e5761a45b 100644
--- a/packages/core/src/editor.module.css
+++ b/packages/core/src/editor/editor.css
@@ -1,6 +1,6 @@
-@import url("./assets/fonts-inter.css");
+@import url("../assets/fonts-inter.css");
-.bnEditor {
+.bn-editor {
outline: none;
padding-inline: 54px;
@@ -11,31 +11,31 @@
}
/*
-bnRoot should be applied to all top-level elements
+bn-root should be applied to all top-level elements
This includes the Prosemirror editor, but also
element such as
Tippy popups that are appended to document.body directly
*/
-.bnRoot {
+.bn-root {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
-.bnRoot *,
-.bnRoot *::before,
-.bnRoot *::after {
+.bn-root *,
+.bn-root *::before,
+.bn-root *::after {
-webkit-box-sizing: inherit;
-moz-box-sizing: inherit;
box-sizing: inherit;
}
/* reset styles, they will be set on blockContent */
-.defaultStyles p,
-.defaultStyles h1,
-.defaultStyles h2,
-.defaultStyles h3,
-.defaultStyles li {
+.bn-default-styles p,
+.bn-default-styles h1,
+.bn-default-styles h2,
+.bn-default-styles h3,
+.bn-default-styles li {
all: unset;
margin: 0;
padding: 0;
@@ -44,7 +44,7 @@ Tippy popups that are appended to document.body directly
min-width: 2px !important;
}
-.defaultStyles {
+.bn-default-styles {
font-size: 16px;
font-weight: normal;
font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont,
@@ -54,9 +54,16 @@ Tippy popups that are appended to document.body directly
-moz-osx-font-smoothing: grayscale;
}
-.dragPreview {
+.bn-table-drop-cursor {
position: absolute;
- top: -1000px;
+ z-index: 20;
+ background-color: #adf;
+ pointer-events: none;
+}
+
+.bn-drag-preview {
+ position: absolute;
+ left: -100000px;
}
/* Give a remote user a caret */
@@ -85,3 +92,23 @@ Tippy popups that are appended to document.body directly
user-select: none;
white-space: nowrap;
}
+
+/* table related: */
+.bn-editor table {
+ width: auto !important;
+}
+.bn-editor th,
+.bn-editor td {
+ min-width: 1em;
+ border: 1px solid #ddd;
+ padding: 3px 5px;
+}
+
+.bn-editor .tableWrapper {
+ margin: 1em 0;
+}
+
+.bn-editor th {
+ font-weight: bold;
+ text-align: left;
+}
diff --git a/packages/core/src/editor/selectionTypes.ts b/packages/core/src/editor/selectionTypes.ts
new file mode 100644
index 0000000000..aef65b5f08
--- /dev/null
+++ b/packages/core/src/editor/selectionTypes.ts
@@ -0,0 +1,14 @@
+import {
+ Block,
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../schema";
+
+export type Selection<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+> = {
+ blocks: Block[];
+};
diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts
new file mode 100644
index 0000000000..ac3d2eb578
--- /dev/null
+++ b/packages/core/src/editor/transformPasted.ts
@@ -0,0 +1,58 @@
+import { Fragment, Slice } from "@tiptap/pm/model";
+import { EditorView } from "@tiptap/pm/view";
+
+// helper function to remove a child from a fragment
+function removeChild(node: Fragment, n: number) {
+ const children: any[] = [];
+ node.forEach((child, _, i) => {
+ if (i !== n) {
+ children.push(child);
+ }
+ });
+ return Fragment.from(children);
+}
+
+/**
+ * fix for https://github.com/ProseMirror/prosemirror/issues/1430#issuecomment-1822570821
+ *
+ * Without this fix, pasting two paragraphs would cause the second one to be indented in the other
+ * this fix wraps every element in the slice in it's own blockContainer, to prevent Prosemirror from nesting the
+ * elements on paste.
+ *
+ * The exception is when we encounter blockGroups with listitems, because those actually should be nested
+ */
+export function transformPasted(slice: Slice, view: EditorView) {
+ let f = Fragment.from(slice.content);
+ for (let i = 0; i < f.childCount; i++) {
+ if (f.child(i).type.spec.group === "blockContent") {
+ const content = [f.child(i)];
+
+ // when there is a blockGroup with lists, it should be nested in the new blockcontainer
+ // (if we remove this if-block, the nesting bug will be fixed, but lists won't be nested correctly)
+ if (
+ i + 1 < f.childCount &&
+ f.child(i + 1).type.spec.group === "blockGroup"
+ ) {
+ const nestedChild = f
+ .child(i + 1)
+ .child(0)
+ .child(0);
+
+ if (
+ nestedChild.type.name === "bulletListItem" ||
+ nestedChild.type.name === "numberedListItem"
+ ) {
+ content.push(f.child(i + 1));
+ f = removeChild(f, i + 1);
+ }
+ }
+ const container = view.state.schema.nodes.blockContainer.create(
+ undefined,
+ content
+ );
+ f = f.replaceChild(i, container);
+ }
+ }
+
+ return new Slice(f, slice.openStart, slice.openEnd);
+}
diff --git a/packages/core/src/shared/BaseUiElementTypes.ts b/packages/core/src/extensions-shared/BaseUiElementTypes.ts
similarity index 100%
rename from packages/core/src/shared/BaseUiElementTypes.ts
rename to packages/core/src/extensions-shared/BaseUiElementTypes.ts
diff --git a/packages/core/src/extensions-shared/README.md b/packages/core/src/extensions-shared/README.md
new file mode 100644
index 0000000000..89c300fd7d
--- /dev/null
+++ b/packages/core/src/extensions-shared/README.md
@@ -0,0 +1,3 @@
+### @blocknote/core/src/extensions-shared
+
+Helper functions / base plugins for @blocknote/core/src/extensions
\ No newline at end of file
diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionItem.ts b/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts
similarity index 100%
rename from packages/core/src/shared/plugins/suggestion/SuggestionItem.ts
rename to packages/core/src/extensions-shared/suggestion/SuggestionItem.ts
diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts
similarity index 94%
rename from packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
rename to packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts
index a3c4e4e010..23ff2ad1b6 100644
--- a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts
+++ b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts
@@ -1,11 +1,13 @@
+import { findParentNode } from "@tiptap/core";
import { EditorState, Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
-import { BlockNoteEditor } from "../../../BlockNoteEditor";
-import { BlockSchema } from "../../../extensions/Blocks/api/blockTypes";
-import { findBlock } from "../../../extensions/Blocks/helpers/findBlock";
-import { BaseUiElementState } from "../../BaseUiElementTypes";
+import type { BlockNoteEditor } from "../../editor/BlockNoteEditor";
+import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema";
+import { BaseUiElementState } from "../BaseUiElementTypes";
import { SuggestionItem } from "./SuggestionItem";
+const findBlock = findParentNode((node) => node.type.name === "blockContainer");
+
export type SuggestionsMenuState =
BaseUiElementState & {
// The suggested items to display.
@@ -16,7 +18,9 @@ export type SuggestionsMenuState =
class SuggestionsMenuView<
T extends SuggestionItem,
- BSchema extends BlockSchema
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
> {
private suggestionsMenuState?: SuggestionsMenuState;
public updateSuggestionsMenu: () => void;
@@ -24,7 +28,7 @@ class SuggestionsMenuView<
pluginState: SuggestionPluginState;
constructor(
- private readonly editor: BlockNoteEditor,
+ private readonly editor: BlockNoteEditor,
private readonly pluginKey: PluginKey,
updateSuggestionsMenu: (
suggestionsMenuState: SuggestionsMenuState
@@ -147,9 +151,11 @@ function getDefaultPluginState<
*/
export const setupSuggestionsMenu = <
T extends SuggestionItem,
- BSchema extends BlockSchema
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
>(
- editor: BlockNoteEditor,
+ editor: BlockNoteEditor,
updateSuggestionsMenu: (
suggestionsMenuState: SuggestionsMenuState
) => void,
@@ -159,7 +165,7 @@ export const setupSuggestionsMenu = <
items: (query: string) => T[] = () => [],
onSelectItem: (props: {
item: T;
- editor: BlockNoteEditor;
+ editor: BlockNoteEditor;
}) => void = () => {
// noop
}
@@ -169,7 +175,7 @@ export const setupSuggestionsMenu = <
throw new Error("'char' should be a single character");
}
- let suggestionsPluginView: SuggestionsMenuView;
+ let suggestionsPluginView: SuggestionsMenuView;
const deactivate = (view: EditorView) => {
view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true }));
@@ -180,7 +186,7 @@ export const setupSuggestionsMenu = <
key: pluginKey,
view: () => {
- suggestionsPluginView = new SuggestionsMenuView(
+ suggestionsPluginView = new SuggestionsMenuView(
editor,
pluginKey,
@@ -398,7 +404,7 @@ export const setupSuggestionsMenu = <
blockNode.pos + blockNode.node.nodeSize,
{
nodeName: "span",
- class: "suggestion-decorator",
+ class: "bn-suggestion-decorator",
"data-decoration-id": decorationId,
}
),
@@ -412,7 +418,7 @@ export const setupSuggestionsMenu = <
queryStartPos,
{
nodeName: "span",
- class: "suggestion-decorator",
+ class: "bn-suggestion-decorator",
"data-decoration-id": decorationId,
}
),
diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
index caa76f6416..3f74150ec0 100644
--- a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
+++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts
@@ -1,17 +1,5 @@
import { Extension } from "@tiptap/core";
-import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos";
-import { defaultProps } from "../Blocks/api/defaultProps";
-
-declare module "@tiptap/core" {
- interface Commands {
- blockBackgroundColor: {
- setBlockBackgroundColor: (
- posInBlock: number,
- color: string
- ) => ReturnType;
- };
- }
-}
+import { defaultProps } from "../../blocks/defaultProps";
export const BackgroundColorExtension = Extension.create({
name: "blockBackgroundColor",
@@ -37,27 +25,4 @@ export const BackgroundColorExtension = Extension.create({
},
];
},
-
- addCommands() {
- return {
- setBlockBackgroundColor:
- (posInBlock, color) =>
- ({ state, view }) => {
- const blockInfo = getBlockInfoFromPos(state.doc, posInBlock);
- if (blockInfo === undefined) {
- return false;
- }
-
- state.tr.setNodeAttribute(
- blockInfo.startPos - 1,
- "backgroundColor",
- color
- );
-
- view.focus();
-
- return true;
- },
- };
- },
});
diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
index adcdca387f..647705acdc 100644
--- a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
+++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts
@@ -1,24 +1,16 @@
import { Mark } from "@tiptap/core";
-import { defaultProps } from "../Blocks/api/defaultProps";
+import { createStyleSpecFromTipTapMark } from "../../schema";
-declare module "@tiptap/core" {
- interface Commands {
- backgroundColor: {
- setBackgroundColor: (color: string) => ReturnType;
- };
- }
-}
-
-export const BackgroundColorMark = Mark.create({
+const BackgroundColorMark = Mark.create({
name: "backgroundColor",
addAttributes() {
return {
- color: {
+ stringValue: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-background-color"),
renderHTML: (attributes) => ({
- "data-background-color": attributes.color,
+ "data-background-color": attributes.stringValue,
}),
},
};
@@ -34,7 +26,9 @@ export const BackgroundColorMark = Mark.create({
}
if (element.hasAttribute("data-background-color")) {
- return { color: element.getAttribute("data-background-color") };
+ return {
+ stringValue: element.getAttribute("data-background-color"),
+ };
}
return false;
@@ -46,18 +40,9 @@ export const BackgroundColorMark = Mark.create({
renderHTML({ HTMLAttributes }) {
return ["span", HTMLAttributes, 0];
},
-
- addCommands() {
- return {
- setBackgroundColor:
- (color) =>
- ({ commands }) => {
- if (color !== defaultProps.backgroundColor.default) {
- return commands.setMark(this.name, { color: color });
- }
-
- return commands.unsetMark(this.name);
- },
- };
- },
});
+
+export const BackgroundColor = createStyleSpecFromTipTapMark(
+ BackgroundColorMark,
+ "string"
+);
diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts
deleted file mode 100644
index ec8a07e8b9..0000000000
--- a/packages/core/src/extensions/Blocks/api/block.ts
+++ /dev/null
@@ -1,307 +0,0 @@
-import { Attribute, Attributes, Node } from "@tiptap/core";
-import { BlockNoteDOMAttributes, BlockNoteEditor } from "../../..";
-import styles from "../nodes/Block.module.css";
-import {
- BlockConfig,
- BlockSchema,
- BlockSpec,
- PropSchema,
- TipTapNode,
- TipTapNodeConfig,
-} from "./blockTypes";
-import { mergeCSSClasses } from "../../../shared/utils";
-import { ParseRule } from "prosemirror-model";
-
-export function camelToDataKebab(str: string): string {
- return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
-}
-
-// Function that uses the 'propSchema' of a blockConfig to create a TipTap
-// node's `addAttributes` property.
-export function propsToAttributes<
- BType extends string,
- PSchema extends PropSchema,
- ContainsInlineContent extends boolean,
- BSchema extends BlockSchema
->(
- blockConfig: Omit<
- BlockConfig,
- "render"
- >
-): Attributes {
- const tiptapAttributes: Record = {};
-
- Object.entries(blockConfig.propSchema).forEach(([name, spec]) => {
- tiptapAttributes[name] = {
- default: spec.default,
- keepOnSplit: true,
- // Props are displayed in kebab-case as HTML attributes. If a prop's
- // value is the same as its default, we don't display an HTML
- // attribute for it.
- parseHTML: (element) => {
- const value = element.getAttribute(camelToDataKebab(name));
-
- if (value === null) {
- return null;
- }
-
- if (typeof spec.default === "boolean") {
- if (value === "true") {
- return true;
- }
-
- if (value === "false") {
- return false;
- }
-
- return null;
- }
-
- if (typeof spec.default === "number") {
- const asNumber = parseFloat(value);
- const isNumeric =
- !Number.isNaN(asNumber) && Number.isFinite(asNumber);
-
- if (isNumeric) {
- return asNumber;
- }
-
- return null;
- }
-
- return value;
- },
- renderHTML: (attributes) =>
- attributes[name] !== spec.default
- ? {
- [camelToDataKebab(name)]: attributes[name],
- }
- : {},
- };
- });
-
- return tiptapAttributes;
-}
-
-// Function that uses the 'parse' function of a blockConfig to create a
-// TipTap node's `parseHTML` property. This is only used for parsing content
-// from the clipboard.
-export function parse<
- BType extends string,
- PSchema extends PropSchema,
- ContainsInlineContent extends boolean,
- BSchema extends BlockSchema
->(
- blockConfig: Omit<
- BlockConfig,
- "render"
- >
-): ParseRule[] {
- return [
- {
- tag: "div[data-content-type=" + blockConfig.type + "]",
- },
- ];
-}
-
-// Function that uses the 'render' function of a blockConfig to create a
-// TipTap node's `renderHTML` property. Since custom blocks use node views,
-// this is only used for serializing content to the clipboard.
-export function render<
- BType extends string,
- PSchema extends PropSchema,
- ContainsInlineContent extends boolean,
- BSchema extends BlockSchema
->(
- blockConfig: Omit<
- BlockConfig,
- "render"
- >,
- HTMLAttributes: Record
-) {
- // Create blockContent element
- const blockContent = document.createElement("div");
- // Add blockContent HTML attribute
- blockContent.setAttribute("data-content-type", blockConfig.type);
- // Add props as HTML attributes in kebab-case with "data-" prefix
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- blockContent.setAttribute(attribute, value);
- }
-
- // TODO: This only works for content copied within BlockNote.
- // Creates contentDOM element to serialize inline content into.
- let contentDOM: HTMLDivElement | undefined;
- if (blockConfig.containsInlineContent) {
- contentDOM = document.createElement("div");
- blockContent.appendChild(contentDOM);
- } else {
- contentDOM = undefined;
- }
-
- return contentDOM !== undefined
- ? {
- dom: blockContent,
- contentDOM: contentDOM,
- }
- : {
- dom: blockContent,
- };
-}
-
-// A function to create custom block for API consumers
-// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
-export function createBlockSpec<
- BType extends string,
- PSchema extends PropSchema,
- ContainsInlineContent extends false,
- BSchema extends BlockSchema
->(
- blockConfig: BlockConfig
-): BlockSpec {
- const node = createTipTapBlock<
- BType,
- ContainsInlineContent,
- {
- editor: BlockNoteEditor;
- domAttributes?: BlockNoteDOMAttributes;
- }
- >({
- name: blockConfig.type,
- content: (blockConfig.containsInlineContent
- ? "inline*"
- : "") as ContainsInlineContent extends true ? "inline*" : "",
- selectable: true,
-
- addAttributes() {
- return propsToAttributes(blockConfig);
- },
-
- parseHTML() {
- return parse(blockConfig);
- },
-
- renderHTML({ HTMLAttributes }) {
- return render(blockConfig, HTMLAttributes);
- },
-
- addNodeView() {
- return ({ HTMLAttributes, getPos }) => {
- // Create blockContent element
- const blockContent = document.createElement("div");
- // Add custom HTML attributes
- const blockContentDOMAttributes =
- this.options.domAttributes?.blockContent || {};
- for (const [attribute, value] of Object.entries(
- blockContentDOMAttributes
- )) {
- if (attribute !== "class") {
- blockContent.setAttribute(attribute, value);
- }
- }
- // Set blockContent & custom classes
- blockContent.className = mergeCSSClasses(
- styles.blockContent,
- blockContentDOMAttributes.class
- );
- // Add blockContent HTML attribute
- blockContent.setAttribute("data-content-type", blockConfig.type);
- // Add props as HTML attributes in kebab-case with "data-" prefix
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- blockContent.setAttribute(attribute, value);
- }
-
- // Gets BlockNote editor instance
- const editor = this.options.editor! as BlockNoteEditor<
- BSchema & {
- [k in BType]: BlockSpec;
- }
- >;
- // Gets position of the node
- if (typeof getPos === "boolean") {
- throw new Error(
- "Cannot find node position as getPos is a boolean, not a function."
- );
- }
- const pos = getPos();
- // Gets TipTap editor instance
- const tipTapEditor = editor._tiptapEditor;
- // Gets parent blockContainer node
- const blockContainer = tipTapEditor.state.doc.resolve(pos!).node();
- // Gets block identifier
- const blockIdentifier = blockContainer.attrs.id;
-
- // Get the block
- const block = editor.getBlock(blockIdentifier)!;
- if (block.type !== blockConfig.type) {
- throw new Error("Block type does not match");
- }
-
- // Render elements
- const rendered = blockConfig.render(block as any, editor);
- // Add HTML attributes to contentDOM
- if (blockConfig.containsInlineContent) {
- const contentDOM = (rendered as { contentDOM: HTMLElement })
- .contentDOM;
-
- const inlineContentDOMAttributes =
- this.options.domAttributes?.inlineContent || {};
- // Add custom HTML attributes
- for (const [attribute, value] of Object.entries(
- inlineContentDOMAttributes
- )) {
- if (attribute !== "class") {
- contentDOM.setAttribute(attribute, value);
- }
- }
- // Merge existing classes with inlineContent & custom classes
- contentDOM.className = mergeCSSClasses(
- contentDOM.className,
- styles.inlineContent,
- inlineContentDOMAttributes.class
- );
- }
- // Add elements to blockContent
- blockContent.appendChild(rendered.dom);
-
- return "contentDOM" in rendered
- ? {
- dom: blockContent,
- contentDOM: rendered.contentDOM,
- destroy: rendered.destroy,
- }
- : {
- dom: blockContent,
- destroy: rendered.destroy,
- };
- };
- },
- });
-
- return {
- node: node as TipTapNode,
- propSchema: blockConfig.propSchema,
- };
-}
-
-export function createTipTapBlock<
- Type extends string,
- ContainsInlineContent extends boolean,
- Options extends {
- domAttributes?: BlockNoteDOMAttributes;
- } = {
- domAttributes?: BlockNoteDOMAttributes;
- },
- Storage = any
->(
- config: TipTapNodeConfig
-): TipTapNode {
- // Type cast is needed as Node.name is mutable, though there is basically no
- // reason to change it after creation. Alternative is to wrap Node in a new
- // class, which I don't think is worth it since we'd only be changing 1
- // attribute to be read only.
- return Node.create({
- ...config,
- group: "blockContent",
- content: config.content,
- }) as TipTapNode;
-}
diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts
deleted file mode 100644
index cb686eabc7..0000000000
--- a/packages/core/src/extensions/Blocks/api/blockTypes.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-/** Define the main block types **/
-import { Node, NodeConfig } from "@tiptap/core";
-import { BlockNoteEditor } from "../../../BlockNoteEditor";
-import { InlineContent, PartialInlineContent } from "./inlineContentTypes";
-import { DefaultBlockSchema } from "./defaultBlocks";
-
-export type BlockNoteDOMElement =
- | "editor"
- | "blockContainer"
- | "blockGroup"
- | "blockContent"
- | "inlineContent";
-
-export type BlockNoteDOMAttributes = Partial<{
- [DOMElement in BlockNoteDOMElement]: Record;
-}>;
-
-// A configuration for a TipTap node, but with stricter type constraints on the
-// "name" and "content" properties. The "name" property is now always a string
-// literal type, and the "content" property can only be "inline*" or "". Used as
-// the parameter in `createTipTapNode`. The "group" is also removed as
-// `createTipTapNode` always sets it to "blockContent"
-export type TipTapNodeConfig<
- Name extends string,
- ContainsInlineContent extends boolean,
- Options extends {
- domAttributes?: BlockNoteDOMAttributes;
- } = {
- domAttributes?: BlockNoteDOMAttributes;
- },
- Storage = any
-> = {
- [K in keyof NodeConfig]: K extends "name"
- ? Name
- : K extends "content"
- ? ContainsInlineContent extends true
- ? "inline*"
- : ""
- : K extends "group"
- ? never
- : NodeConfig[K];
-} & {
- name: Name;
- content: ContainsInlineContent extends true ? "inline*" : "";
-};
-
-// A TipTap node with stricter type constraints on the "name", "group", and
-// "content properties. The "name" property is now a string literal type, and
-// the "blockGroup" property is now "blockContent", and the "content" property
-// can only be "inline*" or "". Returned by `createTipTapNode`.
-export type TipTapNode<
- Name extends string,
- ContainsInlineContent extends boolean,
- Options extends {
- domAttributes?: BlockNoteDOMAttributes;
- } = {
- domAttributes?: BlockNoteDOMAttributes;
- },
- Storage = any
-> = {
- [Key in keyof Node]: Key extends "name"
- ? Name
- : Key extends "config"
- ? {
- [ConfigKey in keyof Node<
- Options,
- Storage
- >["config"]]: ConfigKey extends "group"
- ? "blockContent"
- : ConfigKey extends "content"
- ? ContainsInlineContent extends true
- ? "inline*"
- : ""
- : NodeConfig["config"][ConfigKey];
- } & {
- group: "blockContent";
- content: ContainsInlineContent extends true ? "inline*" : "";
- }
- : Node["config"][Key];
-};
-
-// Defines a single prop spec, which includes the default value the prop should
-// take and possible values it can take.
-export type PropSpec = {
- values?: readonly PType[];
- default: PType;
-};
-
-// Defines multiple block prop specs. The key of each prop is the name of the
-// prop, while the value is a corresponding prop spec. This should be included
-// in a block config or schema. From a prop schema, we can derive both the props'
-// internal implementation (as TipTap node attributes) and the type information
-// for the external API.
-export type PropSchema = Record>;
-
-// Defines Props objects for use in Block objects in the external API. Converts
-// each prop spec into a union type of its possible values, or a string if no
-// values are specified.
-export type Props = {
- [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean
- ? PSchema[PName]["values"] extends readonly boolean[]
- ? PSchema[PName]["values"][number]
- : boolean
- : PSchema[PName]["default"] extends number
- ? PSchema[PName]["values"] extends readonly number[]
- ? PSchema[PName]["values"][number]
- : number
- : PSchema[PName]["default"] extends string
- ? PSchema[PName]["values"] extends readonly string[]
- ? PSchema[PName]["values"][number]
- : string
- : never;
-};
-
-// Defines the config for a single block. Meant to be used as an argument to
-// `createBlockSpec`, which will create a new block spec from it. This is the
-// main way we expect people to create custom blocks as consumers don't need to
-// know anything about the TipTap API since the associated nodes are created
-// automatically.
-export type BlockConfig<
- Type extends string,
- PSchema extends PropSchema,
- ContainsInlineContent extends boolean,
- BSchema extends BlockSchema
-> = {
- // Attributes to define block in the API as well as a TipTap node.
- type: Type;
- readonly propSchema: PSchema;
-
- // Additional attributes to help define block as a TipTap node.
- containsInlineContent: ContainsInlineContent;
- render: (
- /**
- * The custom block to render
- */
- block: SpecificBlock<
- BSchema & {
- [k in Type]: BlockSpec;
- },
- Type
- >,
- /**
- * The BlockNote editor instance
- * This is typed generically. If you want an editor with your custom schema, you need to
- * cast it manually, e.g.: `const e = editor as BlockNoteEditor;`
- */
- editor: BlockNoteEditor<
- BSchema & { [k in Type]: BlockSpec }
- >
- // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations
- // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics
- ) => ContainsInlineContent extends true
- ? {
- dom: HTMLElement;
- contentDOM: HTMLElement;
- destroy?: () => void;
- }
- : {
- dom: HTMLElement;
- destroy?: () => void;
- };
-};
-
-// Defines a single block spec, which includes the props that the block has and
-// the TipTap node used to implement it. Usually created using `createBlockSpec`
-// though it can also be defined from scratch by providing your own TipTap node,
-// allowing for more advanced custom blocks.
-export type BlockSpec<
- Type extends string,
- PSchema extends PropSchema,
- ContainsInlineContent extends boolean
-> = {
- node: TipTapNode;
- readonly propSchema: PSchema;
-};
-
-// Utility type. For a given object block schema, ensures that the key of each
-// block spec matches the name of the TipTap node in it.
-type NamesMatch<
- Blocks extends Record>
-> = Blocks extends {
- [Type in keyof Blocks]: Type extends string
- ? Blocks[Type] extends BlockSpec
- ? Blocks[Type]
- : never
- : never;
-}
- ? Blocks
- : never;
-
-// Defines multiple block specs. Also ensures that the key of each block schema
-// is the same as name of the TipTap node in it. This should be passed in the
-// `blocks` option of the BlockNoteEditor. From a block schema, we can derive
-// both the blocks' internal implementation (as TipTap nodes) and the type
-// information for the external API.
-export type BlockSchema = NamesMatch<
- Record>
->;
-
-// Converts each block spec into a Block object without children. We later merge
-// them into a union type and add a children property to create the Block and
-// PartialBlock objects we use in the external API.
-type BlocksWithoutChildren = {
- [BType in keyof BSchema]: {
- id: string;
- type: BType;
- props: Props;
- content: BSchema[BType]["node"]["config"]["content"] extends "inline*"
- ? InlineContent[]
- : undefined;
- };
-};
-
-// Converts each block spec into a Block object without children, merges them
-// into a union type, and adds a children property
-export type Block =
- BlocksWithoutChildren[keyof BlocksWithoutChildren] & {
- children: Block[];
- };
-
-export type SpecificBlock<
- BSchema extends BlockSchema,
- BlockType extends keyof BSchema
-> = BlocksWithoutChildren[BlockType] & {
- children: Block