@blocknote/core 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/blocknote.cjs +9 -9
  2. package/dist/blocknote.cjs.map +1 -1
  3. package/dist/blocknote.js +1822 -1688
  4. package/dist/blocknote.js.map +1 -1
  5. package/dist/tsconfig.tsbuildinfo +1 -1
  6. package/dist/webpack-stats.json +1 -1
  7. package/package.json +3 -3
  8. package/src/api/__snapshots__/blocks-indented-changed.json +129 -0
  9. package/src/api/__snapshots__/blocks-moved-deeper-into-nesting.json +164 -0
  10. package/src/api/__snapshots__/blocks-moved-multiple-in-same-transaction.json +188 -0
  11. package/src/api/__snapshots__/blocks-moved-to-different-parent.json +78 -0
  12. package/src/api/__snapshots__/blocks-moved-to-root-level.json +78 -0
  13. package/src/api/__snapshots__/blocks-outdented-changed.json +129 -0
  14. package/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +58 -59
  15. package/src/api/clipboard/fromClipboard/handleFileInsertion.ts +12 -1
  16. package/src/api/nodeUtil.test.ts +228 -1
  17. package/src/api/nodeUtil.ts +139 -118
  18. package/src/api/parsers/markdown/detectMarkdown.test.ts +211 -0
  19. package/src/api/parsers/markdown/detectMarkdown.ts +3 -2
  20. package/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +2 -1
  21. package/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +2 -1
  22. package/src/blocks/QuoteBlockContent/QuoteBlockContent.ts +23 -1
  23. package/src/blocks/ToggleWrapper/createToggleWrapper.ts +2 -0
  24. package/src/blocks/defaultBlockTypeGuards.ts +30 -0
  25. package/src/editor/BlockNoteEditor.ts +105 -38
  26. package/src/editor/BlockNoteExtensions.ts +3 -5
  27. package/src/exporter/Exporter.ts +2 -0
  28. package/src/exporter/mapping.ts +1 -0
  29. package/src/extensions/BlockChange/BlockChangePlugin.ts +66 -0
  30. package/src/extensions/Collaboration/ForkYDocPlugin.ts +1 -1
  31. package/src/extensions/SideMenu/SideMenuPlugin.ts +288 -207
  32. package/src/schema/inlineContent/types.ts +8 -0
  33. package/types/src/api/nodeUtil.d.ts +19 -21
  34. package/types/src/api/parsers/markdown/detectMarkdown.test.d.ts +1 -0
  35. package/types/src/blocks/defaultBlockTypeGuards.d.ts +7 -1
  36. package/types/src/editor/BlockNoteEditor.d.ts +89 -36
  37. package/types/src/editor/BlockNoteExtensions.d.ts +0 -1
  38. package/types/src/exporter/Exporter.d.ts +1 -1
  39. package/types/src/exporter/mapping.d.ts +1 -1
  40. package/types/src/extensions/BlockChange/BlockChangePlugin.d.ts +15 -0
  41. package/types/src/extensions/SideMenu/SideMenuPlugin.d.ts +50 -9
  42. package/types/src/schema/inlineContent/types.d.ts +4 -0
@@ -1,8 +1,4 @@
1
- import {
2
- combineTransactionSteps,
3
- findChildrenInRange,
4
- getChangedRanges,
5
- } from "@tiptap/core";
1
+ import { combineTransactionSteps } from "@tiptap/core";
6
2
  import type { Node } from "prosemirror-model";
7
3
  import type { Transaction } from "prosemirror-state";
8
4
  import {
@@ -17,6 +13,23 @@ import type { StyleSchema } from "../schema/styles/types.js";
17
13
  import { nodeToBlock } from "./nodeConversions/nodeToBlock.js";
18
14
  import { getPmSchema } from "./pmUtil.js";
19
15
 
16
+ /**
17
+ * Gets the parent block of a node, if it has one.
18
+ */
19
+ function getParentBlockId(doc: Node, pos: number): string | undefined {
20
+ if (pos === 0) {
21
+ return undefined;
22
+ }
23
+ const resolvedPos = doc.resolve(pos);
24
+ for (let i = resolvedPos.depth; i > 0; i--) {
25
+ const parent = resolvedPos.node(i);
26
+ if (isNodeBlock(parent)) {
27
+ return parent.attrs.id;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+
20
33
  /**
21
34
  * Get a TipTap node by id
22
35
  */
@@ -62,38 +75,11 @@ export function isNodeBlock(node: Node): boolean {
62
75
  * This attributes the changes to a specific source.
63
76
  */
64
77
  export type BlockChangeSource =
65
- | {
66
- /**
67
- * When an event is triggered by the local user, the source is "local".
68
- * This is the default source.
69
- */
70
- type: "local";
71
- }
72
- | {
73
- /**
74
- * When an event is triggered by a paste operation, the source is "paste".
75
- */
76
- type: "paste";
77
- }
78
- | {
79
- /**
80
- * When an event is triggered by a drop operation, the source is "drop".
81
- */
82
- type: "drop";
83
- }
84
- | {
85
- /**
86
- * When an event is triggered by an undo or redo operation, the source is "undo" or "redo".
87
- * @note Y.js undo/redo are not differentiated.
88
- */
89
- type: "undo" | "redo" | "undo-redo";
90
- }
91
- | {
92
- /**
93
- * When an event is triggered by a remote user, the source is "remote".
94
- */
95
- type: "yjs-remote";
96
- };
78
+ | { type: "local" }
79
+ | { type: "paste" }
80
+ | { type: "drop" }
81
+ | { type: "undo" | "redo" | "undo-redo" }
82
+ | { type: "yjs-remote" };
97
83
 
98
84
  export type BlocksChanged<
99
85
  BSchema extends BlockSchema = DefaultBlockSchema,
@@ -120,9 +106,28 @@ export type BlocksChanged<
120
106
  | {
121
107
  type: "update";
122
108
  /**
123
- * The block before the update.
109
+ * The previous block.
110
+ */
111
+ prevBlock: Block<BSchema, ISchema, SSchema>;
112
+ }
113
+ | {
114
+ type: "move";
115
+ /**
116
+ * The affected block.
117
+ */
118
+ block: Block<BSchema, ISchema, SSchema>;
119
+ /**
120
+ * The block before the move.
124
121
  */
125
122
  prevBlock: Block<BSchema, ISchema, SSchema>;
123
+ /**
124
+ * The previous parent block (if it existed).
125
+ */
126
+ prevParent?: Block<BSchema, ISchema, SSchema>;
127
+ /**
128
+ * The current parent block (if it exists).
129
+ */
130
+ currentParent?: Block<BSchema, ISchema, SSchema>;
126
131
  }
127
132
  )
128
133
  >;
@@ -139,8 +144,6 @@ function areBlocksDifferentExcludingChildren<
139
144
  block1: Block<BSchema, ISchema, SSchema>,
140
145
  block2: Block<BSchema, ISchema, SSchema>,
141
146
  ): boolean {
142
- // TODO use an actual diff algorithm
143
- // Compare all properties except children
144
147
  return (
145
148
  block1.id !== block2.id ||
146
149
  block1.type !== block2.type ||
@@ -149,11 +152,63 @@ function areBlocksDifferentExcludingChildren<
149
152
  );
150
153
  }
151
154
 
155
+ function determineChangeSource(transaction: Transaction): BlockChangeSource {
156
+ if (transaction.getMeta("paste")) {
157
+ return { type: "paste" };
158
+ }
159
+ if (transaction.getMeta("uiEvent") === "drop") {
160
+ return { type: "drop" };
161
+ }
162
+ if (transaction.getMeta("history$")) {
163
+ return {
164
+ type: transaction.getMeta("history$").redo ? "redo" : "undo",
165
+ };
166
+ }
167
+ if (transaction.getMeta("y-sync$")) {
168
+ if (transaction.getMeta("y-sync$").isUndoRedoOperation) {
169
+ return { type: "undo-redo" };
170
+ }
171
+ return { type: "yjs-remote" };
172
+ }
173
+ return { type: "local" };
174
+ }
175
+
176
+ function collectAllBlocks<
177
+ BSchema extends BlockSchema,
178
+ ISchema extends InlineContentSchema,
179
+ SSchema extends StyleSchema,
180
+ >(
181
+ doc: Node,
182
+ ): Record<
183
+ string,
184
+ {
185
+ block: Block<BSchema, ISchema, SSchema>;
186
+ parentId: string | undefined;
187
+ }
188
+ > {
189
+ const blocks: Record<
190
+ string,
191
+ {
192
+ block: Block<BSchema, ISchema, SSchema>;
193
+ parentId: string | undefined;
194
+ }
195
+ > = {};
196
+ const pmSchema = getPmSchema(doc);
197
+ doc.descendants((node, pos) => {
198
+ if (isNodeBlock(node)) {
199
+ const parentId = getParentBlockId(doc, pos);
200
+ blocks[node.attrs.id] = {
201
+ block: nodeToBlock(node, pmSchema),
202
+ parentId,
203
+ };
204
+ }
205
+ return true;
206
+ });
207
+ return blocks;
208
+ }
209
+
152
210
  /**
153
211
  * Get the blocks that were changed by a transaction.
154
- * @param transaction The transaction to get the changes from.
155
- * @param editor The editor to get the changes from.
156
- * @returns The blocks that were changed by the transaction.
157
212
  */
158
213
  export function getBlocksChangedByTransaction<
159
214
  BSchema extends BlockSchema = DefaultBlockSchema,
@@ -163,109 +218,75 @@ export function getBlocksChangedByTransaction<
163
218
  transaction: Transaction,
164
219
  appendedTransactions: Transaction[] = [],
165
220
  ): BlocksChanged<BSchema, ISchema, SSchema> {
166
- let source: BlockChangeSource = { type: "local" };
167
-
168
- if (transaction.getMeta("paste")) {
169
- source = { type: "paste" };
170
- } else if (transaction.getMeta("uiEvent") === "drop") {
171
- source = { type: "drop" };
172
- } else if (transaction.getMeta("history$")) {
173
- source = {
174
- type: transaction.getMeta("history$").redo ? "redo" : "undo",
175
- };
176
- } else if (transaction.getMeta("y-sync$")) {
177
- if (transaction.getMeta("y-sync$").isUndoRedoOperation) {
178
- source = {
179
- type: "undo-redo",
180
- };
181
- } else {
182
- source = {
183
- type: "yjs-remote",
184
- };
185
- }
186
- }
187
-
188
- // Get affected blocks before and after the change
189
- const pmSchema = getPmSchema(transaction);
221
+ const source = determineChangeSource(transaction);
190
222
  const combinedTransaction = combineTransactionSteps(transaction.before, [
191
223
  transaction,
192
224
  ...appendedTransactions,
193
225
  ]);
194
226
 
195
- const changedRanges = getChangedRanges(combinedTransaction);
196
- const prevAffectedBlocks = changedRanges
197
- .flatMap((range) => {
198
- return findChildrenInRange(
199
- combinedTransaction.before,
200
- range.oldRange,
201
- isNodeBlock,
202
- );
203
- })
204
- .map(({ node }) => nodeToBlock(node, pmSchema));
205
-
206
- const nextAffectedBlocks = changedRanges
207
- .flatMap((range) => {
208
- return findChildrenInRange(
209
- combinedTransaction.doc,
210
- range.newRange,
211
- isNodeBlock,
212
- );
213
- })
214
- .map(({ node }) => nodeToBlock(node, pmSchema));
215
-
216
- const nextBlocks = new Map(
217
- nextAffectedBlocks.map((block) => {
218
- return [block.id, block];
219
- }),
227
+ const prevBlocks = collectAllBlocks<BSchema, ISchema, SSchema>(
228
+ combinedTransaction.before,
220
229
  );
221
- const prevBlocks = new Map(
222
- prevAffectedBlocks.map((block) => {
223
- return [block.id, block];
224
- }),
230
+ const nextBlocks = collectAllBlocks<BSchema, ISchema, SSchema>(
231
+ combinedTransaction.doc,
225
232
  );
226
233
 
227
234
  const changes: BlocksChanged<BSchema, ISchema, SSchema> = [];
228
235
 
229
- // Inserted blocks are blocks that were not in the previous state and are in the next state
230
- for (const [id, block] of nextBlocks) {
231
- if (!prevBlocks.has(id)) {
236
+ // Handle inserted blocks
237
+ Object.keys(nextBlocks)
238
+ .filter((id) => !(id in prevBlocks))
239
+ .forEach((id) => {
232
240
  changes.push({
233
241
  type: "insert",
234
- block,
242
+ block: nextBlocks[id].block,
235
243
  source,
236
244
  prevBlock: undefined,
237
245
  });
238
- }
239
- }
246
+ });
240
247
 
241
- // Deleted blocks are blocks that were in the previous state but not in the next state
242
- for (const [id, block] of prevBlocks) {
243
- if (!nextBlocks.has(id)) {
248
+ // Handle deleted blocks
249
+ Object.keys(prevBlocks)
250
+ .filter((id) => !(id in nextBlocks))
251
+ .forEach((id) => {
244
252
  changes.push({
245
253
  type: "delete",
246
- block,
254
+ block: prevBlocks[id].block,
247
255
  source,
248
256
  prevBlock: undefined,
249
257
  });
250
- }
251
- }
258
+ });
252
259
 
253
- // Updated blocks are blocks that were in the previous state and are in the next state
254
- for (const [id, block] of nextBlocks) {
255
- if (prevBlocks.has(id)) {
256
- const prevBlock = prevBlocks.get(id)!;
260
+ // Handle updated, moved, indented, outdented blocks
261
+ Object.keys(nextBlocks)
262
+ .filter((id) => id in prevBlocks)
263
+ .forEach((id) => {
264
+ const prev = prevBlocks[id];
265
+ const next = nextBlocks[id];
266
+ const isParentDifferent = prev.parentId !== next.parentId;
257
267
 
258
- // Only include the update if the block itself changed (excluding children)
259
- if (areBlocksDifferentExcludingChildren(prevBlock, block)) {
268
+ if (isParentDifferent) {
269
+ changes.push({
270
+ type: "move",
271
+ block: next.block,
272
+ prevBlock: prev.block,
273
+ source,
274
+ prevParent: prev.parentId
275
+ ? prevBlocks[prev.parentId]?.block
276
+ : undefined,
277
+ currentParent: next.parentId
278
+ ? nextBlocks[next.parentId]?.block
279
+ : undefined,
280
+ });
281
+ } else if (areBlocksDifferentExcludingChildren(prev.block, next.block)) {
260
282
  changes.push({
261
283
  type: "update",
262
- block,
263
- prevBlock,
284
+ block: next.block,
285
+ prevBlock: prev.block,
264
286
  source,
265
287
  });
266
288
  }
267
- }
268
- }
289
+ });
269
290
 
270
291
  return changes;
271
292
  }
@@ -0,0 +1,211 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isMarkdown } from "./detectMarkdown.js";
3
+
4
+ describe("isMarkdown", () => {
5
+ describe("Headings (H1-H6)", () => {
6
+ it("should detect H1 headings", () => {
7
+ expect(isMarkdown("# Heading 1\n\nContent")).toBe(true);
8
+ expect(isMarkdown(" # Heading 1\n\nContent")).toBe(true);
9
+ expect(isMarkdown(" # Heading 1\n\nContent")).toBe(true);
10
+ });
11
+
12
+ it("should detect H2-H6 headings", () => {
13
+ expect(isMarkdown("## Heading 2\n\nContent")).toBe(true);
14
+ expect(isMarkdown("### Heading 3\n\nContent")).toBe(true);
15
+ expect(isMarkdown("#### Heading 4\n\nContent")).toBe(true);
16
+ expect(isMarkdown("##### Heading 5\n\nContent")).toBe(true);
17
+ expect(isMarkdown("###### Heading 6\n\nContent")).toBe(true);
18
+ });
19
+
20
+ it("should not detect invalid headings", () => {
21
+ expect(isMarkdown("####### Heading 7\n\nContent")).toBe(false);
22
+ expect(isMarkdown("#Heading without space\n\nContent")).toBe(false);
23
+ expect(isMarkdown("# \n\nContent")).toBe(false);
24
+ expect(
25
+ isMarkdown(
26
+ "# Very long heading that exceeds the character limit and should not be detected as a valid markdown heading\n\nContent",
27
+ ),
28
+ ).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("Bold, italic, underline, strikethrough, highlight", () => {
33
+ it("should detect bold text", () => {
34
+ expect(isMarkdown("**bold text**")).toBe(true);
35
+ expect(isMarkdown("__bold text__")).toBe(true);
36
+ expect(isMarkdown(" *bold text* ")).toBe(true);
37
+ expect(isMarkdown(" _bold text_ ")).toBe(true);
38
+ });
39
+
40
+ it("should detect italic text", () => {
41
+ expect(isMarkdown("*italic text*")).toBe(true);
42
+ expect(isMarkdown("_italic text_")).toBe(true);
43
+ });
44
+
45
+ it("should detect strikethrough text", () => {
46
+ expect(isMarkdown("~~strikethrough text~~")).toBe(true);
47
+ });
48
+
49
+ it("should detect highlighted text", () => {
50
+ expect(isMarkdown("==highlighted text==")).toBe(true);
51
+ expect(isMarkdown("++highlighted text++")).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("Links", () => {
56
+ it("should detect basic links", () => {
57
+ expect(isMarkdown("[Link text](https://example.com)")).toBe(true);
58
+ expect(isMarkdown("[Link text](http://example.com)")).toBe(true);
59
+ expect(isMarkdown("[Short](https://ex.com)")).toBe(true);
60
+ });
61
+
62
+ it("should detect image links", () => {
63
+ expect(isMarkdown("![Alt text](https://example.com/image.jpg)")).toBe(
64
+ true,
65
+ );
66
+ });
67
+ });
68
+
69
+ describe("Inline code", () => {
70
+ it("should detect inline code", () => {
71
+ expect(isMarkdown("`code`")).toBe(true);
72
+ expect(isMarkdown(" `code` ")).toBe(true);
73
+ expect(isMarkdown("`const x = 1;`")).toBe(true);
74
+ });
75
+
76
+ it("should not detect invalid inline code", () => {
77
+ expect(isMarkdown("` code `")).toBe(false); // spaces around content
78
+ expect(isMarkdown("``")).toBe(false); // empty
79
+ expect(isMarkdown("` `")).toBe(false); // only space
80
+ });
81
+ });
82
+
83
+ describe("Unordered lists", () => {
84
+ it("should detect unordered lists", () => {
85
+ expect(isMarkdown("- Item 1\n- Item 2")).toBe(true);
86
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
87
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
88
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
89
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
90
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
91
+ });
92
+
93
+ it("should not detect invalid unordered lists", () => {
94
+ expect(isMarkdown("- Item 1")).toBe(false); // single item
95
+ expect(isMarkdown("-- Item 1\n-- Item 2")).toBe(false); // wrong marker
96
+ expect(isMarkdown("-Item 1\n-Item 2")).toBe(false); // no space after marker
97
+ });
98
+ });
99
+
100
+ describe("Ordered lists", () => {
101
+ it("should detect ordered lists", () => {
102
+ expect(isMarkdown("1. Item 1\n2. Item 2")).toBe(true);
103
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
104
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
105
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
106
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
107
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
108
+ });
109
+
110
+ it("should not detect invalid ordered lists", () => {
111
+ expect(isMarkdown("1. Item 1")).toBe(false); // single item
112
+ expect(isMarkdown("1 Item 1\n2 Item 2")).toBe(false); // no dot
113
+ expect(isMarkdown("1.Item 1\n2.Item 2")).toBe(false); // no space after dot
114
+ });
115
+ });
116
+
117
+ describe("Horizontal rules", () => {
118
+ it("should detect horizontal rules", () => {
119
+ expect(isMarkdown("\n\n ---\n\n")).toBe(true);
120
+ expect(isMarkdown("\n\n ----\n\n")).toBe(true);
121
+ expect(isMarkdown("\n\n ---\n\n")).toBe(true);
122
+ expect(isMarkdown("\n\n ---\n\n")).toBe(true);
123
+ });
124
+ });
125
+
126
+ describe("Fenced code blocks", () => {
127
+ it("should detect fenced code blocks", () => {
128
+ expect(isMarkdown("```\ncode block\n```")).toBe(true);
129
+ expect(isMarkdown("~~~\ncode block\n~~~")).toBe(true);
130
+ expect(isMarkdown("```javascript\nconst x = 1;\n```")).toBe(true);
131
+ expect(isMarkdown("```js\nconst x = 1;\n```")).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe("Classical underlined headings", () => {
136
+ it("should detect H1 with equals", () => {
137
+ expect(isMarkdown("Heading\n===\n\nContent")).toBe(true);
138
+ expect(isMarkdown("Heading\n====\n\nContent")).toBe(true);
139
+ });
140
+
141
+ it("should detect H2 with dashes", () => {
142
+ expect(isMarkdown("Heading\n---\n\nContent")).toBe(true);
143
+ expect(isMarkdown("Heading\n----\n\nContent")).toBe(true);
144
+ });
145
+ });
146
+
147
+ describe("Blockquotes", () => {
148
+ it("should detect blockquotes", () => {
149
+ expect(isMarkdown("> This is a blockquote\n\nContent")).toBe(true);
150
+ expect(isMarkdown(" > This is a blockquote\n\nContent")).toBe(true);
151
+ expect(isMarkdown(" > This is a blockquote\n\nContent")).toBe(true);
152
+ expect(isMarkdown(" > This is a blockquote\n\nContent")).toBe(true);
153
+ });
154
+
155
+ it("should detect multi-line blockquotes", () => {
156
+ expect(isMarkdown("> Line 1\n> Line 2\n\nContent")).toBe(true);
157
+ expect(isMarkdown("> Line 1\n> Line 2\n> Line 3\n\nContent")).toBe(true);
158
+ });
159
+ });
160
+
161
+ describe("Tables", () => {
162
+ it("should detect table headers", () => {
163
+ expect(isMarkdown("| Header 1 | Header 2 |\n")).toBe(true);
164
+ expect(isMarkdown("| Header 1 | Header 2 | Header 3 |\n")).toBe(true);
165
+ });
166
+
167
+ it("should detect table dividers", () => {
168
+ expect(isMarkdown("| --- | --- |\n")).toBe(true);
169
+ expect(isMarkdown("| :--- | ---: |\n")).toBe(true);
170
+ expect(isMarkdown("| :---: | --- |\n")).toBe(true);
171
+ });
172
+
173
+ it("should detect table rows", () => {
174
+ expect(isMarkdown("| Cell 1 | Cell 2 |\n")).toBe(true);
175
+ expect(isMarkdown("| Cell 1 | Cell 2 | Cell 3 |\n")).toBe(true);
176
+ });
177
+
178
+ it("should detect complete tables", () => {
179
+ const table =
180
+ "| Header 1 | Header 2 |\n| --- | --- |\n| Cell 1 | Cell 2 |\n";
181
+ expect(isMarkdown(table)).toBe(true);
182
+ });
183
+
184
+ it("should not detect invalid tables", () => {
185
+ expect(isMarkdown("| Header 1 | Header 2\n")).toBe(false); // missing closing pipe
186
+ expect(isMarkdown("Header 1 | Header 2 |\n")).toBe(false); // missing opening pipe
187
+ });
188
+ });
189
+
190
+ describe("Edge cases and combinations", () => {
191
+ it("should detect mixed markdown content", () => {
192
+ const mixedContent =
193
+ "# Heading\n\nThis is **bold** and *italic* text with a [link](https://example.com).\n\n- List item 1\n- List item 2\n\n> Blockquote\n\n```\ncode block\n```";
194
+ expect(isMarkdown(mixedContent)).toBe(true);
195
+ });
196
+
197
+ it("should not detect plain text", () => {
198
+ expect(
199
+ isMarkdown("This is just plain text without any markdown formatting."),
200
+ ).toBe(false);
201
+ expect(isMarkdown("")).toBe(false);
202
+ expect(isMarkdown(" \n \n ")).toBe(false); // only whitespace
203
+ });
204
+
205
+ it("should handle special characters", () => {
206
+ expect(isMarkdown("**text with `backticks`**")).toBe(true);
207
+ expect(isMarkdown("**text with [brackets]**")).toBe(true);
208
+ expect(isMarkdown("**text with (parentheses)**")).toBe(true);
209
+ });
210
+ });
211
+ });
@@ -2,13 +2,14 @@
2
2
  const h1 = /(^|\n) {0,3}#{1,6} {1,8}[^\n]{1,64}\r?\n\r?\n\s{0,32}\S/;
3
3
 
4
4
  // Bold, italic, underline, strikethrough, highlight.
5
- const bold = /(?:\s|^)(_|__|\*|\*\*|~~|==|\+\+)(?!\s).{1,64}(?<!\s)(?=\1)/;
5
+ const bold =
6
+ /(_|__|\*|\*\*|~~|==|\+\+)(?!\s)(?:[^\s](?:.{0,62}[^\s])?|\S)(?=\1)/;
6
7
 
7
8
  // Basic inline link (also captures images).
8
9
  const link = /\[[^\]]{1,128}\]\(https?:\/\/\S{1,999}\)/;
9
10
 
10
11
  // Inline code.
11
- const code = /(?:\s|^)`(?!\s)[^`]{1,48}(?<!\s)`([^\w]|$)/;
12
+ const code = /(?:\s|^)`(?!\s)(?:[^\s`](?:[^`]{0,46}[^\s`])?|[^\s`])`([^\w]|$)/;
12
13
 
13
14
  // Unordered list.
14
15
  const ul = /(?:^|\n)\s{0,5}-\s{1}[^\n]+\n\s{0,15}-\s/;
@@ -31,7 +31,8 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({
31
31
  const blockInfo = getBlockInfoFromSelection(state);
32
32
  if (
33
33
  !blockInfo.isBlockContainer ||
34
- blockInfo.blockContent.node.type.spec.content !== "inline*"
34
+ blockInfo.blockContent.node.type.spec.content !== "inline*" ||
35
+ blockInfo.blockNoteType === "heading"
35
36
  ) {
36
37
  return;
37
38
  }
@@ -50,7 +50,8 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
50
50
  if (
51
51
  !blockInfo.isBlockContainer ||
52
52
  blockInfo.blockContent.node.type.spec.content !== "inline*" ||
53
- blockInfo.blockNoteType === "numberedListItem"
53
+ blockInfo.blockNoteType === "numberedListItem" ||
54
+ blockInfo.blockNoteType === "heading"
54
55
  ) {
55
56
  return;
56
57
  }
@@ -2,11 +2,15 @@ import {
2
2
  createBlockSpecFromStronglyTypedTiptapNode,
3
3
  createStronglyTypedTiptapNode,
4
4
  } from "../../schema/index.js";
5
- import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
5
+ import {
6
+ createDefaultBlockDOMOutputSpec,
7
+ mergeParagraphs,
8
+ } from "../defaultBlockHelpers.js";
6
9
  import { defaultProps } from "../defaultProps.js";
7
10
  import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
8
11
  import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
9
12
  import { InputRule } from "@tiptap/core";
13
+ import { DOMParser } from "prosemirror-model";
10
14
 
11
15
  export const quotePropSchema = {
12
16
  ...defaultProps,
@@ -76,6 +80,24 @@ export const QuoteBlockContent = createStronglyTypedTiptapNode({
76
80
  {
77
81
  tag: "blockquote",
78
82
  node: "quote",
83
+ getContent: (node, schema) => {
84
+ // Parse the blockquote content as inline content
85
+ const element = node as HTMLElement;
86
+
87
+ // Clone to avoid modifying the original
88
+ const clone = element.cloneNode(true) as HTMLElement;
89
+
90
+ // Merge multiple paragraphs into one with line breaks
91
+ mergeParagraphs(clone);
92
+
93
+ // Parse the content directly as a paragraph to extract inline content
94
+ const parser = DOMParser.fromSchema(schema);
95
+ const parsed = parser.parse(clone, {
96
+ topNode: schema.nodes.paragraph.create(),
97
+ });
98
+
99
+ return parsed.content;
100
+ },
79
101
  },
80
102
  ];
81
103
  },
@@ -41,6 +41,7 @@ export const createToggleWrapper = (
41
41
 
42
42
  const toggleButton = document.createElement("button");
43
43
  toggleButton.className = "bn-toggle-button";
44
+ toggleButton.type = "button";
44
45
  toggleButton.innerHTML =
45
46
  // https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed
46
47
  '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="CURRENTCOLOR"><path d="M320-200v-560l440 280-440 280Z"/></svg>';
@@ -75,6 +76,7 @@ export const createToggleWrapper = (
75
76
 
76
77
  const toggleAddBlockButton = document.createElement("button");
77
78
  toggleAddBlockButton.className = "bn-toggle-add-block-button";
79
+ toggleAddBlockButton.type = "button";
78
80
  toggleAddBlockButton.textContent = "Empty toggle. Click to add a block.";
79
81
  const toggleAddBlockButtonMouseDown = (event: MouseEvent) =>
80
82
  event.preventDefault();