diff --git a/.env.sample b/.env.sample
new file mode 100644
index 0000000000..bce33191f8
--- /dev/null
+++ b/.env.sample
@@ -0,0 +1,2 @@
+export NX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://cache.nickthesick.com
+export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=g8@ucL8em4*Z9TKXDY9OEX@!upf^Nz9
\ No newline at end of file
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index c0a4c78b89..0000000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,59 +0,0 @@
-const typeScriptExtensions = [".ts", ".cts", ".mts", ".tsx"];
-
-const allExtensions = [...typeScriptExtensions, ".js", ".jsx"];
-
-module.exports = {
- root: true,
- extends: [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "react-app",
- "react-app/jest",
- ],
- parser: "@typescript-eslint/parser",
- plugins: ["import", "@typescript-eslint"],
- settings: {
- "import/extensions": allExtensions,
- "import/external-module-folders": ["node_modules", "node_modules/@types"],
- "import/parsers": {
- "@typescript-eslint/parser": typeScriptExtensions,
- },
- "import/resolver": {
- node: {
- extensions: allExtensions,
- },
- },
- },
- ignorePatterns: ["**/ui/*"],
- rules: {
- "no-console": "error",
- curly: 1,
- "import/no-extraneous-dependencies": [
- "error",
- {
- devDependencies: true,
- optionalDependencies: false,
- peerDependencies: false,
- bundledDependencies: false,
- },
- ],
- // would be nice to enable these rules later, but they are too noisy right now
- "@typescript-eslint/no-non-null-assertion": "off",
- "@typescript-eslint/no-explicit-any": "off",
- "@typescript-eslint/ban-ts-comment": "off",
- "import/no-cycle": "error",
- // doesn't work:
- // "import/no-restricted-paths": [
- // "error",
- // {
- // zones: [
- // {
- // target: "./src/**/*",
- // from: "./types/**/*",
- // message: "Import from this module to types is not allowed.",
- // },
- // ],
- // },
- // ],
- },
-};
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000000..e9511bbf61
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,42 @@
+{
+ "root": true,
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "react-app",
+ "react-app/jest"
+ ],
+ "parser": "@typescript-eslint/parser",
+ "plugins": ["import", "@typescript-eslint"],
+ "settings": {
+ "import/extensions": [".ts", ".cts", ".mts", ".tsx", ".js", ".jsx"],
+ "import/external-module-folders": ["node_modules", "node_modules/@types"],
+ "import/parsers": {
+ "@typescript-eslint/parser": [".ts", ".cts", ".mts", ".tsx"]
+ },
+ "import/resolver": {
+ "node": {
+ "extensions": [".ts", ".cts", ".mts", ".tsx", ".js", ".jsx"]
+ }
+ }
+ },
+ "ignorePatterns": ["**/ui/*"],
+ "rules": {
+ "no-console": "error",
+ "curly": 1,
+ "import/extensions": ["error", "always", { "ignorePackages": true }],
+ "import/no-extraneous-dependencies": [
+ "error",
+ {
+ "devDependencies": true,
+ "peerDependencies": true,
+ "optionalDependencies": false,
+ "bundledDependencies": false
+ }
+ ],
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/ban-ts-comment": "off",
+ "import/no-cycle": "error"
+ }
+}
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index fec535d794..0000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: bug
-assignees: ''
-
----
-
-**Describe the bug**
- for toggle block HTML export ([#2524](https://github.com/TypeCellOS/BlockNote/pull/2524))
+- remove @hocuspocus/provider peer dependency by inlining tiptap comment types BLO-1064 ([#2564](https://github.com/TypeCellOS/BlockNote/pull/2564))
+- **core:** slash menu fails in custom blocks after space BLO-1036 ([#2553](https://github.com/TypeCellOS/BlockNote/pull/2553))
+- **i18n:** fix typo in russian translation ([#2560](https://github.com/TypeCellOS/BlockNote/pull/2560))
+
+### ❤️ Thank You
+
+- Claude Opus 4.6
+- Drone
+- Yousef
+
+## 0.47.1 (2026-03-02)
+
+### 🩹 Fixes
+
+- typeerror cannot read properties of undefined ([#2522](https://github.com/TypeCellOS/BlockNote/pull/2522))
+- handle more delete key cases ([#2126](https://github.com/TypeCellOS/BlockNote/pull/2126))
+- add delay for `data-active` in collab cursors ([#2383](https://github.com/TypeCellOS/BlockNote/pull/2383))
+- disable slash menu in table content #2408 ([#2504](https://github.com/TypeCellOS/BlockNote/pull/2504), [#2408](https://github.com/TypeCellOS/BlockNote/issues/2408))
+- **ai:** selections broken due to floating-ui focus manager ([#2527](https://github.com/TypeCellOS/BlockNote/pull/2527))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Yousef
+
+## 0.47.0 (2026-02-23)
+
+### 🚀 Features
+
+- update suggestion menu component ([#2397](https://github.com/TypeCellOS/BlockNote/pull/2397))
+- **i18n:** add Persian (fa) localization support ([#2447](https://github.com/TypeCellOS/BlockNote/pull/2447))
+- **i18n:** add Uzbek (uz) localization support ([#2506](https://github.com/TypeCellOS/BlockNote/pull/2506))
+
+### 🩹 Fixes
+
+- prevent nested bullet list icon rendering as emoji on iOS 18+ ([#2394](https://github.com/TypeCellOS/BlockNote/pull/2394), [#2399](https://github.com/TypeCellOS/BlockNote/pull/2399))
+- ignore drag & drop from unrelated events #1968 ([#2346](https://github.com/TypeCellOS/BlockNote/pull/2346), [#1968](https://github.com/TypeCellOS/BlockNote/issues/1968))
+- disable checkbox when editor is not editable #2406 ([#2448](https://github.com/TypeCellOS/BlockNote/pull/2448), [#2406](https://github.com/TypeCellOS/BlockNote/issues/2406))
+- Backspace/enter behaviour in empty block with children ([#2451](https://github.com/TypeCellOS/BlockNote/pull/2451))
+- handle pasting into table cells better, by collapsing their content to inline #2410 ([#2449](https://github.com/TypeCellOS/BlockNote/pull/2449), [#2410](https://github.com/TypeCellOS/BlockNote/issues/2410))
+- **accessibility:** ai combobox aria-activedescendant ([#2413](https://github.com/TypeCellOS/BlockNote/pull/2413))
+- **ai:** no more scrolling to top when opening AI menu ([#2503](https://github.com/TypeCellOS/BlockNote/pull/2503))
+- **docs:** unicode char not rendered in bug template ([f13e270be](https://github.com/TypeCellOS/BlockNote/commit/f13e270be))
+
+### ❤️ Thank You
+
+- Cyril G @Ovgodd
+- Dex Devlon @bxff
+- Matthew Lipski @matthewlipski
+- MDSAM05 @MDSAM05
+- Mohammad RAHMANI @Mrahmani71
+- Nick Perez
+- Ogabek @OgabekYuldoshev
+- Wouter Vroege
+- Yousef
+
+## 0.46.2 (2026-01-27)
+
+### 🩹 Fixes
+
+- deep merge floatingUIOptions using nested spread operators ([#2310](https://github.com/TypeCellOS/BlockNote/pull/2310))
+- Visual differences between live editor and rendered exported HTML ([#2348](https://github.com/TypeCellOS/BlockNote/pull/2348))
+- `BlockNoteViewEditor` mismatched editable value ([#2357](https://github.com/TypeCellOS/BlockNote/pull/2357))
+- add `font-synthesis` for italic & bold in fonts that don't have them specified #2325 ([#2354](https://github.com/TypeCellOS/BlockNote/pull/2354), [#2325](https://github.com/TypeCellOS/BlockNote/issues/2325))
+- disable code block language selector when editor is not editable ([#2351](https://github.com/TypeCellOS/BlockNote/pull/2351))
+- table handles would crash ([#2384](https://github.com/TypeCellOS/BlockNote/pull/2384))
+- update CreateLinkButton to be able to toggle popover visibility ([#2316](https://github.com/TypeCellOS/BlockNote/pull/2316), [#2313](https://github.com/TypeCellOS/BlockNote/issues/2313))
+- add context,nestingLevel to toExternalHTML ([#2373](https://github.com/TypeCellOS/BlockNote/pull/2373))
+- **ai:** re-enable flipping the AIMenu when there is not enough space #2245 ([#2247](https://github.com/TypeCellOS/BlockNote/pull/2247), [#2245](https://github.com/TypeCellOS/BlockNote/issues/2245))
+- **link-toolbar:** prevent Enter from submitting during IME composition ([#2361](https://github.com/TypeCellOS/BlockNote/pull/2361))
+
+### ❤️ Thank You
+
+- hanios123
+- Jean-Baptiste PENRATH
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Shohei Yoshida @ysds
+- Yousef
+
+## 0.46.1 (2026-01-10)
+
+This was a version bump only, there were no code changes.
+
+## 0.46.0 (2026-01-08)
+
+### 🚀 Features
+
+- add data-nesting-level to HTML export ([#2329](https://github.com/TypeCellOS/BlockNote/pull/2329))
+- migrate to ai sdk 6 ([#2328](https://github.com/TypeCellOS/BlockNote/pull/2328))
+
+### 🩹 Fixes
+
+- emojipicker can sometimes fail to mount ([575b81cec](https://github.com/TypeCellOS/BlockNote/commit/575b81cec))
+- LinkToolbar Event Listener leak ([#2335](https://github.com/TypeCellOS/BlockNote/pull/2335))
+- when you convert a block into checkListItem via inputRule, it should transfer its content into checkListItem content ([#2331](https://github.com/TypeCellOS/BlockNote/pull/2331))
+- do not return focus back to menu ([484d7da36](https://github.com/TypeCellOS/BlockNote/commit/484d7da36))
+- arrow up on a checklist item should move to the element above BLO-362 ([#2306](https://github.com/TypeCellOS/BlockNote/pull/2306))
+- getPos race condition in React StrictMode ([#2311](https://github.com/TypeCellOS/BlockNote/pull/2311))
+- adjust input rules to be more tolerant to starting whitespace ([#2341](https://github.com/TypeCellOS/BlockNote/pull/2341))
+- **ai:** make sure ShowSelection works ([#2297](https://github.com/TypeCellOS/BlockNote/pull/2297))
+- **xl-email-exporter:** remove redundant sections in email export ([#2323](https://github.com/TypeCellOS/BlockNote/pull/2323))
+
+### ❤️ Thank You
+
+- Nick Perez
+- Nick the Sick @nperez0111
+- supernova @tmpluto
+- Yousef
+
+## 0.45.0 (2025-12-17)
+
+### 🚀 Features
+
+- **ai:** expand selections to contain words ([#2304](https://github.com/TypeCellOS/BlockNote/pull/2304))
+- **extensions:** extensions can now include other extensions for grouping into one extension ([#2284](https://github.com/TypeCellOS/BlockNote/pull/2284))
+
+### 🩹 Fixes
+
+- an invalidly specified table should not crash the editor ([#2255](https://github.com/TypeCellOS/BlockNote/pull/2255))
+- filter out invalid heading items based on the current block schema in the slash menu #2253 ([#2259](https://github.com/TypeCellOS/BlockNote/pull/2259), [#2253](https://github.com/TypeCellOS/BlockNote/issues/2253))
+- relax shiki package requirements #2279 ([#2280](https://github.com/TypeCellOS/BlockNote/pull/2280), [#2279](https://github.com/TypeCellOS/BlockNote/issues/2279))
+- filter the default tiptap extensions #2282 ([#2283](https://github.com/TypeCellOS/BlockNote/pull/2283), [#2282](https://github.com/TypeCellOS/BlockNote/issues/2282))
+- always include the cursor extension #2244 ([#2260](https://github.com/TypeCellOS/BlockNote/pull/2260), [#2244](https://github.com/TypeCellOS/BlockNote/issues/2244))
+- make `onBeforeChange` return the correct type again ([9009369b1](https://github.com/TypeCellOS/BlockNote/commit/9009369b1))
+- if there is no table block, there is no table handles to show #1055 ([#2281](https://github.com/TypeCellOS/BlockNote/pull/2281), [#1055](https://github.com/TypeCellOS/BlockNote/issues/1055))
+- pass dragHandleMenu prop to DragHandleButton ([#2254](https://github.com/TypeCellOS/BlockNote/pull/2254))
+- html diff error with whitespace ([#2230](https://github.com/TypeCellOS/BlockNote/pull/2230))
+- update regex for checklist items #2288 ([#2305](https://github.com/TypeCellOS/BlockNote/pull/2305), [#2288](https://github.com/TypeCellOS/BlockNote/issues/2288))
+- **email-exporter:** ReadableByteStreamController for safari react-email ([#2295](https://github.com/TypeCellOS/BlockNote/pull/2295))
+
+### ❤️ Thank You
+
+- Max @maqen
+- Nick Perez
+- Nick the Sick @nperez0111
+- Yousef
+
+## 0.44.2 (2025-12-09)
+
+### 🩹 Fixes
+
+- put back `onBeforeChange` method #2221 ([#2243](https://github.com/TypeCellOS/BlockNote/pull/2243), [#2221](https://github.com/TypeCellOS/BlockNote/issues/2221))
+- Improper accessing of editor DOM element ([#2234](https://github.com/TypeCellOS/BlockNote/pull/2234))
+- make validation errors recoverable by llm ([#2054](https://github.com/TypeCellOS/BlockNote/pull/2054))
+- shadowdom support and example ([#2223](https://github.com/TypeCellOS/BlockNote/pull/2223))
+- ensure numbered list start property always present ([#2241](https://github.com/TypeCellOS/BlockNote/pull/2241), [#2242](https://github.com/TypeCellOS/BlockNote/pull/2242))
+- Suggestion menu positioning ([#2232](https://github.com/TypeCellOS/BlockNote/pull/2232))
+- conditionally access the TableHandles extension from React ([#2248](https://github.com/TypeCellOS/BlockNote/pull/2248))
+- **ai:** upgrade prosemirror-suggest-changes ([#2235](https://github.com/TypeCellOS/BlockNote/pull/2235))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- wcyat @sdip15fa
+- Yousef
+
+## 0.44.1 (2025-12-08)
+
+### 🩹 Fixes
+
+- clearing selection was not being called when create link button is no longer rendered ([#2217](https://github.com/TypeCellOS/BlockNote/pull/2217))
+- AI menu not updating position on new line ([#2233](https://github.com/TypeCellOS/BlockNote/pull/2233))
+- UI elements not scrolling when editor DOM element is scrollable ([#2231](https://github.com/TypeCellOS/BlockNote/pull/2231))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+
+## 0.44.0 (2025-12-02)
+
+### 🚀 Features
+
+- **ai:** Abort requests ([#2213](https://github.com/TypeCellOS/BlockNote/pull/2213))
+
+### ❤️ Thank You
+
+- Yousef
+
+## 0.43.0 (2025-12-01)
+
+### 🚀 Features
+
+- Major Extensions & UI Refactor ([#2143](https://github.com/TypeCellOS/BlockNote/pull/2143))
+
+### 🩹 Fixes
+
+- allow configuring the email body's styles ([#2182](https://github.com/TypeCellOS/BlockNote/pull/2182))
+- **xl-docx-exporter:** improve OOXML interoperability ([#2206](https://github.com/TypeCellOS/BlockNote/pull/2206))
+
+### ❤️ Thank You
+
+- Nick Perez
+- Stephan Meijer @StephanMeijer
+
+## 0.42.3 (2025-11-19)
+
+### 🩹 Fixes
+
+- disallow access to the `domElement` or `isFocused` if the editor is unmounted ([#2187](https://github.com/TypeCellOS/BlockNote/pull/2187))
+
+### ❤️ Thank You
+
+- Nick Perez
+
+## 0.42.2 (2025-11-19)
+
+### 🩹 Fixes
+
+- put back mounting system ([#2183](https://github.com/TypeCellOS/BlockNote/pull/2183))
+
+### ❤️ Thank You
+
+- Nick Perez
+
+## 0.42.1 (2025-11-18)
+
+### 🩹 Fixes
+
+- do not error on invalid `backgroundColor` or `textColor` #2176 ([#2179](https://github.com/TypeCellOS/BlockNote/pull/2179), [#2176](https://github.com/TypeCellOS/BlockNote/issues/2176))
+- remove dependency array from comments re-rendering ([#2177](https://github.com/TypeCellOS/BlockNote/pull/2177))
+
+### ❤️ Thank You
+
+- Nick Perez
+
+## 0.42.0 (2025-11-11)
+
+### 🚀 Features
+
+- **yjs:** expose Y.js BlockNote conversion primitives #1866 ([#2166](https://github.com/TypeCellOS/BlockNote/pull/2166), [#1866](https://github.com/TypeCellOS/BlockNote/issues/1866))
+
+### 🩹 Fixes
+
+- Emoji picker issues ([#2092](https://github.com/TypeCellOS/BlockNote/pull/2092))
+- set a default for `blocksToFullHTML` #2100 ([#2101](https://github.com/TypeCellOS/BlockNote/pull/2101), [#2100](https://github.com/TypeCellOS/BlockNote/issues/2100))
+- correctly index blocks that have children fixes #2115 ([#2116](https://github.com/TypeCellOS/BlockNote/pull/2116), [#2115](https://github.com/TypeCellOS/BlockNote/issues/2115))
+- add more lenient parsing for code blocks, to accept newlines #2105 ([#2108](https://github.com/TypeCellOS/BlockNote/pull/2108), [#2105](https://github.com/TypeCellOS/BlockNote/issues/2105))
+- Firefox invisible text cursor after dropping blocks ([#2128](https://github.com/TypeCellOS/BlockNote/pull/2128))
+- parsing `priority` for custom inline content and styles ([#2119](https://github.com/TypeCellOS/BlockNote/pull/2119))
+- `BlockTypeSelect` item filtering based on schema ([#2112](https://github.com/TypeCellOS/BlockNote/pull/2112))
+- deleting last block in column ([#2110](https://github.com/TypeCellOS/BlockNote/pull/2110))
+- **comments:** update the styles for the cursor to be the default cursor ([#2163](https://github.com/TypeCellOS/BlockNote/pull/2163))
+- **comments:** always surface the closest mark to the current position ([#2164](https://github.com/TypeCellOS/BlockNote/pull/2164))
+- **comments:** scrolling bug when clicking comment marks ([#2165](https://github.com/TypeCellOS/BlockNote/pull/2165))
+- **react:** destroy editor instances after two ticks ([#2121](https://github.com/TypeCellOS/BlockNote/pull/2121))
+- **schema-migration:** more robust migration of background-color & text-color attributes ([#2154](https://github.com/TypeCellOS/BlockNote/pull/2154))
+- **unique-id:** do not attempt to append to y-sync plugin transactions ([#2153](https://github.com/TypeCellOS/BlockNote/pull/2153))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+
+## 0.41.1 (2025-10-09)
+
+This was a version bump only, there were no code changes.
+
+## 0.41.0 (2025-10-08)
+
+### 🚀 Features
+
+- AI menu auto scrolling ([#2039](https://github.com/TypeCellOS/BlockNote/pull/2039))
+- Shortcut to delete empty table while cells are selected ([#2052](https://github.com/TypeCellOS/BlockNote/pull/2052))
+- **divider:** add a divider block ([#2014](https://github.com/TypeCellOS/BlockNote/pull/2014))
+
+### 🩹 Fixes
+
+- Code block language select value not updating properly ([#2050](https://github.com/TypeCellOS/BlockNote/pull/2050))
+- disable input rules for numbered headings #1789 ([#2032](https://github.com/TypeCellOS/BlockNote/pull/2032), [#1789](https://github.com/TypeCellOS/BlockNote/issues/1789))
+- video parsing and export for markdown ([#1955](https://github.com/TypeCellOS/BlockNote/pull/1955))
+- Reaction picker shown for users who can't react ([#2061](https://github.com/TypeCellOS/BlockNote/pull/2061))
+- Add Mantine dependency to individual examples ([#2070](https://github.com/TypeCellOS/BlockNote/pull/2070))
+- allow listening to `onChange` and other events before the underlying editor is initialized ([#2063](https://github.com/TypeCellOS/BlockNote/pull/2063))
+- toggle and check list item blocks ([#2071](https://github.com/TypeCellOS/BlockNote/pull/2071))
+- added missing fields to implementations in editor schema block specs ([#2046](https://github.com/TypeCellOS/BlockNote/pull/2046))
+
+### ❤️ Thank You
+
+- Héctor Zhuang @Hector-Zhuang
+- Matthew Lipski @matthewlipski
+- Nick Perez
+
+## 0.40.0 (2025-09-30)
+
+### 🚀 Features
+
+- Mantine v8 upgrade ([#2028](https://github.com/TypeCellOS/BlockNote/pull/2028), [#2029](https://github.com/TypeCellOS/BlockNote/issues/2029))
+- Update Mantine setup ([#2033](https://github.com/TypeCellOS/BlockNote/pull/2033))
+- **ai:** SDK 5, tool calling, custom backends ([#2007](https://github.com/TypeCellOS/BlockNote/pull/2007))
+- **core:** add the ability to autofocus on the editor element ([#2018](https://github.com/TypeCellOS/BlockNote/pull/2018))
+
+### 🩹 Fixes
+
+- Block colors menu not always showing ([#2027](https://github.com/TypeCellOS/BlockNote/pull/2027))
+- Update remianing examples to Mantine v8 ([#2031](https://github.com/TypeCellOS/BlockNote/pull/2031))
+- ShadCN example Tailwind setup ([#2042](https://github.com/TypeCellOS/BlockNote/pull/2042))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Yousef
+
+## 0.39.1 (2025-09-19)
+
+### 🩹 Fixes
+
+- cleanup accesses to prosemirrorView to account for tiptap 3 behavior ([#2017](https://github.com/TypeCellOS/BlockNote/pull/2017))
+- **core:** input rules can handle when a new block is empty now ([#2013](https://github.com/TypeCellOS/BlockNote/pull/2013))
+
+### ❤️ Thank You
+
+- Nick Perez
+
+## 0.39.0 (2025-09-18)
+
+### 🚀 Features
+
+- move all blocks to use the custom blocks API ([#1904](https://github.com/TypeCellOS/BlockNote/pull/1904))
+- **core:** support for Tiptap V3 ([#2001](https://github.com/TypeCellOS/BlockNote/pull/2001))
+
+### ❤️ Thank You
+
+- Nick Perez
+
+## 0.38.0 (2025-09-16)
+
+### 🚀 Features
+
+- Custom schemas for comment editors ([#1976](https://github.com/TypeCellOS/BlockNote/pull/1976))
+
+### 🩹 Fixes
+
+- Suggestion menu positioning ([#1975](https://github.com/TypeCellOS/BlockNote/pull/1975))
+- doLLMRequest fails when deleting a non-existent block ([#1982](https://github.com/TypeCellOS/BlockNote/pull/1982))
+- file block resize handles not working with touch inputs ([#1981](https://github.com/TypeCellOS/BlockNote/pull/1981))
+- get pdf example working again ([a90ae4d58](https://github.com/TypeCellOS/BlockNote/commit/a90ae4d58))
+- better markdown & html paste, make methods synchronous ([#1957](https://github.com/TypeCellOS/BlockNote/pull/1957))
+- Improve setting text for custom file blocks ([#1984](https://github.com/TypeCellOS/BlockNote/pull/1984))
+- **react:** close link popover on submit in static formatting toolbar #1696 ([#1997](https://github.com/TypeCellOS/BlockNote/pull/1997), [#1696](https://github.com/TypeCellOS/BlockNote/issues/1696))
+
+### ❤️ Thank You
+
+- dsriva03 @dsriva03
+- Héctor Zhuang @Hector-Zhuang
+- Matthew Lipski @matthewlipski
+- Nick the Sick
+
+## 0.37.0 (2025-08-29)
+
+### 🚀 Features
+
+- export `ShadCNComponentsContext` ([#1965](https://github.com/TypeCellOS/BlockNote/pull/1965))
+
+### 🩹 Fixes
+
+- Typing in empty table cells ([#1973](https://github.com/TypeCellOS/BlockNote/pull/1973))
+
+### ❤️ Thank You
+
+- Héctor Zhuang @Hector-Zhuang
+- Matthew Lipski @matthewlipski
+
+## 0.36.1 (2025-08-27)
+
+### 🩹 Fixes
+
+- table column widths not being set in exported HTML ([#1947](https://github.com/TypeCellOS/BlockNote/pull/1947))
+- Minor change to formatting toolbar extension logic ([#1963](https://github.com/TypeCellOS/BlockNote/pull/1963))
+- **core:** report block moves in `getBlocksChangedByTransaction` #1924 ([#1960](https://github.com/TypeCellOS/BlockNote/pull/1960), [#1924](https://github.com/TypeCellOS/BlockNote/issues/1924))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+
+## 0.36.0 (2025-08-25)
+
+### 🚀 Features
+
+- **docx:** add locale configuration for docx export ([#1937](https://github.com/TypeCellOS/BlockNote/pull/1937))
+
+### 🩹 Fixes
+
+- Editors in comments not inheriting theme ([#1890](https://github.com/TypeCellOS/BlockNote/pull/1890))
+- Minor drag & drop changes ([#1891](https://github.com/TypeCellOS/BlockNote/pull/1891))
+- Overflow on table blocks ([#1892](https://github.com/TypeCellOS/BlockNote/pull/1892))
+- Suggestion menu closing when clicking scroll bar ([#1899](https://github.com/TypeCellOS/BlockNote/pull/1899))
+- Table padding ([#1906](https://github.com/TypeCellOS/BlockNote/pull/1906))
+- Formatting toolbar getting wrong bounding box when updating React inline content ([#1908](https://github.com/TypeCellOS/BlockNote/pull/1908))
+- Vanilla blocks return true for editor.isEditable on initial render ([#1925](https://github.com/TypeCellOS/BlockNote/pull/1925))
+- table cell menu styling ([#1945](https://github.com/TypeCellOS/BlockNote/pull/1945))
+- Missing internationalization for toggle wrapper ([#1946](https://github.com/TypeCellOS/BlockNote/pull/1946))
+- parse image alt text for image blocks ([#1883](https://github.com/TypeCellOS/BlockNote/pull/1883))
+- initialize esm deps before copy extension uses it ([#1951](https://github.com/TypeCellOS/BlockNote/pull/1951))
+- error when dragging a block from one editor to another with multiple column extension ([#1950](https://github.com/TypeCellOS/BlockNote/pull/1950))
+- prevent infinite render loop when selecting all content ([#1956](https://github.com/TypeCellOS/BlockNote/pull/1956))
+- **core:** maintain text selection across table updates ([#1894](https://github.com/TypeCellOS/BlockNote/pull/1894))
+- **locales:** ko locale fix ([#1902](https://github.com/TypeCellOS/BlockNote/pull/1902))
+- **react:** add data attribute for correct react rendering ([#1954](https://github.com/TypeCellOS/BlockNote/pull/1954))
+- **xl-email-exporter:** better defaults, customize textStyles, output inline styles ([#1856](https://github.com/TypeCellOS/BlockNote/pull/1856))
+
+### ❤️ Thank You
+
+- Brad Greenlee
+- Cyril G @Ovgodd
+- Héctor Zhuang @Hector-Zhuang
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Nick the Sick
+
+## 0.35.0 (2025-07-25)
+
+### 🚀 Features
+
+- use fumadocs for website ([#1654](https://github.com/TypeCellOS/BlockNote/pull/1654))
+- llms.mdx routes ([cea93840e](https://github.com/TypeCellOS/BlockNote/commit/cea93840e))
+
+### 🩹 Fixes
+
+- insert file upload before block if it is closer to the top of the block ([#1857](https://github.com/TypeCellOS/BlockNote/pull/1857))
+- rename albert model ([3b0ba8d25](https://github.com/TypeCellOS/BlockNote/commit/3b0ba8d25))
+- resolve some minor drag & drop regressions ([#1862](https://github.com/TypeCellOS/BlockNote/pull/1862))
+- blockquote HTML parsing #1762 ([#1877](https://github.com/TypeCellOS/BlockNote/pull/1877), [#1762](https://github.com/TypeCellOS/BlockNote/issues/1762))
+
+### ❤️ Thank You
+
+- Brad Greenlee
+- Nick Perez
+- Nick the Sick
+- yousefed
+
+## 0.34.0 (2025-07-17)
+
+### 🚀 Features
+
+- support multi-column block in PDF, DOCX & ODT exporters ([#1781](https://github.com/TypeCellOS/BlockNote/pull/1781))
+- support react 19 ([f7b3466d3](https://github.com/TypeCellOS/BlockNote/commit/f7b3466d3))
+- disable conversion of headings to list items ([#1799](https://github.com/TypeCellOS/BlockNote/pull/1799))
+- report `moves` (indents and outdents) as changes when using `getChanges` #1757 ([#1786](https://github.com/TypeCellOS/BlockNote/pull/1786), [#1757](https://github.com/TypeCellOS/BlockNote/issues/1757))
+- allow inline content to be `draggable` ([#1818](https://github.com/TypeCellOS/BlockNote/pull/1818))
+- added type guards, types, and `editor` prop to custom inline content rendering ([#1736](https://github.com/TypeCellOS/BlockNote/pull/1736))
+- **block-change:** adds a new API for blocking changes to editor state, by filtering transactions ([#1750](https://github.com/TypeCellOS/BlockNote/pull/1750))
+
+### 🩹 Fixes
+
+- remove lookbehind regex for browser compat ([#1827](https://github.com/TypeCellOS/BlockNote/pull/1827))
+- `ToggleWrapper` button defaulting to `submit` type ([#1823](https://github.com/TypeCellOS/BlockNote/pull/1823))
+- disable $ref in AI schemas (html format) ([#1819](https://github.com/TypeCellOS/BlockNote/pull/1819))
+- re-evaluate side-menu on scroll ([#1830](https://github.com/TypeCellOS/BlockNote/pull/1830))
+- hide table extend buttons when not editable #1848 ([#1850](https://github.com/TypeCellOS/BlockNote/pull/1850), [#1848](https://github.com/TypeCellOS/BlockNote/issues/1848))
+- resolve several drag & drop issues ([#1845](https://github.com/TypeCellOS/BlockNote/pull/1845))
+
+### ❤️ Thank You
+
+- Arek Nawo @areknawo
+- Gonçalo Basto @gbasto
+- Héctor Zhuang @Hector-Zhuang
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Nick the Sick @nperez0111
+- Yousef
+
+## 0.33.0 (2025-07-03)
+
+### 🚀 Features
+
+- FloatingUI options prop for `BlockPositioner` ([#1801](https://github.com/TypeCellOS/BlockNote/pull/1801))
+- Support Google Gemini AI ([#1805](https://github.com/TypeCellOS/BlockNote/pull/1805))
+
+### 🩹 Fixes
+
+- support multi-character suggestions ([#1734](https://github.com/TypeCellOS/BlockNote/pull/1734))
+- switch foreground color based on selected user color dynamically #1785 ([#1787](https://github.com/TypeCellOS/BlockNote/pull/1787), [#1785](https://github.com/TypeCellOS/BlockNote/issues/1785))
+- mark react package as external in email exporter ([#1807](https://github.com/TypeCellOS/BlockNote/pull/1807))
+- Duplicate `formatConversionTest` files ([#1798](https://github.com/TypeCellOS/BlockNote/pull/1798))
+- AI empty document handling ([#1810](https://github.com/TypeCellOS/BlockNote/pull/1810))
+- `bn-inline-content` class name getting duplicated ([#1794](https://github.com/TypeCellOS/BlockNote/pull/1794))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Yousef
+
+## 0.32.0 (2025-06-24)
+
+### 🚀 Features
+
+- toggle blocks ([#1707](https://github.com/TypeCellOS/BlockNote/pull/1707))
+- **core:** support h4, h5, and h6 ([#1634](https://github.com/TypeCellOS/BlockNote/pull/1634))
+- **xl-email-exporter:** add email exporter ([#1768](https://github.com/TypeCellOS/BlockNote/pull/1768))
+
+### 🩹 Fixes
+
+- react 19 strict mode compatibility ([#1726](https://github.com/TypeCellOS/BlockNote/pull/1726))
+- add keys to pdf exporter ([#1739](https://github.com/TypeCellOS/BlockNote/pull/1739))
+- only listten for left click on formatting toolbar ([#1774](https://github.com/TypeCellOS/BlockNote/pull/1774))
+- prevent formatting toolbar from closing if click was from inside the editor ([#1775](https://github.com/TypeCellOS/BlockNote/pull/1775))
+- **locales:** add Hebrew translations for various components ([#1779](https://github.com/TypeCellOS/BlockNote/pull/1779))
+
+### ❤️ Thank You
+
+- Aslam @Aslam97
+- Drew Johnson
+- Jonathan Marbutt @jmarbutt
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Samuel Bisberg
+- Yousef
+
+## 0.31.3 (2025-06-18)
+
+### 🩹 Fixes
+
+- AI generation with empty document ([#1740](https://github.com/TypeCellOS/BlockNote/pull/1740))
+- do not send a welcome email if magic link was used on an account older than a minute ago ([db88fe4aa](https://github.com/TypeCellOS/BlockNote/commit/db88fe4aa))
+- AI system messages should always be at start of prompt ([#1741](https://github.com/TypeCellOS/BlockNote/pull/1741))
+- Selection clicking editor padding ([#1717](https://github.com/TypeCellOS/BlockNote/pull/1717))
+- preserve marks across a shift+enter #1672 ([#1743](https://github.com/TypeCellOS/BlockNote/pull/1743), [#1672](https://github.com/TypeCellOS/BlockNote/issues/1672))
+- **ai:** undo-redo after accepting/rejecting changes will undo as expected ([#1752](https://github.com/TypeCellOS/BlockNote/pull/1752))
+- **locales:** add translations for some comment strings ([#1764](https://github.com/TypeCellOS/BlockNote/pull/1764))
+- **website:** log in bug fixes ([#1742](https://github.com/TypeCellOS/BlockNote/pull/1742))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Nick the Sick
+- Vinicius Fernandes @ViniCleFer
+- Yousef
+
+## 0.31.2 (2025-06-05)
+
+### 🩹 Fixes
+
+- re-release ([0bc546e18](https://github.com/TypeCellOS/BlockNote/commit/0bc546e18))
+- ignore falsy values in boolean prop schema ([#1730](https://github.com/TypeCellOS/BlockNote/pull/1730))
+
+### ❤️ Thank You
+
+- Nick Perez
+- Nick the Sick
+
+## 0.31.1 (2025-05-23)
+
+### 🩹 Fixes
+
+- backwards-compat for `_extensions` ([#1708](https://github.com/TypeCellOS/BlockNote/pull/1708))
+
+### ❤️ Thank You
+
+- Nick Perez
+
+## 0.31.0 (2025-05-20)
+
+### 🩹 Fixes
+
+- Playwright flaky keyboard handler test ([#1704](https://github.com/TypeCellOS/BlockNote/pull/1704))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+
+## 0.30.1 (2025-05-20)
+
+### 🩹 Fixes
+
+- better type-safety ([678086d4d](https://github.com/TypeCellOS/BlockNote/commit/678086d4d))
+- do not use `editor.dispatch` ([#1698](https://github.com/TypeCellOS/BlockNote/pull/1698))
+- re-added `display: flex` to blocks without inline content ([#1702](https://github.com/TypeCellOS/BlockNote/pull/1702))
+- **react:** add missing exports ([#1689](https://github.com/TypeCellOS/BlockNote/pull/1689))
+
+### ❤️ Thank You
+
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Nick the Sick
+
+## 0.30.0 (2025-05-09)
+
+### 🚀 Features
+
+- expose `editor.prosemirrorState` again ([#1615](https://github.com/TypeCellOS/BlockNote/pull/1615))
+- add `undo` and `redo` methods to editor API ([#1592](https://github.com/TypeCellOS/BlockNote/pull/1592))
+- new auth & payment system ([#1617](https://github.com/TypeCellOS/BlockNote/pull/1617))
+- re-implement Y.js collaboration as BlockNote plugins ([#1638](https://github.com/TypeCellOS/BlockNote/pull/1638))
+- **file:** `previewWidth` prop now defaults to `undefined` ([#1664](https://github.com/TypeCellOS/BlockNote/pull/1664))
+- **locales:** add zh-TW i18n ([#1668](https://github.com/TypeCellOS/BlockNote/pull/1668))
+
+### 🩹 Fixes
+
+- Formatting toolbar regression ([#1630](https://github.com/TypeCellOS/BlockNote/pull/1630))
+- provide `blockId` to `uploadFile` in UploadTab ([#1641](https://github.com/TypeCellOS/BlockNote/pull/1641))
+- do not close the menu on content/selection change ([#1644](https://github.com/TypeCellOS/BlockNote/pull/1644))
+- keep file panel open during collaboration ([#1646](https://github.com/TypeCellOS/BlockNote/pull/1646))
+- force pasting plain text into code block ([#1663](https://github.com/TypeCellOS/BlockNote/pull/1663))
+- updating HTML parsing rules to account for `prosemirror-model@1.25.1` ([#1661](https://github.com/TypeCellOS/BlockNote/pull/1661))
+- **code-block:** handle unknown languages better ([#1626](https://github.com/TypeCellOS/BlockNote/pull/1626))
+- **locales:** add slovak i18n ([#1649](https://github.com/TypeCellOS/BlockNote/pull/1649))
+
+### ❤️ Thank You
+
+- l0st0 @l0st0
+- Lawrence Lin @linyiru
+- Matthew Lipski @matthewlipski
+- Nick Perez
+- Quentin Nativel
+
+## 0.29.1 (2025-04-17)
+
+### 🩹 Fixes
+
+- try not to always use workspace version ([7af344ea9](https://github.com/TypeCellOS/BlockNote/commit/7af344ea9))
+
+### ❤️ Thank You
+
+- Nick the Sick
+
+## 0.29.0 (2025-04-17)
+
+### 🚀 Features
+
+- `change` event allows getting a list of the block changed ([#1585](https://github.com/TypeCellOS/BlockNote/pull/1585))
+
+### 🩹 Fixes
+
+- allow opening another suggestion menu if another is triggered #1473 ([#1591](https://github.com/TypeCellOS/BlockNote/pull/1591), [#1473](https://github.com/TypeCellOS/BlockNote/issues/1473))
+- add quote to schema ([aa16b15fe](https://github.com/TypeCellOS/BlockNote/commit/aa16b15fe))
+- update y-prosemirror to fix #1462 ([#1608](https://github.com/TypeCellOS/BlockNote/pull/1608), [#1462](https://github.com/TypeCellOS/BlockNote/issues/1462))
+- dispatch suggestion menu as a separate transaction ([#1614](https://github.com/TypeCellOS/BlockNote/pull/1614))
+
+### ❤️ Thank You
+
+- Nick Perez
+- Nick the Sick
+
+## 0.28.0 (2025-04-07)
+
+### 🚀 Features
+
+- position storage ([#1529](https://github.com/TypeCellOS/BlockNote/pull/1529))
+
+### ❤️ Thank You
+
+- Nick Perez
+
+## 0.27.2 (2025-04-05)
+
+### 🩹 Fixes
+
+- minor update for publishing ([c2820fdac](https://github.com/TypeCellOS/BlockNote/commit/c2820fdac))
+
+### ❤️ Thank You
+
+- Nick the Sick
+
+## 0.27.1 (2025-04-05)
+
+### 🚀 Features
+
+- **nx-cloud:** set up nx workspace ([#1586](https://github.com/TypeCellOS/BlockNote/pull/1586))
+
+### 🩹 Fixes
+
+- update packages to use correct react versions ([ea11ebce0](https://github.com/TypeCellOS/BlockNote/commit/ea11ebce0))
+
+### ❤️ Thank You
+
+- Nick Perez
+- Nick the Sick
+
+## 0.27.0 (2025-04-04)
+
+### 🚀 Features
+
+- split out localization files for optimized bundle ([#1533](https://github.com/TypeCellOS/BlockNote/pull/1533))
+- remove shiki dep, add new @blocknote/code-block package for slim shiki build ([#1519](https://github.com/TypeCellOS/BlockNote/pull/1519))
+- Block quote ([#1563](https://github.com/TypeCellOS/BlockNote/pull/1563))
+- markdown pasting & custom paste handlers ([#1490](https://github.com/TypeCellOS/BlockNote/pull/1490))
+
+### 🩹 Fixes
+
+- Backspace in empty block deletes previous block ([#1505](https://github.com/TypeCellOS/BlockNote/pull/1505))
+- Selection when clicking past end of inline content ([#1553](https://github.com/TypeCellOS/BlockNote/pull/1553))
+- better expose setting a draghandlemenu's items #1525 ([#1526](https://github.com/TypeCellOS/BlockNote/pull/1526), [#1525](https://github.com/TypeCellOS/BlockNote/issues/1525))
+- Multi-block links ([#1565](https://github.com/TypeCellOS/BlockNote/pull/1565))
+- Hard break keyboard shortcut not working in custom blocks ([#1554](https://github.com/TypeCellOS/BlockNote/pull/1554))
+- Overlapping marks in comments ([#1564](https://github.com/TypeCellOS/BlockNote/pull/1564))
+- some more sentry fixes ([#1577](https://github.com/TypeCellOS/BlockNote/pull/1577))
+
+### ❤️ Thank You
+
+- Martinrsts @Martinrsts
+- Matthew Lipski @matthewlipski
+- Nick Perez
+
+## Previous Versions
+
+See [Github Releases](https://github.com/TypeCellOS/BlockNote/releases) for previous versions.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9159700782..8fe42686f0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -23,26 +23,65 @@ An introduction into the BlockNote Prosemirror schema can be found in [packages/
To run the project, open the command line in the project's root directory and enter the following commands:
- # Install all required npm modules for lerna, and bootstrap lerna packages
- npm install
- npm run bootstrap
+```bash
+# Install all required npm modules
+pnpm install
- # Start the example project
- npm start
+# Start the example project
+pnpm start
+```
## Adding packages
- Add the dependency to the relevant `package.json` file (packages/xxx/package.json)
-- run `npm run install-new-packages`
-- Double check `package-lock.json` to make sure only the relevant packages have been affected
+- Double check `pnpm-lock.yaml` to make sure only the relevant packages have been affected
-## Packages:
+## Packages
| Package | Size | Version |
-|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
+| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| [@blocknote/core](https://github.com/TypeCellOS/BlockNote/tree/main/packages/core) |
|
|
| [@blocknote/react](https://github.com/TypeCellOS/BlockNote/tree/main/packages/react) |
|
|
| [@blocknote/ariakit](https://github.com/TypeCellOS/BlockNote/tree/main/packages/ariakit) |
|
|
| [@blocknote/mantine](https://github.com/TypeCellOS/BlockNote/tree/main/packages/mantine) |
|
|
| [@blocknote/shadcn](https://github.com/TypeCellOS/BlockNote/tree/main/packages/shadcn) |
|
|
| [@blocknote/server-util](https://github.com/TypeCellOS/BlockNote/tree/main/packages/server-util) |
|
|
+
+## Releasing
+
+This diagram illustrates the release workflow for the BlockNote monorepo.
+
+
+
+Essentially, when the maintainers have decided to release a new version of BlockNote, they will:
+
+ 1. Check that the `main` branch is in a releasable state:
+ - CI status of main branch is green
+ - Builds are passing
+ 2. Bump the package versions using the `pnpm run deploy` command. This command will:
+ 1. Based on semantic versioning, determine the next version number.
+ 2. Apply the new version number to all publishable packages within the monorepo.
+ 3. Generate a changelog for the new version.
+ 4. Commit the changes to the `main` branch.
+ 5. Create a new git tag for the new version.
+ 6. Push the changes to the `origin` remote.
+ 7. Create a new GitHub Release with the same name as the new version.
+ 8. Trigger a release workflow.
+
+The release workflow will:
+
+1. Checkout the `main` branch.
+2. Install the dependencies.
+3. Build the project.
+4. Login to npm.
+5. Publish the packages to npm.
+
+### Publishing a new package
+
+From time to time, you may need to publish a new package to npm. To do this, you cannot just deploy the package to npm, you need to:
+
+ 1. Run `nx release version --dry-run` and check that the version number is correct for the package.
+ - Once this is done, you can run `nx release version` to actually apply the version bump locally (staged to your local git repo).
+ 2. Run `nx release changelog --from
Homepage
- -
+ -
Documentation
- -
+ -
Quickstart
-
Examples
@@ -48,8 +44,6 @@ function App() {
`@blocknote/react` comes with a fully styled UI that makes it an instant, polished editor ready to use in your app.
-If you prefer to create your own UI components (menus), or don't want to use React, you can use `@blocknote/core` (_advanced_, [see docs](https://www.blocknotejs.org/docs/vanilla-js)).
-
# Features
BlockNote comes with a number of features and components to make it easy to embed a high-quality block-based editor in your app:
@@ -84,31 +78,19 @@ BlockNote comes with a number of features and components to make it easy to embe
# Feedback 🙋♂️🙋♀️
-We'd love to hear your thoughts and see your experiments, so [come and say hi on Discord](https://discord.gg/Qc2QTTH5dF) or [Matrix](https://matrix.to/#/#typecell-space:matrix.org).
+We'd love to hear your thoughts and see your experiments, so [come and say hi on Discord](https://discord.gg/Qc2QTTH5dF).
# Contributing 🙌
-See [CONTRIBUTING.md](CONTRIBUTING.md) for more info and guidance on how to run the project (TLDR: just use `npm start`).
-
-Directory structure:
-
-```
-blocknote
-├── packages/core - The core of the editor
-├── packages/react - The main library for use in React apps
-├── packages/mantine - Mantine (default) implementation of BlockNote UI
-├── packages/ariakit - AriaKit implementation of BlockNote UI
-├── packages/shadcn - ShadCN / Tailwind / Radix implementation of BlockNote UI
-├── examples - Example apps
-├── playground - App to browse the example apps (https://playground.blocknotejs.org)
-└── tests - Playwright end to end tests
-```
+See [CONTRIBUTING.md](CONTRIBUTING.md) for more info and guidance on how to run the project (TLDR: just use `pnpm start`).
The codebase is automatically tested using Vitest and Playwright.
# License 📃
-BlockNote is licensed under the [MPL 2.0 license](https://fossa.com/blog/open-source-software-licenses-101-mozilla-public-license-2-0/), which allows you to use BlockNote in commercial (and closed-source) applications. If you make changes to the BlockNote source files, you're expected to publish these changes so the wider community can benefit as well.
+BlockNote is 100% Open Source Software. The majority of BlockNote is licensed under the [MPL-2.0 license](LICENSE-MPL.txt), which allows you to use BlockNote in commercial (and closed-source) applications. If you make changes to the BlockNote source files, you're expected to publish these changes so the wider community can benefit as well. [Learn more](https://fossa.com/blog/open-source-software-licenses-101-mozilla-public-license-2-0/).
+
+The XL packages (source code in the `packages/xl-*` directories and published in NPM as `@blocknote/xl-*`) are licensed under the GPL-3.0. If you cannot comply with this license and want to use the XL libraries, you'll need a commercial license. Refer to [our website](https://www.blocknotejs.org/pricing) for more information.
# Credits ❤️
@@ -121,3 +103,5 @@ BlockNote is built as part of [TypeCell](https://www.typecell.org). TypeCell is
Hosting and deployments powered by Vercel:
+ Every BlockNote document is a collection of blocks—headings, lists,
+ images, and more. Use the built-in blocks, customize them to fit
+ your needs, or create entirely new ones.
+
+ France, Germany, and the Netherlands partner to build{" "}
+
+ Docs
+
+ , a collaborative writing tool for thousands of public servants.{" "}
+ BlockNote is the engine.
+
+ "Building Digital Commons means better tools, data
+ sovereignty, and shared progress."
+
+ {faq.answer}
+
+ {description}
+
+ The AI-native, open source rich
+ text editor for React. Add a{" "}
+ fully customizable modern block-based editing
+ experience to your product that users will love.
+
+ Building a rich text editor is one of the hardest engineering
+ challenges on the web. It used to take months of specialized
+ work.
+
+ We believe that great tools should be{" "}
+ sovereign by default. You shouldn't have
+ to choose between a cohesive UX and owning your
+ infrastructure.
+
+ That's why we built BlockNote. A{" "}
+ batteries-included editor that gives you a
+ Notion-quality experience in minutes, while staying grounded
+ in open standards like{" "}
+
+ ProseMirror
+ {" "}
+ and Yjs.
+
+ Whether you're a startup or a public institution, you
+ deserve software that lasts. Join us to{" "}
+
+ shape the future
+
+ {" "}
+ of the open web.
+
+ Forget low-level details. Work with a strongly typed API.
+ Get modern UI components out-of-the-box.
+
+ Document editing is foundational infrastructure for the modern
+ workforce. We believe the tools we use to create and share knowledge
+ should be open, transparent, and free from lock-in. That's why
+ everything we build is open source.
+
+ {pillar.description}
+
+ "Here we could put a quote about our open source commitment."
+
+ Transparent pricing
+
+ BlockNote is 100% open source. Here's how licensing works.
+
+ The majority of BlockNote (including all blocks, real-time
+ collaboration, comments, and UI components) are liberally
+ licensed.
+
+ Free to use in any project; personal, open source, or commercial.
+
+ ✓ Free for everyone
+
+ Advanced features like AI integration,{" "}
+ PDF / Word / ODT exports, and{" "}
+ multi-column layouts.
+
+ Free for open source projects under GPL-3.0. Closed source
+ projects require a subscription.
+
+ ✓ Free for open source
+
+ {props.title}
+
+ "{testimonial.quote}"
+
+ From startups to enterprises, teams choose BlockNote to build their
+ document experiences.
+
+ BlockNote combines a premium editing experience with the flexibility
+ of open standards. Zero compromise.
+
+ Stop building rich text editors from scratch. BlockNote comes with
+ a polished, modern UI that works out of the box.
+
+ Forget low-level editor internals. We abstract away the complex
+ parts and give you a type-safe, intuitive API.
+
+
+ Upgrade
+ {" "}
+ to unlock AI support for commercial products, or partner with our
+ team for advanced integrations and support.
+
+ Try all features combined in our full-featured demo editor.
+
+ We couldn't find the page you're looking for, but here
+ are some pages that might help:
+
+ {result.url}
+
+ No similar pages found. Try searching or browsing our
+ documentation.
+
- Get started by editing
-
- Find in-depth information about Next.js features and API.
-
- Learn about Next.js in an interactive course with quizzes!
-
- Explore starter templates for Next.js.
-
- Instantly deploy your Next.js site to a shareable URL with Vercel.
-
+ Pricing
+
+ The majority of BlockNote is liberally licensed and free to use for
+ any purpose. The dual-licensed XL features (like AI) are free for
+ open source projects, but require a commercial license for
+ closed-source applications.
+
+ Trusted by teams building the future of collaboration
+
+ Building the next big thing? We love supporting early-stage
+ companies. If you're a seed-stage startup or non-profit, get in
+ touch for special pricing on our Business plan.
+
+ {tier.tagline}
+
+ ${tier.price.year.toLocaleString()} billed yearly
+
+ {tier.description}
+ {signingInState.message}
+ {
+ router.push(
+ `${props.variant === "email" ? "/signup" : "/signin"}?redirect=${encodeURIComponent(callbackURL)}`,
+ );
+ }}
+ >
+ {props.variant === "email"
+ ? "Don't have an account? Sign Up"
+ : props.variant === "password"
+ ? "Return to email login"
+ : "Already have an account? Log In"}
+
+
+ Get access to the full source code for pro examples by subscribing to
+ BlockNote Pro
+
+ Or{" "}
+ {" "}
+ via GitHub
+
- A better workflow
-
- Lorem ipsum, dolor sit amet consectetur adipisicing elit.
- Maiores impedit perferendis suscipit eaque, iste dolor
- cupiditate blanditiis ratione.
-
- “Vel ultricies morbi odio facilisi ultrices accumsan donec
- lacus purus. Lectus nibh ullamcorper ac dictum justo in
- euismod. Risus aenean ut elit massa. In amet aliquet eget
- cras. Sem volutpat enim tristique.”
-
+
BlockNote is an extensible React rich text editor with support for
- block-based editing, collaboration and comes with ready-to-use
- customizable UI components.
+ block-based editing, real-time collaboration, and comes with
+ ready-to-use customizable UI components.
- © {new Date().getFullYear()} BlockNote maintainers. All
- rights reserved.
-
+ © {new Date().getFullYear()} BlockNote maintainers. All rights
+ reserved.
+
+
+
+Welcome to BlockNote! The open source Block-Based
+React rich text editor. Easily add a modern text editing experience to your app.
+
+
+ Homepage
+ -
+ Documentation
+ -
+ Quickstart
+ -
+ Examples
+
+ |) move it backwards so we drop empty start tags ( |) move it backwards so we include all open tags (| )
+ while (start.parentOffset === 0 && start.depth > 0) {
+ start = tr.doc.resolve(start.pos - 1);
+ }
+
+ // if the start is at the end of a node (| |) move it forwards so we drop all closing tags (| )
+ while (start.parentOffset >= start.parent.nodeSize - 2 && start.depth > 0) {
+ start = tr.doc.resolve(start.pos + 1);
+ }
+
+ const selectionInfo = prosemirrorSliceToSlicedBlocks(
+ tr.doc.slice(start.pos, end.pos, true),
+ pmSchema,
+ );
+
+ return {
+ _meta: {
+ startPos: start.pos,
+ endPos: end.pos,
+ },
+ ...selectionInfo,
+ };
+}
diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
new file mode 100644
index 0000000000..83f5340698
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
@@ -0,0 +1,118 @@
+import type { Node } from "prosemirror-model";
+import {
+ NodeSelection,
+ TextSelection,
+ type Transaction,
+} from "prosemirror-state";
+import type { TextCursorPosition } from "../../../editor/cursorPositionTypes.js";
+import type {
+ BlockIdentifier,
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../../schema/index.js";
+import { UnreachableCaseError } from "../../../util/typescript.js";
+import {
+ getBlockInfo,
+ getBlockInfoFromTransaction,
+} from "../../getBlockInfoFromPos.js";
+import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js";
+import { getNodeById } from "../../nodeUtil.js";
+import { getBlockNoteSchema, getPmSchema } from "../../pmUtil.js";
+
+export function getTextCursorPosition<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(tr: Transaction): TextCursorPosition Nested Paragraph 1 Nested Paragraph 1 Nested Paragraph 2 Nested Paragraph 3 Nested Paragraph 1 Nested Paragraph 2 Nested Paragraph 3 Nested Paragraph 1 Nested Paragraph 2 Nested Paragraph 3 Nested Paragraph Nested Paragraph 1 Nested Paragraph 2 Nested Paragraph 3 Nested Paragraph Nested Table Cell Table Cell Table Cell Table Cell Table Cell Table Cell Table Cell Paragraph Paragraph Hello World Custom Paragraph Hello World Line 1 Hello World Hello World Hello World Custom Paragraph Nested Custom Paragraph 1 Nested Custom Paragraph 2 Hello World Plain Red Text Blue Background Mixed Colors Caption example Caption Add file Caption Caption example Caption example Caption example Caption Caption This is text with a custom fontSize This is text with a custom fontSize Text1 Text1 Text1 Text1 Text1 Text1 Text1 Text1 Caption Add image Caption Caption Caption Caption example Caption Bullet List Item 1 Bullet List Item 2 Numbered List Item 1 Numbered List Item 2 Check List Item 1 Check List Item 2 Bullet List Item 1 Bullet List Item 2 Numbered List Item 1 Numbered List Item 2 Check List Item 1 Check List Item 2 Bullet List Item 1 Bullet List Item 2 Numbered List Item 1 Numbered List Item 2 Check List Item 1 Check List Item 2 Bullet List Item 1 Bullet List Item 2 Numbered List Item 1 Numbered List Item 2 Check List Item 1 Check List Item 2 I enjoy working with @Matthew I enjoy working with @Matthew Paragraph Line 1 Line 1 Paragraph Nested Paragraph 1 Nested Paragraph 2 Paragraph Nested Paragraph 1 Nested Paragraph 2 Plain Red Text Blue Background Mixed Colors Plain Red Text Blue Background Mixed Colors Custom Paragraph Custom Paragraph Custom Paragraph Nested Custom Paragraph 1 Nested Custom Paragraph 2 Custom Paragraph Nested Custom Paragraph 1 Nested Custom Paragraph 2 Plain Red Text Blue Background Mixed Colors Plain Red Text Blue Background Mixed Colors Caption Caption Add file Add file Caption Caption Caption Caption Caption Caption Add image Add image Caption Caption Caption Caption Caption Caption example Caption example Caption This is a small text This is a small text I love #BlockNote I love #BlockNote )
+ const blockChildren = Array.from(el.children).filter((child) => {
+ const tag = child.tagName.toLowerCase();
+ return ["p", "ul", "ol", "pre", "blockquote", "table", "hr"].includes(tag);
+ });
+
+ let content: string;
+ if (blockChildren.length > 0) {
+ // Has block-level children — serialize each
+ const parts: string[] = [];
+ for (const child of blockChildren) {
+ const tag = child.tagName.toLowerCase();
+ if (tag === "p") {
+ parts.push(serializeInlineContent(child as HTMLElement));
+ } else {
+ const innerCtx: SerializeContext = { indent: "", inListItem: false };
+ parts.push(serializeNode(child, innerCtx).trim());
+ }
+ }
+ content = parts.join("\n\n");
+ } else {
+ // No block-level children — treat entire content as inline
+ content = serializeInlineContent(el);
+ }
+
+ const lines = content.split("\n");
+ return lines.map((line) => ctx.indent + "> " + line).join("\n") + "\n\n";
+}
+
+function serializeCodeBlock(el: HTMLElement, ctx: SerializeContext): string {
+ const codeEl = el.querySelector("code");
+ if (!codeEl) {return "";}
+
+ const language =
+ codeEl.getAttribute("data-language") ||
+ extractLanguageFromClass(codeEl.className) ||
+ "";
+
+ // Extract code content, handling
+
+This project is tested with BrowserStack
diff --git a/docs/.env.local.example b/docs/.env.local.example
index c0f2a490a6..a2dba36b4b 100644
--- a/docs/.env.local.example
+++ b/docs/.env.local.example
@@ -1,4 +1,35 @@
AUTH_SECRET= # Linux: `openssl rand -hex 32` or go to https://generate-secret.vercel.app/32
-AUTH_GITHUB_ID=
-AUTH_GITHUB_SECRET=
\ No newline at end of file
+# Better Auth Deployed URL
+BETTER_AUTH_URL=http://localhost:3000
+
+# ======= OPTIONAL =======
+
+# # Polar Sandbox is used in dev mode: https://sandbox.polar.sh/
+# # You may need to delete your user in their dashboard if you get a "cannot attach new external ID error"
+# POLAR_ACCESS_TOKEN=
+# POLAR_WEBHOOK_SECRET=
+
+# # In production, we use postgres
+# POSTGRES_URL=
+
+# # Email
+# SMTP_HOST=
+# SMTP_USER=
+# SMTP_PASS=
+# SMTP_PORT=
+# # Insecure if false, secure if any other value
+# SMTP_SECURE=false
+
+# # For GitHub Signin method
+# AUTH_GITHUB_ID=
+# AUTH_GITHUB_SECRET=
+
+# # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
+# # It's used for authentication when uploading source maps.
+# SENTRY_AUTH_TOKEN=
+
+NEXT_PUBLIC_BLOCKNOTE_AI_SERVER_API_KEY=
+NEXT_PUBLIC_BLOCKNOTE_AI_SERVER_BASE_URL=
+
+TURNSTILE_SECRET_KEY=
\ No newline at end of file
diff --git a/docs/.eslintrc.json b/docs/.eslintrc.json
deleted file mode 100644
index bffb357a71..0000000000
--- a/docs/.eslintrc.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "extends": "next/core-web-vitals"
-}
diff --git a/docs/.gitignore b/docs/.gitignore
index fd3dbb571a..c12953bff8 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -1,36 +1,32 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
+# deps
/node_modules
-/.pnp
-.pnp.js
-.yarn/install-state.gz
-# testing
-/coverage
+# generated content
+.source
-# next.js
+# test & build
+/coverage
/.next/
/out/
-
-# production
/build
+*.tsbuildinfo
# misc
.DS_Store
*.pem
-
-# debug
+/.pnp
+.pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-# local env files
+# others
.env*.local
-
-# vercel
.vercel
-
-# typescript
-*.tsbuildinfo
next-env.d.ts
+# Sentry Config File
+.env.sentry-build-plugin
+
+/content/examples/*/*
+/components/example/generated/
+sqlite.db
diff --git a/docs/README.md b/docs/README.md
index c4033664f8..b8e4860113 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,36 +1,105 @@
-This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+# Website Development
-## Getting Started
+This is the code for the [BlockNote documentation website](https://www.blocknotejs.org). If you're looking to work on BlockNote itself, check the [`packages`](/packages/) folder.
-First, run the development server:
+To get started with development of the website, you can follow these steps:
+
+1. Initialize the DB
+
+If you haven't already, you can initialize the database with the following command:
+
+```bash
+cd docs && pnpm run init-db
+```
+
+This will initialize an SQLite database at `./docs/sqlite.db`.
+
+2. Setup environment variables
+
+Copy the `.env.example` file to `.env.local` and set the environment variables.
+
+```bash
+cp .env.example .env.local
+```
+
+If you want to test logging in, or payments see more information below [in the environment variables section](#environment-variables).
+
+3. Start the development server from within the `./docs` directory.
+
+```bash
+pnpm run dev
+```
+
+This will start the development server on port 3000.
+
+## Environment Variables
+
+### Logging in
+
+To test logging in, you can set the following environment variables:
```bash
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
+AUTH_SECRET=test
+# Github OAuth optionally
+AUTH_GITHUB_ID=test
+AUTH_GITHUB_SECRET=test
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Note: the GITHUB_ID and GITHUB_SECRET are optional, but if you want to test logging in with Github you'll need to set them. For local development, you'll need to set the callback URL to `http://localhost:3000/api/auth/callback/github`
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+### Payments
-This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
+To test payments, you can set the following environment variables:
-## Learn More
+```bash
+POLAR_ACCESS_TOKEN=test
+POLAR_WEBHOOK_SECRET=test
+```
+
+For testing payments, you'll need access to the polar sandbox which needs to be configured to point a webhook to your local server. This can be configured at:
+ Build anything, block by block.
+
+
+ Three nations choose
+
+ {/* Short punchy copy */}
+
+
+ open source
+ {" "}
+ to power
+
+ their digital future.
+
+ Questions?
+
+
+ {faq.question}
+
+
+ {title}
+
+
+ Build a Notion-style{" "}
+ editor in minutes.
+
+
+ Let's build.
+
+
+ Enter BlockNote.
+
+
+ Committed to open source.
+
+ {pillar.title}
+
+
+
+ Subscribe to BlockNote XL.
+
+
+ Core Editor
+
+
+ XL Packages
+
+
+ Trusted by teams everywhere.
+
+
+ The editor you'd build, if you had the time.
+
+ {/*
+ Batteries included UX
+
+
+ Built for Developers
+
+
+ Partnerships
+
+
+ );
+ }
+
+ // Case 2: Code/Video - usually needs Chrome
+ return (
+
Whoops. What the blocks!?
+ Whoops. What the blocks!?
+
+ Try BlockNote
+
+ Whoops. What the blocks!?
+
+ Interactive Playground
+
+ Whoops. What the blocks!?
+
+ Page Not Found
+
+
+ {useFallback ? "Popular Pages" : "Similar Pages"}
+
+
+ {result.content}
+
+ app/page.tsx
-
- Docs{" "}
-
- ->
-
-
-
- Learn{" "}
-
- ->
-
-
-
- Templates{" "}
-
- ->
-
-
-
- Deploy{" "}
-
- ->
-
-
-
+ The XL packages (like AI integration, multi-column layouts, and
+ exporters) are dual-licensed and available under{" "}
+ GPL-3.0, or -
+ for closed-source projects - a commercial license as part of the
+ BlockNote Business subscription or above. See the{" "}
+
+ commercial license terms
+ {" "}
+ for the exact details.
+ >
+ ),
+ },
+ {
+ question: "When do I need a commercial license?",
+ answer: (
+ <>
+ Only when you use any of the XL packages (like AI integration,
+ multi-column layouts, and exporters) and you cannot comply with the
+ GPL-3.0 license you'll need a{" "}
+
+ commercial license
+
+ . This is likely to be the case when you're building closed-source
+ applications. The BlockNote Business subscription and above includes a
+ commercial license.
+ >
+ ),
+ },
+ {
+ question: "Why did you choose to dual-license the XL packages?",
+ answer: (
+ <>
+ We’ve built BlockNote as open source from day one and remain committed
+ to keeping the core library licensed under the MPL 2.0. That means it’s
+ free to use—even in commercial and closed-source projects.
+
+ To sustainably support ongoing development, we offer a small set of
+ advanced features (the XL packages) under a dual-license model:
+
+
+ This approach allows us to fund a full-time team while keeping 100% of
+ the code we build open source. It’s our way of balancing community
+ accessibility with long-term sustainability.
+ >
+ ),
+ },
+ {
+ question: "What kind of support is included in a license?",
+ answer: (
+ <>
+ We have you covered! All BlockNote subscriptions come with prioritized
+ support. See the{" "}
+
+ Service Level Agreement
+ {" "}
+ for the exact details.
+ >
+ ),
+ },
+ {
+ question:
+ "Is there any limit to the number of documents or users I can have?",
+ answer: `With BlockNote, there are no limits on the number of documents or users you can have.
+ You're free to run the software on your own infrastructure, and none of your data passes through our servers — your documents and users remain entirely your business.`,
+ },
+ {
+ question: "What if I have more than one SaaS or Web application?",
+ answer: (
+ <>
+ The BlockNote Commercial license (included in the Business tier and
+ above) for XL packages covers one application per license. See the{" "}
+
+ commercial license terms
+ {" "}
+ for the exact details.
+
+ If you want to use XL packages in more than one app, contact us at
+ team@blocknotejs.org; we're happy to work with you on a custom
+ license.
+ >
+ ),
+ },
+ {
+ question: "Do you offer any discounts for startups?",
+ answer: (
+ <>
+ Yes! We offer a discount for startups with less than 5 employees. See
+ the{" "}
+
+ commercial license terms
+ {" "}
+ for the exact details.
+ >
+ ),
+ },
+ {
+ question: "What payment methods do you accept?",
+ answer: `We accept all major credit cards. If you require a different payment method, please contact us.`,
+ },
+];
+
+export function FAQ() {
+ return (
+
+ 100% Open Source.
+
+
+
+ Fair Pricing.
+
+
+
+ Discounts for Startups
+
+
+ {tier.title}
+
+ {tier.tagline && (
+
+ {tier.features.map((feature, index) => (
+
+
+ {props.variant === "password"
+ ? "Login to your BlockNote account"
+ : props.variant === "email"
+ ? "Login with your email account"
+ : "Create an account"}
+
+
- Deploy faster
-
-
-
-
-
- {children}
;
+ return (
+ {children}
+ );
}
const navigation = {
@@ -63,70 +68,77 @@ export function FooterContent() {
Footer
-
- {navigation.general.map((item) => (
-
-
- {navigation.collaborate().map((item) => (
-
-
- {navigation.community.map((item) => (
-
-
-
+ {navigation.general.map((item) => (
+
- {/*
+ {navigation.collaborate().map((item) => (
+
+
+ {navigation.community.map((item) => (
+
+
+
+
+
+
+### Helpful placeholders:
+
+
+
+### Drag and drop blocks:
+
+
+
+### Nesting / indentation with tab and shift+tab:
+
+
+
+### Slash (/) menu:
+
+
+
+### Format menu:
+
+
+
+### Real-time collaboration:
+
+
+
+# Feedback 🙋♂️🙋♀️
+
+We'd love to hear your thoughts and see your experiments, so [come and say hi on Discord](https://discord.gg/Qc2QTTH5dF).
+
+# Contributing 🙌
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for more info and guidance on how to run the project (TLDR: just use `pnpm start`).
+
+The codebase is automatically tested using Vitest and Playwright.
+
+# License 📃
+
+BlockNote is 100% Open Source Software. The majority of BlockNote is licensed under the [MPL-2.0 license](LICENSE-MPL.txt), which allows you to use BlockNote in commercial (and closed-source) applications. If you make changes to the BlockNote source files, you're expected to publish these changes so the wider community can benefit as well. [Learn more](https://fossa.com/blog/open-source-software-licenses-101-mozilla-public-license-2-0/).
+
+The XL packages (source code in the `packages/xl-*` directories and published in NPM as `@blocknote/xl-*`) are licensed under the GPL-3.0. If you cannot comply with this license and want to use the XL libraries, you'll need a commercial license. Refer to [our website](https://www.blocknotejs.org/pricing) for more information.
+
+# Credits ❤️
+
+BlockNote builds directly on two awesome projects; [Prosemirror](https://prosemirror.net/) by Marijn Haverbeke and [Tiptap](https://tiptap.dev/). Consider sponsoring those libraries when using BlockNote: [Prosemirror](https://marijnhaverbeke.nl/fund/), [Tiptap](https://github.com/sponsors/ueberdosis).
+
+BlockNote is built as part of [TypeCell](https://www.typecell.org). TypeCell is proudly sponsored by the renowned [NLNet foundation](https://nlnet.nl/foundation/) who are on a mission to support an open internet, and protect the privacy and security of internet users. Check them out!
+
+
+
+Hosting and deployments powered by Vercel:
+
+
+
+This project is tested with BrowserStack
diff --git a/packages/core/package.json b/packages/core/package.json
index b0042e4a33..c37562b259 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -2,8 +2,16 @@
"name": "@blocknote/core",
"homepage": "https://github.com/TypeCellOS/BlockNote",
"private": false,
+ "sideEffects": [
+ "*.css"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TypeCellOS/BlockNote.git",
+ "directory": "packages/core"
+ },
"license": "MPL-2.0",
- "version": "0.16.0",
+ "version": "0.51.0",
"files": [
"dist",
"types",
@@ -26,21 +34,48 @@
"type": "module",
"source": "src/index.ts",
"types": "./types/src/index.d.ts",
- "main": "./dist/blocknote.umd.cjs",
+ "main": "./dist/blocknote.cjs",
"module": "./dist/blocknote.js",
"exports": {
".": {
"types": "./types/src/index.d.ts",
"import": "./dist/blocknote.js",
- "require": "./dist/blocknote.umd.cjs"
+ "require": "./dist/blocknote.cjs"
},
"./style.css": {
"import": "./dist/style.css",
- "require": "./dist/style.css"
+ "require": "./dist/style.css",
+ "style": "./dist/style.css"
},
"./fonts/inter.css": {
"import": "./src/fonts/inter.css",
- "require": "./src/fonts/inter.css"
+ "require": "./src/fonts/inter.css",
+ "style": "./src/fonts/inter.css"
+ },
+ "./comments": {
+ "types": "./types/src/comments/index.d.ts",
+ "import": "./dist/comments.js",
+ "require": "./dist/comments.cjs"
+ },
+ "./blocks": {
+ "types": "./types/src/blocks/index.d.ts",
+ "import": "./dist/blocks.js",
+ "require": "./dist/blocks.cjs"
+ },
+ "./locales": {
+ "types": "./types/src/i18n/index.d.ts",
+ "import": "./dist/locales.js",
+ "require": "./dist/locales.cjs"
+ },
+ "./extensions": {
+ "types": "./types/src/extensions/index.d.ts",
+ "import": "./dist/extensions.js",
+ "require": "./dist/extensions.cjs"
+ },
+ "./yjs": {
+ "types": "./types/src/yjs/index.d.ts",
+ "import": "./dist/yjs.js",
+ "require": "./dist/yjs.cjs"
}
},
"scripts": {
@@ -51,73 +86,52 @@
"lint": "eslint src --max-warnings 0",
"test": "vitest --run",
"test-watch": "vitest watch",
- "clean": "rimraf dist && rimraf types"
+ "clean": "rimraf dist && rimraf types",
+ "update-tlds": "node scripts/update-tlds.mjs"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
- "@tiptap/core": "^2.7.1",
- "@tiptap/extension-bold": "^2.7.1",
- "@tiptap/extension-code": "^2.7.1",
- "@tiptap/extension-collaboration": "^2.7.1",
- "@tiptap/extension-collaboration-cursor": "^2.7.1",
- "@tiptap/extension-dropcursor": "^2.7.1",
- "@tiptap/extension-gapcursor": "^2.7.1",
- "@tiptap/extension-hard-break": "^2.7.1",
- "@tiptap/extension-history": "^2.7.1",
- "@tiptap/extension-horizontal-rule": "^2.7.1",
- "@tiptap/extension-italic": "^2.7.1",
- "@tiptap/extension-link": "^2.7.1",
- "@tiptap/extension-paragraph": "^2.7.1",
- "@tiptap/extension-strike": "^2.7.1",
- "@tiptap/extension-table-cell": "^2.7.1",
- "@tiptap/extension-table-header": "^2.7.1",
- "@tiptap/extension-table-row": "^2.7.1",
- "@tiptap/extension-text": "^2.7.1",
- "@tiptap/extension-underline": "^2.7.1",
- "@tiptap/pm": "^2.7.1",
+ "@handlewithcare/prosemirror-inputrules": "^0.1.4",
+ "@shikijs/types": "^4",
+ "@tanstack/store": "^0.7.7",
+ "@tiptap/core": "^3.13.0",
+ "@tiptap/extension-bold": "^3.13.0",
+ "@tiptap/extension-code": "^3.13.0",
+ "@tiptap/extension-horizontal-rule": "^3.13.0",
+ "@tiptap/extension-italic": "^3.13.0",
+ "@tiptap/extension-paragraph": "^3.13.0",
+ "@tiptap/extension-strike": "^3.13.0",
+ "@tiptap/extension-text": "^3.13.0",
+ "@tiptap/extension-underline": "^3.13.0",
+ "@tiptap/extensions": "^3.13.0",
+ "@tiptap/pm": "^3.13.0",
"emoji-mart": "^5.6.0",
- "hast-util-from-dom": "^4.2.0",
- "prosemirror-model": "^1.21.0",
- "prosemirror-state": "^1.4.3",
- "prosemirror-tables": "^1.3.7",
- "prosemirror-transform": "^1.9.0",
- "prosemirror-view": "^1.33.7",
- "rehype-format": "^5.0.0",
- "rehype-parse": "^8.0.4",
- "rehype-remark": "^9.1.2",
- "rehype-stringify": "^9.0.3",
- "remark-gfm": "^3.0.1",
- "remark-parse": "^10.0.1",
- "remark-rehype": "^10.1.0",
- "remark-stringify": "^10.0.2",
- "unified": "^10.1.2",
- "uuid": "^8.3.2",
- "y-prosemirror": "1.2.12",
+ "fast-deep-equal": "^3.1.3",
+ "lib0": "^0.2.99",
+ "prosemirror-highlight": "^0.15.1",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-tables": "^1.8.3",
+ "prosemirror-transform": "^1.11.0",
+ "prosemirror-view": "^1.41.4",
+ "y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
- "yjs": "^13.6.15"
+ "yjs": "^13.6.27"
},
"devDependencies": {
- "@types/emoji-mart": "^3.0.14",
- "@types/hast": "^2.3.4",
- "@types/uuid": "^8.3.4",
- "eslint": "^8.10.0",
- "jsdom": "^21.1.0",
- "prettier": "^2.7.1",
- "rimraf": "^5.0.5",
- "rollup-plugin-webpack-stats": "^0.2.2",
- "typescript": "^5.3.3",
- "vite": "^5.3.4",
+ "eslint": "^8.57.1",
+ "jsdom": "^29.0.2",
+ "rimraf": "^5.0.10",
+ "rollup-plugin-webpack-stats": "^0.2.6",
+ "typescript": "^5.9.3",
+ "vite": "^8.0.8",
"vite-plugin-eslint": "^1.8.1",
- "vitest": "^2.0.3"
+ "vitest": "^4.1.2"
},
"eslintConfig": {
"extends": [
- "../../.eslintrc.js"
+ "../../.eslintrc.json"
]
},
- "publishConfig": {
- "access": "public",
- "registry": "https://registry.npmjs.org/"
- },
"gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d"
}
diff --git a/packages/core/scripts/update-tlds.mjs b/packages/core/scripts/update-tlds.mjs
new file mode 100644
index 0000000000..43f4d02e15
--- /dev/null
+++ b/packages/core/scripts/update-tlds.mjs
@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+/**
+ * Regenerate src/extensions/tiptap-extensions/Link/helpers/tlds.ts from IANA's
+ * authoritative TLD list.
+ *
+ * Run with: pnpm --filter @blocknote/core update-tlds
+ *
+ * Encoding format ported from linkifyjs (MIT, https://github.com/nfrasser/linkifyjs):
+ * a sorted TLD list is built into a trie, then serialized as an ASCII string
+ * where letters descend the trie and digit runs mean "emit a word and pop N
+ * levels back up." Shared TLD prefixes (e.g. construction/consulting/
+ * contractors) collapse, producing a payload smaller than a flat list.
+ *
+ * IDN punycode entries (XN--...) are skipped: the schemeless URL regex in
+ * linkDetector.ts requires ASCII-only TLDs, so unicode TLDs would never reach
+ * the validation step.
+ */
+
+import { writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+
+const TLDS_URL = "https://data.iana.org/TLD/tlds-alpha-by-domain.txt";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const OUT_PATH = resolve(
+ __dirname,
+ "../src/extensions/tiptap-extensions/Link/helpers/tlds.ts",
+);
+
+function createTrie(words) {
+ const root = {};
+ for (const word of words) {
+ let current = root;
+ for (const letter of word) {
+ if (!(letter in current)) {
+ current[letter] = {};
+ }
+ current = current[letter];
+ }
+ current.isWord = true;
+ }
+ return root;
+}
+
+function encodeTrieHelper(trie) {
+ const output = [];
+ for (const k in trie) {
+ if (k === "isWord") {
+ output.push(0);
+ continue;
+ }
+ output.push(k);
+ output.push(...encodeTrieHelper(trie[k]));
+ if (typeof output[output.length - 1] === "number") {
+ output[output.length - 1] += 1;
+ } else {
+ output.push(1);
+ }
+ }
+ return output;
+}
+
+function encodeTlds(tlds) {
+ return encodeTrieHelper(createTrie(tlds)).join("");
+}
+
+function decodeTlds(encoded) {
+ const words = [];
+ const stack = [];
+ let i = 0;
+ const digits = "0123456789";
+ while (i < encoded.length) {
+ let popDigitCount = 0;
+ while (digits.indexOf(encoded[i + popDigitCount]) >= 0) {
+ popDigitCount++;
+ }
+ if (popDigitCount > 0) {
+ words.push(stack.join(""));
+ let popCount = parseInt(encoded.substring(i, i + popDigitCount), 10);
+ while (popCount-- > 0) {
+ stack.pop();
+ }
+ i += popDigitCount;
+ } else {
+ stack.push(encoded[i]);
+ i++;
+ }
+ }
+ return words;
+}
+
+async function main() {
+ console.log(`Fetching ${TLDS_URL}...`);
+ const response = await fetch(TLDS_URL);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch IANA TLDs: ${response.status}`);
+ }
+ const body = await response.text();
+
+ const tlds = body
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#") && !/^XN--/i.test(line))
+ .map((line) => line.toLowerCase())
+ .sort();
+
+ console.log(`Encoding ${tlds.length} TLDs...`);
+ const encoded = encodeTlds(tlds);
+
+ console.log("Round-trip asserting...");
+ const decoded = decodeTlds(encoded);
+ if (JSON.stringify(decoded) !== JSON.stringify(tlds)) {
+ throw new Error("Encode/decode round-trip mismatch");
+ }
+
+ const fileContents = `// THIS FILE IS AUTO-GENERATED. DO NOT EDIT DIRECTLY.
+// Source: ${TLDS_URL}
+// Regenerate with: pnpm --filter @blocknote/core update-tlds
+// Encoding format ported from linkifyjs (MIT) — trie collapsed into ASCII.
+
+export const ENCODED_TLDS =
+ "${encoded}";
+`;
+
+ writeFileSync(OUT_PATH, fileContents);
+ console.log(
+ `Wrote ${OUT_PATH} (${encoded.length} chars, ${tlds.length} TLDs)`,
+ );
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/packages/core/src/api/README.md b/packages/core/src/api/README.md
index 2f7465ed67..0633c57156 100644
--- a/packages/core/src/api/README.md
+++ b/packages/core/src/api/README.md
@@ -5,4 +5,4 @@ Implements the BlockNote API surface
- `blockManipulation`: API to insert / update / remove blocks
- `exporters`: exporting to HTML / markdown / other formats
- `nodeConversions`: internal API for converting between BlockNote Schema (Blocks) and Prosemirror (Nodes)
-- `parsers`: importing from HTML / markdown / other formats
\ No newline at end of file
+- `parsers`: importing from HTML / markdown / other formats
diff --git a/packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json b/packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json
new file mode 100644
index 0000000000..7a78484b71
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json
@@ -0,0 +1,26 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "delete",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-deleted-nested.json b/packages/core/src/api/__snapshots__/blocks-deleted-nested.json
new file mode 100644
index 0000000000..2a9bf12ad5
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-deleted-nested.json
@@ -0,0 +1,68 @@
+[
+ {
+ "block": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "delete",
+ },
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "delete",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-deleted.json b/packages/core/src/api/__snapshots__/blocks-deleted.json
new file mode 100644
index 0000000000..8f8bb1f537
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-deleted.json
@@ -0,0 +1,26 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "delete",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-indented-changed.json b/packages/core/src/api/__snapshots__/blocks-indented-changed.json
new file mode 100644
index 0000000000..860b9dfaf1
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-indented-changed.json
@@ -0,0 +1,129 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "A",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-inserted-nested.json b/packages/core/src/api/__snapshots__/blocks-inserted-nested.json
new file mode 100644
index 0000000000..ba74c2faf8
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-inserted-nested.json
@@ -0,0 +1,62 @@
+[
+ {
+ "block": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "insert",
+ },
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "insert",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-inserted.json b/packages/core/src/api/__snapshots__/blocks-inserted.json
new file mode 100644
index 0000000000..02c4d9bd94
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-inserted.json
@@ -0,0 +1,20 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "insert",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-deeper-into-nesting.json b/packages/core/src/api/__snapshots__/blocks-moved-deeper-into-nesting.json
new file mode 100644
index 0000000000..23b74cd24c
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-deeper-into-nesting.json
@@ -0,0 +1,164 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Target",
+ "type": "text",
+ },
+ ],
+ "id": "target",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Level 2",
+ "type": "text",
+ },
+ ],
+ "id": "level-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Target",
+ "type": "text",
+ },
+ ],
+ "id": "target",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Level 1",
+ "type": "text",
+ },
+ ],
+ "id": "level-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Target",
+ "type": "text",
+ },
+ ],
+ "id": "target",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Level 2",
+ "type": "text",
+ },
+ ],
+ "id": "level-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Level 1",
+ "type": "text",
+ },
+ ],
+ "id": "level-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Target",
+ "type": "text",
+ },
+ ],
+ "id": "target",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Root",
+ "type": "text",
+ },
+ ],
+ "id": "root",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-down-twice-in-same-parent.json b/packages/core/src/api/__snapshots__/blocks-moved-down-twice-in-same-parent.json
new file mode 100644
index 0000000000..c5ac2b79ff
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-down-twice-in-same-parent.json
@@ -0,0 +1,44 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "A",
+ "type": "text",
+ },
+ ],
+ "id": "a",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": undefined,
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "A",
+ "type": "text",
+ },
+ ],
+ "id": "a",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-insert-changes-sibling-order.json b/packages/core/src/api/__snapshots__/blocks-moved-insert-changes-sibling-order.json
new file mode 100644
index 0000000000..9b4445d20d
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-insert-changes-sibling-order.json
@@ -0,0 +1,26 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "X",
+ "type": "text",
+ },
+ ],
+ "id": "x",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "insert",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-multiple-in-same-transaction.json b/packages/core/src/api/__snapshots__/blocks-moved-multiple-in-same-transaction.json
new file mode 100644
index 0000000000..9489e47c41
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-multiple-in-same-transaction.json
@@ -0,0 +1,188 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": undefined,
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 2",
+ "type": "text",
+ },
+ ],
+ "id": "child-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent 1",
+ "type": "text",
+ },
+ ],
+ "id": "parent-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 2",
+ "type": "text",
+ },
+ ],
+ "id": "child-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": undefined,
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 2",
+ "type": "text",
+ },
+ ],
+ "id": "child-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 2",
+ "type": "text",
+ },
+ ],
+ "id": "child-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent 1",
+ "type": "text",
+ },
+ ],
+ "id": "parent-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-nested-sibling-reorder.json b/packages/core/src/api/__snapshots__/blocks-moved-nested-sibling-reorder.json
new file mode 100644
index 0000000000..60e2b51881
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-nested-sibling-reorder.json
@@ -0,0 +1,180 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "child-b",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "child-b",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "A",
+ "type": "text",
+ },
+ ],
+ "id": "child-a",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "child-c",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent",
+ "type": "text",
+ },
+ ],
+ "id": "parent",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "child-b",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "A",
+ "type": "text",
+ },
+ ],
+ "id": "child-a",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "child-b",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "child-c",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent",
+ "type": "text",
+ },
+ ],
+ "id": "parent",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-to-different-parent.json b/packages/core/src/api/__snapshots__/blocks-moved-to-different-parent.json
new file mode 100644
index 0000000000..bd702b07f6
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-to-different-parent.json
@@ -0,0 +1,78 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": undefined,
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent 1",
+ "type": "text",
+ },
+ ],
+ "id": "parent-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-to-root-level.json b/packages/core/src/api/__snapshots__/blocks-moved-to-root-level.json
new file mode 100644
index 0000000000..86598e5a28
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-to-root-level.json
@@ -0,0 +1,78 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child",
+ "type": "text",
+ },
+ ],
+ "id": "child",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": undefined,
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child",
+ "type": "text",
+ },
+ ],
+ "id": "child",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child",
+ "type": "text",
+ },
+ ],
+ "id": "child",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent",
+ "type": "text",
+ },
+ ],
+ "id": "parent",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-parent.json b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-parent.json
new file mode 100644
index 0000000000..9e60af8ded
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-parent.json
@@ -0,0 +1,44 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Bottom",
+ "type": "text",
+ },
+ ],
+ "id": "bottom",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": undefined,
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Bottom",
+ "type": "text",
+ },
+ ],
+ "id": "bottom",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-transaction.json b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-transaction.json
new file mode 100644
index 0000000000..766843b6bf
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-transaction.json
@@ -0,0 +1,44 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Middle",
+ "type": "text",
+ },
+ ],
+ "id": "middle",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": undefined,
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Middle",
+ "type": "text",
+ },
+ ],
+ "id": "middle",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-outdented-changed.json b/packages/core/src/api/__snapshots__/blocks-outdented-changed.json
new file mode 100644
index 0000000000..f2916bd730
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-outdented-changed.json
@@ -0,0 +1,129 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "currentParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "A",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevParent": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "C",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "move",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json b/packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json
new file mode 100644
index 0000000000..8e768c6b85
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json
@@ -0,0 +1,42 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "HelloParagraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated-multiple-insert.json b/packages/core/src/api/__snapshots__/blocks-updated-multiple-insert.json
new file mode 100644
index 0000000000..6fd7f9fcf5
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated-multiple-insert.json
@@ -0,0 +1,50 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "ABC",
+ "type": "text",
+ },
+ ],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "insert",
+ },
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "DEF",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": undefined,
+ "source": {
+ "type": "local",
+ },
+ "type": "insert",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated-multiple.json b/packages/core/src/api/__snapshots__/blocks-updated-multiple.json
new file mode 100644
index 0000000000..621a233bea
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated-multiple.json
@@ -0,0 +1,82 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "red",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "blue",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated-nested-deep.json b/packages/core/src/api/__snapshots__/blocks-updated-nested-deep.json
new file mode 100644
index 0000000000..ee0020ed68
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated-nested-deep.json
@@ -0,0 +1,42 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Example Text",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated-nested-multiple.json b/packages/core/src/api/__snapshots__/blocks-updated-nested-multiple.json
new file mode 100644
index 0000000000..9b26b44d8e
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated-nested-multiple.json
@@ -0,0 +1,118 @@
+[
+ {
+ "block": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Example Text",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "red",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Example Text",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated-nested.json b/packages/core/src/api/__snapshots__/blocks-updated-nested.json
new file mode 100644
index 0000000000..ba2627ab0f
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated-nested.json
@@ -0,0 +1,78 @@
+[
+ {
+ "block": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "red",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated-single.json b/packages/core/src/api/__snapshots__/blocks-updated-single.json
new file mode 100644
index 0000000000..9b02bfb755
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated-single.json
@@ -0,0 +1,42 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "blue",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/__snapshots__/blocks-updated.json b/packages/core/src/api/__snapshots__/blocks-updated.json
new file mode 100644
index 0000000000..fb042475aa
--- /dev/null
+++ b/packages/core/src/api/__snapshots__/blocks-updated.json
@@ -0,0 +1,42 @@
+[
+ {
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "red",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "prevBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "source": {
+ "type": "local",
+ },
+ "type": "update",
+ },
+]
\ No newline at end of file
diff --git a/packages/core/src/api/blockManipulation/__snapshots__/blockManipulation.test.ts.snap b/packages/core/src/api/blockManipulation/__snapshots__/blockManipulation.test.ts.snap
deleted file mode 100644
index 9a49952d7a..0000000000
--- a/packages/core/src/api/blockManipulation/__snapshots__/blockManipulation.test.ts.snap
+++ /dev/null
@@ -1,714 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`Insert, Update, & Delete Blocks > Insert, update, & delete multiple blocks 1`] = `
-[
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 1",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 1",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 2",
- "type": "text",
- },
- ],
- "id": "4",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 2",
- "type": "text",
- },
- ],
- "id": "3",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Insert, Update, & Delete Blocks > Insert, update, & delete multiple blocks 2`] = `
-[
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 1",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 1",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 2",
- "type": "text",
- },
- ],
- "id": "4",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 2",
- "type": "text",
- },
- ],
- "id": "3",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Insert, Update, & Delete Blocks > Insert, update, & delete multiple blocks 3`] = `
-[
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Heading 1",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Insert, Update, & Delete Blocks > Insert, update, & delete single block 1`] = `
-[
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Insert, Update, & Delete Blocks > Insert, update, & delete single block 2`] = `
-[
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "content": [
- {
- "styles": {
- "textColor": "red",
- },
- "text": "Heading ",
- "type": "text",
- },
- {
- "styles": {
- "backgroundColor": "red",
- },
- "text": "3",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "level": 3,
- "textAlignment": "right",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Insert, Update, & Delete Blocks > Insert, update, & delete single block 3`] = `
-[
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Inserting Blocks with Different Placements > Insert after existing block 1`] = `
-[
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 1",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 1",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 2",
- "type": "text",
- },
- ],
- "id": "4",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 2",
- "type": "text",
- },
- ],
- "id": "3",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [],
- "content": [],
- "id": "5",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Inserting Blocks with Different Placements > Insert before existing block 1`] = `
-[
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 1",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 1",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 2",
- "type": "text",
- },
- ],
- "id": "4",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 2",
- "type": "text",
- },
- ],
- "id": "3",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Inserting Blocks with Different Placements > Insert nested inside existing block 1`] = `
-[
- {
- "children": [
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 1",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 1",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Nested Heading 2",
- "type": "text",
- },
- ],
- "id": "4",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Heading 2",
- "type": "text",
- },
- ],
- "id": "3",
- "props": {
- "backgroundColor": "default",
- "level": 2,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [],
- "id": "5",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Update Line Breaks > Update custom block with line break 1`] = `
-[
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Line1
-Line2",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Updated Custom Block with
-line
-break",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {},
- "type": "customBlock",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
-
-exports[`Update Line Breaks > Update paragraph with line break 1`] = `
-[
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Updated Custom Block with
-line
-break",
- "type": "text",
- },
- ],
- "id": "1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Line1
-Line2",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {},
- "type": "customBlock",
- },
- {
- "children": [],
- "content": [],
- "id": "0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-]
-`;
diff --git a/packages/core/src/api/blockManipulation/__snapshots__/transactions.test.ts.snap b/packages/core/src/api/blockManipulation/__snapshots__/transactions.test.ts.snap
new file mode 100644
index 0000000000..e30e9cb90c
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/__snapshots__/transactions.test.ts.snap
@@ -0,0 +1,34 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test blocknote transactions > should return the correct block info 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Hey-yo",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
diff --git a/packages/core/src/api/blockManipulation/blockManipulation.test.ts b/packages/core/src/api/blockManipulation/blockManipulation.test.ts
deleted file mode 100644
index da99c48fae..0000000000
--- a/packages/core/src/api/blockManipulation/blockManipulation.test.ts
+++ /dev/null
@@ -1,292 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import {
- Block,
- defaultBlockSpecs,
- DefaultInlineContentSchema,
- DefaultStyleSchema,
- PartialBlock,
-} from "../../blocks/defaultBlocks";
-import { BlockNoteEditor } from "../../editor/BlockNoteEditor";
-import { createBlockSpec } from "../../schema";
-import { BlockNoteSchema } from "../../editor/BlockNoteSchema";
-
-const CustomBlock = createBlockSpec(
- {
- type: "customBlock",
- propSchema: {},
- content: "inline",
- } as const,
- {
- render: () => {
- const dom = document.createElement("div");
- dom.className = "custom-block";
-
- return {
- dom: dom,
- contentDOM: dom,
- };
- },
- }
-);
-
-const schema = BlockNoteSchema.create({
- blockSpecs: {
- ...defaultBlockSpecs,
- customBlock: CustomBlock,
- },
-});
-
-let editor: BlockNoteEditor
+ *
+ *
+ * We have a problem though, from the block json, there is no way to tell that the cell "2-1" is the second cell in the second row.
+ * To resolve this, we created the occupancy grid, which is a grid of all the cells in the table, as though they were only 1x1 cells.
+ * See {@link OccupancyGrid} for more information.
+ *
+ */
+
+/**
+ * Relative cell indices are relative to the table block's content.
+ *
+ * This is a sparse representation of the table and is how HTML and BlockNote JSON represent tables.
+ *
+ * For example, if we have a table with a rowspan of 2, the second row may only have 1 element in a 2x2 table.
+ *
+ * ```
+ * // Visual representation of the table
+ * | 1-1 | 1-2 | // has 2 cells
+ * | 1-1 | 2-2 | // has only 1 cell
+ * // Relative cell indices
+ * [{ row: 1, col: 1, rowspan: 2 }, { row: 1, col: 2 }] // has 2 cells
+ * [{ row: 1, col: 2 }] // has only 1 cell
+ * ```
+ */
+export type RelativeCellIndices = {
+ row: number;
+ col: number;
+};
+
+/**
+ * Absolute cell indices are relative to the table's layout (it's {@link OccupancyGrid}).
+ *
+ * It is as though the table is a grid of 1x1 cells, and any colspan or rowspan results in multiple 1x1 cells being occupied.
+ *
+ * For example, if we have a table with a colspan of 2, it will occupy 2 cells in the layout grid.
+ *
+ * ```
+ * // Visual representation of the table
+ * | 1-1 | 1-1 | // has 2 cells
+ * | 2-1 | 2-2 | // has 2 cell
+ * // Absolute cell indices
+ * [{ row: 1, col: 1, colspan: 2 }, { row: 1, col: 2, colspan: 2 }] // has 2 cells
+ * [{ row: 1, col: 1 }, { row: 1, col: 2 }] // has 2 cells
+ * ```
+ */
+export type AbsoluteCellIndices = {
+ row: number;
+ col: number;
+};
+
+/**
+ * An occupancy grid is a grid of the occupied cells in the table.
+ * It is used to track the occupied cells in the table to know where to place the next cell.
+ *
+ * Since it allows us to resolve cell indices both {@link RelativeCellIndices} and {@link AbsoluteCellIndices}, it is the core data structure for table operations.
+ */
+type OccupancyGrid = (RelativeCellIndices & {
+ /**
+ * The rowspan of the cell.
+ */
+ rowspan: number;
+ /**
+ * The colspan of the cell.
+ */
+ colspan: number;
+ /**
+ * The cell.
+ */
+ cell: TableCell
+ *
+ * 1-1
+ * 1-2
+ *
+ *
+ * 2-1
+ * 2-2
+ *
+ *
+ * 3-1
+ * Heading 1
Heading 2
\ No newline at end of file
diff --git a/packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html b/packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html
deleted file mode 100644
index ba81a3a20a..0000000000
--- a/packages/core/src/api/clipboard/__snapshots__/childrenToNextParentsChildren.html
+++ /dev/null
@@ -1 +0,0 @@
-Heading 2
BoldItalicRegular
ding 1
\ No newline at end of file
diff --git a/packages/core/src/api/clipboard/__snapshots__/tableCellText.html b/packages/core/src/api/clipboard/__snapshots__/tableCellText.html
deleted file mode 100644
index cd55158ac5..0000000000
--- a/packages/core/src/api/clipboard/__snapshots__/tableCellText.html
+++ /dev/null
@@ -1 +0,0 @@
-Table Cell
\ No newline at end of file
diff --git a/packages/core/src/api/clipboard/__snapshots__/tableRow.html b/packages/core/src/api/clipboard/__snapshots__/tableRow.html
deleted file mode 100644
index a7d0f18df5..0000000000
--- a/packages/core/src/api/clipboard/__snapshots__/tableRow.html
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/core/src/api/clipboard/__snapshots__/unstyledText.html b/packages/core/src/api/clipboard/__snapshots__/unstyledText.html
deleted file mode 100644
index ea9503c08c..0000000000
--- a/packages/core/src/api/clipboard/__snapshots__/unstyledText.html
+++ /dev/null
@@ -1 +0,0 @@
-Regular
\ No newline at end of file
diff --git a/packages/core/src/api/clipboard/clipboard.test.ts b/packages/core/src/api/clipboard/clipboard.test.ts
deleted file mode 100644
index 58cf9e8927..0000000000
--- a/packages/core/src/api/clipboard/clipboard.test.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-import { Node } from "prosemirror-model";
-import { NodeSelection, Selection, TextSelection } from "prosemirror-state";
-import { CellSelection } from "prosemirror-tables";
-import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
-
-import { PartialBlock } from "../../blocks/defaultBlocks";
-import { BlockNoteEditor } from "../../editor/BlockNoteEditor";
-import { doPaste } from "../testUtil/paste";
-import { initializeESMDependencies } from "../../util/esmDependencies";
-import { selectedFragmentToHTML } from "./toClipboard/copyExtension";
-
-type SelectionTestCase = {
- testName: string;
- createSelection: (doc: Node) => Selection;
-};
-
-// These tests are meant to test the copying of user selections in the editor.
-// The test cases used for the other HTML conversion tests are not suitable here
-// as they are represented in the BlockNote API, whereas here we want to test
-// ProseMirror/TipTap selections directly.
-describe("Test ProseMirror selection clipboard HTML", () => {
- const initialContent: PartialBlock[] = [
- {
- type: "heading",
- props: {
- level: 2,
- textColor: "red",
- },
- content: "Heading 1",
- children: [
- {
- type: "paragraph",
- content: "Nested Paragraph 1",
- },
- {
- type: "paragraph",
- content: "Nested Paragraph 2",
- },
- {
- type: "paragraph",
- content: "Nested Paragraph 3",
- },
- ],
- },
- {
- type: "heading",
- props: {
- level: 2,
- textColor: "red",
- },
- content: "Heading 2",
- children: [
- {
- type: "paragraph",
- content: "Nested Paragraph 1",
- },
- {
- type: "paragraph",
- content: "Nested Paragraph 2",
- },
- {
- type: "paragraph",
- content: "Nested Paragraph 3",
- },
- ],
- },
- {
- type: "heading",
- props: {
- level: 2,
- textColor: "red",
- },
- content: [
- {
- type: "text",
- text: "Bold",
- styles: {
- bold: true,
- },
- },
- {
- type: "text",
- text: "Italic",
- styles: {
- italic: true,
- },
- },
- {
- type: "text",
- text: "Regular",
- styles: {},
- },
- ],
- children: [
- {
- type: "image",
- props: {
- url: "https://ralfvanveen.com/wp-content/uploads/2021/06/Placeholder-_-Glossary.svg",
- },
- children: [
- {
- type: "paragraph",
- content: "Nested Paragraph",
- },
- ],
- },
- ],
- },
- {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- {
- cells: ["Table Cell", "Table Cell"],
- },
- {
- cells: ["Table Cell", "Table Cell"],
- },
- ],
- },
- // Not needed as selections starting in table cells will get snapped to
- // the table boundaries.
- // children: [
- // {
- // type: "table",
- // content: {
- // type: "tableContent",
- // rows: [
- // {
- // cells: ["Table Cell", "Table Cell"],
- // },
- // {
- // cells: ["Table Cell", "Table Cell"],
- // },
- // ],
- // },
- // },
- // ],
- },
- ];
-
- let editor: BlockNoteEditor;
- const div = document.createElement("div");
-
- beforeEach(() => {
- editor.replaceBlocks(editor.document, initialContent);
- });
-
- beforeAll(async () => {
- (window as any).__TEST_OPTIONS = (window as any).__TEST_OPTIONS || {};
-
- editor = BlockNoteEditor.create();
- editor.mount(div);
-
- await initializeESMDependencies();
- });
-
- afterAll(() => {
- editor.mount(undefined);
- editor._tiptapEditor.destroy();
- editor = undefined as any;
-
- delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
- });
-
- // Sets the editor selection to the given start and end positions, then
- // exports the selected content to HTML and compares it to a snapshot.
- async function testSelection(testCase: SelectionTestCase) {
- editor.dispatch(
- editor._tiptapEditor.state.tr.setSelection(
- testCase.createSelection(editor._tiptapEditor.view.state.doc)
- )
- );
-
- const { clipboardHTML, externalHTML } = await selectedFragmentToHTML(
- editor._tiptapEditor.view,
- editor
- );
-
- expect(externalHTML).toMatchFileSnapshot(
- `./__snapshots__/${testCase.testName}.html`
- );
-
- const originalDocument = editor.document;
- doPaste(
- editor._tiptapEditor.view,
- "text",
- clipboardHTML,
- false,
- new ClipboardEvent("paste")
- );
- const newDocument = editor.document;
-
- expect(newDocument).toStrictEqual(originalDocument);
- }
-
- const testCases: SelectionTestCase[] = [
- // TODO: Consider adding test cases for nested blocks & double nested blocks.
- // Selection spans all of first heading's children.
- {
- testName: "multipleChildren",
- createSelection: (doc) => TextSelection.create(doc, 16, 78),
- },
- // Selection spans from start of first heading to end of its first child.
- {
- testName: "childToParent",
- createSelection: (doc) => TextSelection.create(doc, 3, 34),
- },
- // Selection spans from middle of first heading to the middle of its first
- // child.
- {
- testName: "partialChildToParent",
- createSelection: (doc) => TextSelection.create(doc, 6, 23),
- },
- // Selection spans from start of first heading's first child to end of
- // second heading's content (does not include second heading's children).
- {
- testName: "childrenToNextParent",
- createSelection: (doc) => TextSelection.create(doc, 16, 93),
- },
- // Selection spans from start of first heading's first child to end of
- // second heading's last child.
- {
- testName: "childrenToNextParentsChildren",
- createSelection: (doc) => TextSelection.create(doc, 16, 159),
- },
- // Selection spans "Regular" text inside third heading.
- {
- testName: "unstyledText",
- createSelection: (doc) => TextSelection.create(doc, 175, 182),
- },
- // Selection spans "Italic" text inside third heading.
- {
- testName: "styledText",
- createSelection: (doc) => TextSelection.create(doc, 169, 175),
- },
- // Selection spans third heading's content (does not include third heading's
- // children).
- {
- testName: "multipleStyledText",
- createSelection: (doc) => TextSelection.create(doc, 165, 182),
- },
- // Selection spans the image block content.
- {
- testName: "image",
- createSelection: (doc) => NodeSelection.create(doc, 185),
- },
- // Selection spans from start of third heading to end of it's last
- // descendant.
- {
- testName: "nestedImage",
- createSelection: (doc) => TextSelection.create(doc, 165, 205),
- },
- // Selection spans text in first cell of the table.
- {
- testName: "tableCellText",
- createSelection: (doc) => TextSelection.create(doc, 216, 226),
- },
- // Selection spans first cell of the table.
- // TODO: External HTML is wrapped in unnecessary `tr` element.
- {
- testName: "tableCell",
- createSelection: (doc) => CellSelection.create(doc, 214),
- },
- // Selection spans first row of the table.
- {
- testName: "tableRow",
- createSelection: (doc) => CellSelection.create(doc, 214, 228),
- },
- // Selection spans all cells of the table.
- // TODO: External HTML is wrapped in unnecessary `blockContent` element.
- {
- testName: "tableAllCells",
- createSelection: (doc) => CellSelection.create(doc, 214, 258),
- },
- ];
-
- for (const testCase of testCases) {
- it(`${testCase.testName}`, async () => {
- await testSelection(testCase);
- });
- }
-});
diff --git a/packages/core/src/api/clipboard/fromClipboard/acceptedMIMETypes.ts b/packages/core/src/api/clipboard/fromClipboard/acceptedMIMETypes.ts
index 9876281a76..c79400c78a 100644
--- a/packages/core/src/api/clipboard/fromClipboard/acceptedMIMETypes.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/acceptedMIMETypes.ts
@@ -1,6 +1,8 @@
export const acceptedMIMETypes = [
+ "vscode-editor-data",
"blocknote/html",
- "Files",
+ "text/markdown",
"text/html",
"text/plain",
+ "Files",
] as const;
diff --git a/packages/core/src/api/clipboard/fromClipboard/fileDropExtension.ts b/packages/core/src/api/clipboard/fromClipboard/fileDropExtension.ts
index 657e775279..f602ef4a2d 100644
--- a/packages/core/src/api/clipboard/fromClipboard/fileDropExtension.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/fileDropExtension.ts
@@ -1,17 +1,21 @@
import { Extension } from "@tiptap/core";
import { Plugin } from "prosemirror-state";
-import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
-import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
-import { handleFileInsertion } from "./handleFileInsertion";
-import { acceptedMIMETypes } from "./acceptedMIMETypes";
+import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import {
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../../schema/index.js";
+import { acceptedMIMETypes } from "./acceptedMIMETypes.js";
+import { handleFileInsertion } from "./handleFileInsertion.js";
export const createDropFileExtension = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
- S extends StyleSchema
+ S extends StyleSchema,
>(
- editor: BlockNoteEditor
`,
+ );
+
+ return true;
+}
diff --git a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
index 8b9161b890..9fa4ed3c55 100644
--- a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
@@ -1,20 +1,125 @@
import { Extension } from "@tiptap/core";
import { Plugin } from "prosemirror-state";
-import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
-import { BlockSchema, InlineContentSchema, StyleSchema } from "../../../schema";
-import { nestedListsToBlockNoteStructure } from "../../parsers/html/util/nestedLists";
-import { acceptedMIMETypes } from "./acceptedMIMETypes";
-import { handleFileInsertion } from "./handleFileInsertion";
+import type {
+ BlockNoteEditor,
+ BlockNoteEditorOptions,
+} from "../../../editor/BlockNoteEditor";
+import { isMarkdown } from "../../parsers/markdown/detectMarkdown.js";
+import {
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../../schema/index.js";
+import { acceptedMIMETypes } from "./acceptedMIMETypes.js";
+import { handleFileInsertion } from "./handleFileInsertion.js";
+import { handleVSCodePaste } from "./handleVSCodePaste.js";
+
+function defaultPasteHandler({
+ event,
+ editor,
+ prioritizeMarkdownOverHTML,
+ plainTextAsMarkdown,
+}: {
+ event: ClipboardEvent;
+ editor: BlockNoteEditor${text.replace(
+ /\r\n?/g,
+ "\n",
+ )}${externalHTMLExporter.exportInlineContent(
+ ic as any,
+ {},
+ )}
`;
+ } else if (isWithinBlockContent) {
+ // first convert selection to blocknote-style inline content, and then
+ // pass this to the exporter
+ const ic = contentNodeToInlineContent(
+ selectedFragment as any,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema,
+ );
+ externalHTML = externalHTMLExporter.exportInlineContent(ic, {});
+ } else {
+ const blocks = fragmentToBlocksHeading
2
\ 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
deleted file mode 100644
index efec8f89d3..0000000000
--- a/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html
+++ /dev/null
@@ -1 +0,0 @@
-Heading
2
with
line breaks
with
line breaks
Line 2
Text2
Text2
Text2
Text3
Text2
Text3
Text1
Text1
Text2
Text2
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/noCaption/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/noCaption/internal.html
deleted file mode 100644
index 0874d2e700..0000000000
--- a/packages/core/src/api/exporters/html/__snapshots__/image/noCaption/internal.html
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/lists/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/lists/basic/internal.html
deleted file mode 100644
index bd48311268..0000000000
--- a/packages/core/src/api/exporters/html/__snapshots__/lists/basic/internal.html
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/core/src/api/exporters/html/__snapshots__/lists/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/lists/nested/internal.html
deleted file mode 100644
index c4026355d2..0000000000
--- a/packages/core/src/api/exporters/html/__snapshots__/lists/nested/internal.html
+++ /dev/null
@@ -1 +0,0 @@
-
Line 2
Line 2
elements as newlines
+ const code = extractCodeContent(codeEl);
+
+ // Use a fence longer than the longest backtick run in the code
+ const longestRun = Math.max(
+ 0,
+ ...((code.match(/`+/g) ?? []).map((run) => run.length))
+ );
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
+
+ // For empty code blocks, don't add a newline between the fences
+ if (!code) {
+ return ctx.indent + fence + language + "\n" + fence + "\n\n";
+ }
+
+ return (
+ ctx.indent +
+ fence +
+ language +
+ "\n" +
+ code +
+ (code.endsWith("\n") ? "" : "\n") +
+ fence +
+ "\n\n"
+ );
+}
+
+function extractCodeContent(el: Element): string {
+ let result = "";
+ for (const child of Array.from(el.childNodes)) {
+ if (child.nodeType === 3 /* Node.TEXT_NODE */) {
+ result += child.textContent || "";
+ } else if (child.nodeType === 1 /* Node.ELEMENT_NODE */) {
+ const tag = (child as HTMLElement).tagName.toLowerCase();
+ if (tag === "br") {
+ result += "\n";
+ } else {
+ result += extractCodeContent(child as Element);
+ }
+ }
+ }
+ return result;
+}
+
+function extractLanguageFromClass(className: string): string {
+ const match = className.match(/language-(\S+)/);
+ return match ? match[1] : "";
+}
+
+function serializeUnorderedList(
+ el: HTMLElement,
+ ctx: SerializeContext
+): string {
+ let result = "";
+ const items = Array.from(el.children).filter(
+ (child) => child.tagName.toLowerCase() === "li"
+ );
+
+ for (const item of items) {
+ result += serializeListItem(item as HTMLElement, "bullet", ctx);
+ }
+
+ // Trailing blank line separates the list from the next block. Skip when
+ // this list is nested inside another list item — adding it would convert
+ // the parent list into a "loose" list (or break tightness).
+ if (!ctx.inListItem) {
+ result += "\n";
+ }
+ return result;
+}
+
+function serializeOrderedList(el: HTMLElement, ctx: SerializeContext): string {
+ let result = "";
+ const items = Array.from(el.children).filter(
+ (child) => child.tagName.toLowerCase() === "li"
+ );
+ const startNum = parseInt(el.getAttribute("start") || "1", 10);
+
+ for (let i = 0; i < items.length; i++) {
+ const num = startNum + i;
+ result += serializeListItem(items[i] as HTMLElement, "ordered", ctx, num);
+ }
+
+ if (!ctx.inListItem) {
+ result += "\n";
+ }
+ return result;
+}
+
+function serializeListItem(
+ el: HTMLElement,
+ listType: "bullet" | "ordered",
+ ctx: SerializeContext,
+ num?: number
+): string {
+ // Check for checkbox (task list) - direct children only
+ let checkbox: HTMLInputElement | null = null;
+ let details: HTMLElement | null = null;
+
+ for (const child of Array.from(el.children)) {
+ const tag = child.tagName.toLowerCase();
+ if (tag === "input" && (child as HTMLInputElement).type === "checkbox") {
+ checkbox = child as HTMLInputElement;
+ }
+ if (tag === "details") {
+ details = child as HTMLElement;
+ }
+ }
+
+ let marker: string;
+ let markerWidth: number;
+
+ if (checkbox) {
+ const state = checkbox.checked ? "[x]" : "[ ]";
+ marker = `* ${state} `;
+ // For child indentation, use bullet width (2), not full checkbox marker width
+ markerWidth = 2;
+ } else if (listType === "ordered") {
+ marker = `${num}. `;
+ markerWidth = marker.length;
+ } else {
+ marker = "* ";
+ markerWidth = 2;
+ }
+
+ // Collect the item's inline content
+ let inlineContent: string;
+ let firstContentEl: Element | null;
+
+ if (details) {
+ // Toggle item: get content from summary
+ const summary = details.querySelector("summary");
+ const summaryP = summary?.querySelector("p");
+ firstContentEl = details;
+ inlineContent = summaryP ? serializeInlineContent(summaryP) : "";
+ } else {
+ firstContentEl = getFirstContentElement(el, checkbox);
+ inlineContent = firstContentEl ? serializeInlineContent(firstContentEl) : "";
+ }
+
+ // The marker line ends with a single `\n` so that consecutive list items
+ // produce a "tight" list (no blank line between markers). Continuation
+ // content within the item (nested lists, continuation paragraphs, other
+ // blocks) injects its own spacing as needed.
+ let result = ctx.indent + marker + inlineContent + "\n";
+
+ // Serialize child content (nested lists, continuation paragraphs, etc.)
+ const childIndent = ctx.indent + " ".repeat(markerWidth);
+ const childCtx: SerializeContext = { indent: childIndent, inListItem: true };
+
+ // For toggle items, also serialize children inside the details element
+ if (details) {
+ const summary = details.querySelector("summary");
+ for (const child of Array.from(details.children)) {
+ if (child === summary) {continue;}
+ const childTag = child.tagName.toLowerCase();
+ if (childTag === "p") {
+ const content = serializeInlineContent(child as HTMLElement);
+ // Continuation paragraph needs a blank line to separate it from the
+ // previous content; CommonMark would otherwise treat it as a soft
+ // wrap of that content.
+ result += "\n" + childIndent + content + "\n";
+ } else {
+ result += serializeNode(child, childCtx);
+ }
+ }
+ }
+
+ const children = Array.from(el.children);
+ for (const child of children) {
+ const childTag = child.tagName.toLowerCase();
+
+ // Skip the first content element and checkbox
+ if (child === firstContentEl || (child as HTMLElement) === checkbox) {continue;}
+ if (childTag === "input") {continue;}
+
+ // Nested lists and other block content
+ if (childTag === "ul" || childTag === "ol") {
+ // Nested list flows directly under the parent marker — no blank line.
+ result += serializeNode(child, childCtx);
+ } else if (childTag === "p") {
+ // Continuation paragraph within list item — requires blank line before
+ // so it isn't read as part of the marker line's text.
+ const content = serializeInlineContent(child as HTMLElement);
+ result += "\n" + childIndent + content + "\n";
+ } else {
+ // Other block-level children (code blocks, blockquotes, etc.) already
+ // emit their own separating newlines; prefix with a blank line so they
+ // are recognized as separate blocks.
+ result += "\n" + serializeNode(child, childCtx);
+ }
+ }
+
+ return result;
+}
+
+function getFirstContentElement(
+ li: HTMLElement,
+ checkbox: HTMLInputElement | null
+): HTMLElement | null {
+ for (const child of Array.from(li.children)) {
+ if (child === checkbox) {continue;}
+ if (child.tagName.toLowerCase() === "input") {continue;}
+ const tag = child.tagName.toLowerCase();
+ if (tag === "p" || tag === "span") {return child as HTMLElement;}
+ }
+ return null;
+}
+
+// ─── Table Serializer ────────────────────────────────────────────────────────
+
+function serializeTable(el: HTMLElement, ctx: SerializeContext): string {
+ // First, determine column count from colgroup or first row
+ const colgroup = el.querySelector("colgroup");
+ let colCount = 0;
+
+ if (colgroup) {
+ colCount = colgroup.querySelectorAll("col").length;
+ }
+
+ const rows: string[][] = [];
+ let hasHeader = false;
+
+ // Collect all rows, handling colspan/rowspan
+ const trElements = el.querySelectorAll("tr");
+ // Build a grid to handle colspan/rowspan
+ const grid: (string | null)[][] = [];
+
+ trElements.forEach((tr, rowIdx) => {
+ if (!grid[rowIdx]) {grid[rowIdx] = [];}
+ const cellElements = tr.querySelectorAll("th, td");
+ let gridCol = 0;
+
+ cellElements.forEach((cell) => {
+ // Find next empty column in this row
+ while (grid[rowIdx][gridCol] !== undefined) {gridCol++;}
+
+ if (rowIdx === 0 && cell.tagName.toLowerCase() === "th") {
+ hasHeader = true;
+ }
+
+ const content = escapeTableCell(
+ serializeInlineContent(cell as HTMLElement).trim()
+ );
+ const colspan = parseInt(cell.getAttribute("colspan") || "1", 10);
+ const rowspan = parseInt(cell.getAttribute("rowspan") || "1", 10);
+
+ // Fill the grid
+ for (let r = 0; r < rowspan; r++) {
+ for (let c = 0; c < colspan; c++) {
+ const ri = rowIdx + r;
+ if (!grid[ri]) {grid[ri] = [];}
+ grid[ri][gridCol + c] = r === 0 && c === 0 ? content : "";
+ }
+ }
+
+ gridCol += colspan;
+ });
+
+ // Update colCount
+ if (grid[rowIdx]) {
+ colCount = Math.max(colCount, grid[rowIdx].length);
+ }
+ });
+
+ // Convert grid to rows
+ for (const gridRow of grid) {
+ const row: string[] = [];
+ for (let c = 0; c < colCount; c++) {
+ row.push(gridRow && gridRow[c] !== undefined ? (gridRow[c] ?? "") : "");
+ }
+ rows.push(row);
+ }
+
+ if (rows.length === 0) {return "";}
+
+ // Determine column widths
+ const colWidths: number[] = [];
+ for (let c = 0; c < colCount; c++) {
+ let maxWidth = 3; // minimum width for separator "---"
+ for (const row of rows) {
+ const cellWidth = c < row.length ? row[c].length : 0;
+ maxWidth = Math.max(maxWidth, cellWidth);
+ }
+ // Use minimum of 10 to match remark output
+ colWidths.push(Math.max(maxWidth, 10));
+ }
+
+ let result = "";
+
+ if (hasHeader) {
+ result += ctx.indent + formatTableRow(rows[0], colWidths, colCount) + "\n";
+ result += ctx.indent + formatSeparatorRow(colWidths, colCount) + "\n";
+ for (let r = 1; r < rows.length; r++) {
+ result +=
+ ctx.indent + formatTableRow(rows[r], colWidths, colCount) + "\n";
+ }
+ } else {
+ // No header — emit empty header + separator
+ const emptyRow = new Array(colCount).fill("");
+ result += ctx.indent + formatTableRow(emptyRow, colWidths, colCount) + "\n";
+ result += ctx.indent + formatSeparatorRow(colWidths, colCount) + "\n";
+ for (const row of rows) {
+ result +=
+ ctx.indent + formatTableRow(row, colWidths, colCount) + "\n";
+ }
+ }
+
+ result += "\n";
+ return result;
+}
+
+function escapeTableCell(text: string): string {
+ return text.replace(/\|/g, "\\|");
+}
+
+function formatTableRow(
+ cells: string[],
+ colWidths: number[],
+ colCount: number
+): string {
+ const parts: string[] = [];
+ for (let c = 0; c < colCount; c++) {
+ const cell = c < cells.length ? cells[c] : "";
+ parts.push(" " + cell.padEnd(colWidths[c]) + " ");
+ }
+ return "|" + parts.join("|") + "|";
+}
+
+function formatSeparatorRow(colWidths: number[], colCount: number): string {
+ const parts: string[] = [];
+ for (let c = 0; c < colCount; c++) {
+ parts.push(" " + "-".repeat(colWidths[c]) + " ");
+ }
+ return "|" + parts.join("|") + "|";
+}
+
+// ─── Media Serializers ───────────────────────────────────────────────────────
+
+function serializeImage(el: HTMLElement, ctx: SerializeContext): string {
+ const src = el.getAttribute("src") || "";
+ const alt = el.getAttribute("alt") || "";
+ // Empty placeholder — preserve the block-level break, matching how
+ // serializeParagraph/serializeHeading emit `\n\n` for empty content.
+ if (!src) {return "\n\n";}
+ return ctx.indent + `\n\n`;
+}
+
+function serializeVideo(el: HTMLElement, ctx: SerializeContext): string {
+ const src =
+ el.getAttribute("src") || el.getAttribute("data-url") || "";
+ const name = el.getAttribute("data-name") || el.getAttribute("title") || "";
+ if (!src) {return "\n\n";}
+ return ctx.indent + `\n\n`;
+}
+
+function serializeAudio(el: HTMLElement, ctx: SerializeContext): string {
+ const src = el.getAttribute("src") || "";
+ if (!src) {return "\n\n";}
+ // Audio has no markdown syntax, so emit raw HTML. The markdown parser
+ // passes