;
-const createHighlighter = /* @__PURE__ */ createdBundledHighlighter<
+const createHighlighter = /* @__PURE__ */ createBundledHighlighter<
BundledLanguage,
BundledTheme
>({
diff --git a/packages/code-block/vite.config.ts b/packages/code-block/vite.config.ts
index 954be159ec..cb9f20516e 100644
--- a/packages/code-block/vite.config.ts
+++ b/packages/code-block/vite.config.ts
@@ -4,12 +4,6 @@ import { defineConfig } from "vite";
import pkg from "./package.json";
// import eslintPlugin from "vite-plugin-eslint";
-const deps = Object.keys({
- ...pkg.dependencies,
- ...pkg.peerDependencies,
- ...pkg.devDependencies,
-});
-
// https://vitejs.dev/config/
export default defineConfig((conf) => ({
test: {
@@ -41,22 +35,30 @@ export default defineConfig((conf) => ({
rollupOptions: {
// make sure to externalize deps that shouldn't be bundled
// into your library
- external: (source: string) => {
- if (deps.includes(source)) {
+ external: (source) => {
+ if (
+ Object.keys({
+ ...pkg.dependencies,
+ ...((pkg as any).peerDependencies || {}),
+ ...pkg.devDependencies,
+ }).some((dep) => source === dep || source.startsWith(dep + "/"))
+ ) {
return true;
}
-
- if (source.startsWith("@shikijs/")) {
- return true;
- }
-
- return false;
+ return (
+ source.startsWith("react/") ||
+ source.startsWith("react-dom/") ||
+ source.startsWith("prosemirror-") ||
+ source.startsWith("@tiptap/") ||
+ source.startsWith("@blocknote/") ||
+ source.startsWith("@shikijs/") ||
+ source.startsWith("node:")
+ );
},
output: {
// Provide global variables to use in the UMD build
// for externalized deps
globals: {},
- interop: "compat", // https://rollupjs.org/migration/#changed-defaults
},
},
},
diff --git a/packages/core/LICENSE b/packages/core/LICENSE
new file mode 100644
index 0000000000..fa0086a952
--- /dev/null
+++ b/packages/core/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
\ No newline at end of file
diff --git a/packages/core/README.md b/packages/core/README.md
index 6ad9973798..f391efb3f3 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -9,16 +9,12 @@ 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
@@ -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 ❤️
diff --git a/packages/core/package.json b/packages/core/package.json
index 0632fe0724..ab42afc47a 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -11,7 +11,7 @@
"directory": "packages/core"
},
"license": "MPL-2.0",
- "version": "0.30.0",
+ "version": "0.51.2",
"files": [
"dist",
"types",
@@ -57,10 +57,25 @@
"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": {
@@ -71,74 +86,51 @@
"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",
- "@shikijs/types": "3.2.1",
- "@tiptap/core": "^2.11.5",
- "@tiptap/extension-bold": "^2.11.5",
- "@tiptap/extension-code": "^2.11.5",
- "@tiptap/extension-gapcursor": "^2.11.5",
- "@tiptap/extension-history": "^2.11.5",
- "@tiptap/extension-horizontal-rule": "^2.11.5",
- "@tiptap/extension-italic": "^2.11.5",
- "@tiptap/extension-link": "^2.11.5",
- "@tiptap/extension-paragraph": "^2.11.5",
- "@tiptap/extension-strike": "^2.11.5",
- "@tiptap/extension-table-cell": "^2.11.5",
- "@tiptap/extension-table-header": "^2.11.5",
- "@tiptap/extension-table-row": "^2.11.5",
- "@tiptap/extension-text": "^2.11.5",
- "@tiptap/extension-underline": "^2.11.5",
- "@tiptap/pm": "^2.11.5",
+ "@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": "^5.0.1",
- "prosemirror-dropcursor": "^1.8.1",
- "prosemirror-highlight": "^0.13.0",
- "prosemirror-model": "^1.25.1",
- "prosemirror-state": "^1.4.3",
- "prosemirror-tables": "^1.6.4",
- "prosemirror-transform": "^1.10.2",
- "prosemirror-view": "^1.38.1",
- "rehype-format": "^5.0.1",
- "rehype-parse": "^9.0.1",
- "rehype-remark": "^10.0.0",
- "rehype-stringify": "^10.0.1",
- "remark-gfm": "^4.0.1",
- "remark-parse": "^11.0.0",
- "remark-rehype": "^11.1.1",
- "remark-stringify": "^11.0.0",
- "unified": "^11.0.5",
- "uuid": "^8.3.2",
- "y-prosemirror": "^1.3.4",
+ "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": "^3.0.0",
- "@types/uuid": "^8.3.4",
- "eslint": "^8.10.0",
- "jsdom": "^25.0.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"
- },
- "peerDependencies": {
- "@hocuspocus/provider": "^2.15.2"
- },
- "peerDependenciesMeta": {
- "@hocuspocus/provider": {
- "optional": true
- }
+ "vitest": "^4.1.2"
},
"eslintConfig": {
"extends": [
- "../../.eslintrc.js"
+ "../../.eslintrc.json"
]
},
"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/__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-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/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap
index 3a53ef0fc2..e854849d11 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap
@@ -307,6 +307,7 @@ exports[`Test insertBlocks > Insert multiple blocks after 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -653,6 +654,7 @@ exports[`Test insertBlocks > Insert multiple blocks after 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -662,7 +664,7 @@ exports[`Test insertBlocks > Insert multiple blocks after 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -980,6 +982,7 @@ exports[`Test insertBlocks > Insert multiple blocks before 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1326,6 +1329,7 @@ exports[`Test insertBlocks > Insert multiple blocks before 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1335,7 +1339,7 @@ exports[`Test insertBlocks > Insert multiple blocks before 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1573,6 +1577,7 @@ exports[`Test insertBlocks > Insert single basic block after 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1919,6 +1924,7 @@ exports[`Test insertBlocks > Insert single basic block after 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1928,7 +1934,7 @@ exports[`Test insertBlocks > Insert single basic block after 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2178,6 +2184,7 @@ exports[`Test insertBlocks > Insert single basic block before (without type) 2`]
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2524,6 +2531,7 @@ exports[`Test insertBlocks > Insert single basic block before (without type) 2`]
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2533,7 +2541,7 @@ exports[`Test insertBlocks > Insert single basic block before (without type) 2`]
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2771,6 +2779,7 @@ exports[`Test insertBlocks > Insert single basic block before 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3117,6 +3126,7 @@ exports[`Test insertBlocks > Insert single basic block before 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3126,7 +3136,7 @@ exports[`Test insertBlocks > Insert single basic block before 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3201,6 +3211,7 @@ exports[`Test insertBlocks > Insert single complex block after 1`] = `
"id": "inserted-heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3291,6 +3302,7 @@ exports[`Test insertBlocks > Insert single complex block after 2`] = `
"id": "inserted-heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3478,6 +3490,7 @@ exports[`Test insertBlocks > Insert single complex block after 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3824,6 +3837,7 @@ exports[`Test insertBlocks > Insert single complex block after 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3833,7 +3847,7 @@ exports[`Test insertBlocks > Insert single complex block after 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3908,6 +3922,7 @@ exports[`Test insertBlocks > Insert single complex block before 1`] = `
"id": "inserted-heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3981,6 +3996,7 @@ exports[`Test insertBlocks > Insert single complex block before 2`] = `
"id": "inserted-heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -4185,6 +4201,7 @@ exports[`Test insertBlocks > Insert single complex block before 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -4531,6 +4548,7 @@ exports[`Test insertBlocks > Insert single complex block before 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -4540,7 +4558,7 @@ exports[`Test insertBlocks > Insert single complex block before 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
index 0daf3a4517..25debee60c 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
@@ -1,5 +1,6 @@
import { Fragment, Slice } from "prosemirror-model";
-
+import type { Transaction } from "prosemirror-state";
+import { ReplaceStep } from "prosemirror-transform";
import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
import {
BlockIdentifier,
@@ -10,8 +11,6 @@ import {
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
-import { ReplaceStep } from "prosemirror-transform";
-import type { Transaction } from "prosemirror-state";
import { getPmSchema } from "../../../pmUtil.js";
export function insertBlocks<
@@ -27,9 +26,11 @@ export function insertBlocks<
const id =
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
const pmSchema = getPmSchema(tr);
- const nodesToInsert = blocksToInsert.map((block) =>
- blockToNode(block, pmSchema),
- );
+ const nodesToInsert = blocksToInsert.map((block) => {
+ const node = blockToNode(block, pmSchema);
+ node.check(); // `blockToNode` is lenient; validate before mutating the doc
+ return node;
+ });
const posInfo = getNodeById(id, tr.doc);
if (!posInfo) {
@@ -49,7 +50,7 @@ export function insertBlocks<
// re-convert them into full `Block`s.
const insertedBlocks = nodesToInsert.map((node) =>
nodeToBlock(node, pmSchema),
- );
+ ) as Block[];
return insertedBlocks;
}
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap
index 1c8df9a1c2..20c94c5ab8 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap
@@ -183,6 +183,7 @@ exports[`Test mergeBlocks > Basic 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -529,6 +530,7 @@ exports[`Test mergeBlocks > Basic 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -538,7 +540,7 @@ exports[`Test mergeBlocks > Basic 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -749,6 +751,7 @@ exports[`Test mergeBlocks > Blocks have different types 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1078,6 +1081,7 @@ exports[`Test mergeBlocks > Blocks have different types 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1087,7 +1091,7 @@ exports[`Test mergeBlocks > Blocks have different types 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1281,6 +1285,7 @@ exports[`Test mergeBlocks > First block has children 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1627,6 +1632,7 @@ exports[`Test mergeBlocks > First block has children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1636,7 +1642,7 @@ exports[`Test mergeBlocks > First block has children 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1829,6 +1835,7 @@ exports[`Test mergeBlocks > Second block has children 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2175,6 +2182,7 @@ exports[`Test mergeBlocks > Second block has children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2184,7 +2192,7 @@ exports[`Test mergeBlocks > Second block has children 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2395,6 +2403,7 @@ exports[`Test mergeBlocks > Second block is empty 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2730,6 +2739,7 @@ exports[`Test mergeBlocks > Second block is empty 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2739,7 +2749,7 @@ exports[`Test mergeBlocks > Second block is empty 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
index 57ec776d3a..654fbfdeba 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { getBlockInfoFromTransaction } from "../../../getBlockInfoFromPos.js";
import { setupTestEnv } from "../../setupTestEnv.js";
-import { mergeBlocksCommand } from "./mergeBlocks.js";
+import { getParentBlockInfo, mergeBlocksCommand } from "./mergeBlocks.js";
const getEditor = setupTestEnv();
@@ -77,6 +77,20 @@ describe("Test mergeBlocks", () => {
expect(anchorIsAtOldFirstBlockEndPos).toBeTruthy();
});
+ it("getParentBlockInfo returns undefined for top-level block", () => {
+ getEditor().setTextCursorPosition("paragraph-0");
+
+ const beforePos = getPosBeforeSelectedBlock();
+ const doc = getEditor()._tiptapEditor.state.doc;
+ const $pos = doc.resolve(beforePos);
+
+ expect($pos.depth - 1).toBeLessThan(1);
+
+ const result = getParentBlockInfo(doc, beforePos);
+
+ expect(result).toBeUndefined();
+ });
+
// We expect a no-op for each of the remaining tests as merging should only
// happen for blocks which both have inline content. We also expect
// `mergeBlocks` to return false as TipTap commands should do that instead of
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
index 50f8aa346c..ce1a9455db 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
@@ -10,22 +10,32 @@ import {
* Returns the block info from the parent block
* or undefined if we're at the root
*/
-export const getParentBlockInfo = (doc: Node, beforePos: number) => {
+export const getParentBlockInfo = (
+ doc: Node,
+ beforePos: number,
+): BlockInfo | undefined => {
const $pos = doc.resolve(beforePos);
+ const depth = $pos.depth - 1;
- if ($pos.depth <= 1) {
+ if (depth < 1) {
return undefined;
}
- // get start pos of parent
- const parentBeforePos = $pos.posAtIndex(
- $pos.index($pos.depth - 1),
- $pos.depth - 1,
- );
+ const parentBeforePos = $pos.before(depth);
+ const parentNode = doc.resolve(parentBeforePos).nodeAfter;
+
+ if (!parentNode) {
+ return undefined;
+ }
+
+ if (!parentNode.type.spec.group?.includes("bnBlock")) {
+ return getParentBlockInfo(doc, parentBeforePos);
+ }
const parentBlockInfo = getBlockInfoFromResolvedPos(
doc.resolve(parentBeforePos),
);
+
return parentBlockInfo;
};
@@ -50,6 +60,27 @@ export const getPrevBlockInfo = (doc: Node, beforePos: number) => {
return prevBlockInfo;
};
+/**
+ * Returns the block info from the sibling block after (below) the given block,
+ * or undefined if the given block is the last sibling.
+ */
+export const getNextBlockInfo = (doc: Node, beforePos: number) => {
+ const $pos = doc.resolve(beforePos);
+
+ const indexInParent = $pos.index();
+
+ if (indexInParent === $pos.node().childCount - 1) {
+ return undefined;
+ }
+
+ const nextBlockBeforePos = $pos.posAtIndex(indexInParent + 1);
+
+ const nextBlockInfo = getBlockInfoFromResolvedPos(
+ doc.resolve(nextBlockBeforePos),
+ );
+ return nextBlockInfo;
+};
+
/**
* If a block has children like this:
* A
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/__snapshots__/moveBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/moveBlocks/__snapshots__/moveBlocks.test.ts.snap
index b2d429c343..e59da045ed 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/__snapshots__/moveBlocks.test.ts.snap
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/__snapshots__/moveBlocks.test.ts.snap
@@ -200,6 +200,7 @@ exports[`Test moveBlocksDown > Basic 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -546,6 +547,7 @@ exports[`Test moveBlocksDown > Basic 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -555,7 +557,7 @@ exports[`Test moveBlocksDown > Basic 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -566,8 +568,25 @@ exports[`Test moveBlocksDown > Basic 1`] = `
]
`;
-exports[`Test moveBlocksDown > Into children 1`] = `
+exports[`Test moveBlocksDown > Explicit block argument moves the given block 1`] = `
[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
{
"children": [],
"content": [
@@ -587,23 +606,6 @@ exports[`Test moveBlocksDown > Into children 1`] = `
},
{
"children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
{
"children": [
{
@@ -766,6 +768,7 @@ exports[`Test moveBlocksDown > Into children 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1112,6 +1115,7 @@ exports[`Test moveBlocksDown > Into children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1121,7 +1125,7 @@ exports[`Test moveBlocksDown > Into children 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1132,7 +1136,7 @@ exports[`Test moveBlocksDown > Into children 1`] = `
]
`;
-exports[`Test moveBlocksDown > Last block 1`] = `
+exports[`Test moveBlocksDown > Explicit block argument with nested block 1`] = `
[
{
"children": [],
@@ -1168,36 +1172,35 @@ exports[`Test moveBlocksDown > Last block 1`] = `
},
"type": "paragraph",
},
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
{
"children": [
{
- "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",
- },
- ],
+ "children": [],
"content": [
{
"styles": {},
- "text": "Nested Paragraph 0",
+ "text": "Double Nested Paragraph 0",
"type": "text",
},
],
- "id": "nested-paragraph-0",
+ "id": "double-nested-paragraph-0",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1209,11 +1212,11 @@ exports[`Test moveBlocksDown > Last block 1`] = `
"content": [
{
"styles": {},
- "text": "Paragraph with children",
+ "text": "Nested Paragraph 0",
"type": "text",
},
],
- "id": "paragraph-with-children",
+ "id": "nested-paragraph-0",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1332,6 +1335,7 @@ exports[`Test moveBlocksDown > Last block 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1678,6 +1682,7 @@ exports[`Test moveBlocksDown > Last block 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1687,7 +1692,7 @@ exports[`Test moveBlocksDown > Last block 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1698,7 +1703,7 @@ exports[`Test moveBlocksDown > Last block 1`] = `
]
`;
-exports[`Test moveBlocksDown > Multiple blocks 1`] = `
+exports[`Test moveBlocksDown > Into children 1`] = `
[
{
"children": [],
@@ -1718,41 +1723,24 @@ exports[`Test moveBlocksDown > Multiple blocks 1`] = `
"type": "paragraph",
},
{
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with props",
- "type": "text",
- },
- ],
- "id": "paragraph-with-props",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "center",
- "textColor": "red",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
+ "children": [
{
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
},
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [
{
"children": [
{
@@ -1821,6 +1809,23 @@ exports[`Test moveBlocksDown > Multiple blocks 1`] = `
},
"type": "paragraph",
},
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
{
"children": [],
"content": [
@@ -1898,6 +1903,7 @@ exports[`Test moveBlocksDown > Multiple blocks 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2244,6 +2250,7 @@ exports[`Test moveBlocksDown > Multiple blocks 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2253,7 +2260,7 @@ exports[`Test moveBlocksDown > Multiple blocks 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2264,7 +2271,7 @@ exports[`Test moveBlocksDown > Multiple blocks 1`] = `
]
`;
-exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`] = `
+exports[`Test moveBlocksDown > Last block 1`] = `
[
{
"children": [],
@@ -2283,23 +2290,6 @@ exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`]
},
"type": "paragraph",
},
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 2",
- "type": "text",
- },
- ],
- "id": "paragraph-2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
{
"children": [],
"content": [
@@ -2370,6 +2360,23 @@ exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`]
},
"type": "paragraph",
},
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
{
"children": [],
"content": [
@@ -2464,6 +2471,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`]
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2810,6 +2818,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`]
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2819,7 +2828,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`]
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2830,7 +2839,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`]
]
`;
-exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
+exports[`Test moveBlocksDown > Multiple blocks 1`] = `
[
{
"children": [],
@@ -2854,15 +2863,15 @@ exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
"content": [
{
"styles": {},
- "text": "Paragraph 2",
+ "text": "Paragraph with props",
"type": "text",
},
],
- "id": "paragraph-2",
+ "id": "paragraph-with-props",
"props": {
"backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
},
"type": "paragraph",
},
@@ -2941,15 +2950,15 @@ exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
"content": [
{
"styles": {},
- "text": "Paragraph with props",
+ "text": "Paragraph 2",
"type": "text",
},
],
- "id": "paragraph-with-props",
+ "id": "paragraph-2",
"props": {
"backgroundColor": "default",
- "textAlignment": "center",
- "textColor": "red",
+ "textAlignment": "left",
+ "textColor": "default",
},
"type": "paragraph",
},
@@ -3030,6 +3039,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3376,6 +3386,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3385,7 +3396,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3396,7 +3407,7 @@ exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
]
`;
-exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested block 1`] = `
+exports[`Test moveBlocksDown > Multiple blocks ending in block with children 1`] = `
[
{
"children": [],
@@ -3420,11 +3431,11 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
"content": [
{
"styles": {},
- "text": "Paragraph 1",
+ "text": "Paragraph 2",
"type": "text",
},
],
- "id": "paragraph-1",
+ "id": "paragraph-2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3437,22 +3448,11 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
"content": [
{
"styles": {},
- "text": "Paragraph with children",
+ "text": "Paragraph 1",
"type": "text",
},
],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-1",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3463,15 +3463,33 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
{
"children": [
{
- "children": [],
+ "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": "Double Nested Paragraph 0",
+ "text": "Nested Paragraph 0",
"type": "text",
},
],
- "id": "double-nested-paragraph-0",
+ "id": "nested-paragraph-0",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3483,11 +3501,11 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
"content": [
{
"styles": {},
- "text": "Nested Paragraph 0",
+ "text": "Paragraph with children",
"type": "text",
},
],
- "id": "nested-paragraph-0",
+ "id": "paragraph-with-children",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3500,28 +3518,11 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
"content": [
{
"styles": {},
- "text": "Paragraph 2",
+ "text": "Paragraph with props",
"type": "text",
},
],
- "id": "paragraph-2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with props",
- "type": "text",
- },
- ],
- "id": "paragraph-with-props",
+ "id": "paragraph-with-props",
"props": {
"backgroundColor": "default",
"textAlignment": "center",
@@ -3606,6 +3607,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3952,6 +3954,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3961,7 +3964,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
{
"children": [],
"content": [],
- "id": "0",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3972,7 +3975,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested blo
]
`;
-exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1`] = `
+exports[`Test moveBlocksDown > Multiple blocks ending in nested block 1`] = `
[
{
"children": [],
@@ -3996,11 +3999,11 @@ exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1
"content": [
{
"styles": {},
- "text": "Paragraph 1",
+ "text": "Paragraph 2",
"type": "text",
},
],
- "id": "paragraph-1",
+ "id": "paragraph-2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -4013,15 +4016,15 @@ exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1
"content": [
{
"styles": {},
- "text": "Paragraph with props",
+ "text": "Paragraph 1",
"type": "text",
},
],
- "id": "paragraph-with-props",
+ "id": "paragraph-1",
"props": {
"backgroundColor": "default",
- "textAlignment": "center",
- "textColor": "red",
+ "textAlignment": "left",
+ "textColor": "default",
},
"type": "paragraph",
},
@@ -4083,15 +4086,15 @@ exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1
"content": [
{
"styles": {},
- "text": "Paragraph 2",
+ "text": "Paragraph with props",
"type": "text",
},
],
- "id": "paragraph-2",
+ "id": "paragraph-with-props",
"props": {
"backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
},
"type": "paragraph",
},
@@ -4172,6 +4175,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -4518,6 +4522,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -4527,7 +4532,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -4538,7 +4543,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1
]
`;
-exports[`Test moveBlocksDown > Multiple blocks starting in nested block 1`] = `
+exports[`Test moveBlocksDown > Multiple blocks starting and ending in nested block 1`] = `
[
{
"children": [],
@@ -4593,18 +4598,12 @@ exports[`Test moveBlocksDown > Multiple blocks starting in nested block 1`] = `
},
{
"children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with props",
- "type": "text",
- },
- ],
- "id": "paragraph-with-props",
+ "content": [],
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
- "textAlignment": "center",
- "textColor": "red",
+ "textAlignment": "left",
+ "textColor": "default",
},
"type": "paragraph",
},
@@ -4660,6 +4659,23 @@ exports[`Test moveBlocksDown > Multiple blocks starting in nested block 1`] = `
},
"type": "paragraph",
},
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
{
"children": [],
"content": [
@@ -4737,6 +4753,7 @@ exports[`Test moveBlocksDown > Multiple blocks starting in nested block 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -5083,27 +5100,17 @@ exports[`Test moveBlocksDown > Multiple blocks starting in nested block 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
},
"type": "heading",
},
- {
- "children": [],
- "content": [],
- "id": "trailing-paragraph",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
]
`;
-exports[`Test moveBlocksDown > Out of children 1`] = `
+exports[`Test moveBlocksDown > Multiple blocks starting in block with children 1`] = `
[
{
"children": [],
@@ -5139,6 +5146,23 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
},
"type": "paragraph",
},
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
{
"children": [
{
@@ -5209,23 +5233,6 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
},
"type": "paragraph",
},
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with props",
- "type": "text",
- },
- ],
- "id": "paragraph-with-props",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "center",
- "textColor": "red",
- },
- "type": "paragraph",
- },
{
"children": [],
"content": [
@@ -5303,6 +5310,7 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -5588,7 +5596,43 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
"type": "paragraph",
},
{
- "children": [],
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
"content": [
{
"styles": {
@@ -5613,6 +5657,7 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -5620,33 +5665,31 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
"type": "heading",
},
{
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Double Nested Paragraph 1",
- "type": "text",
- },
- ],
- "id": "double-nested-paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test moveBlocksDown > Multiple blocks starting in nested block 1`] = `
+[
+ {
+ "children": [],
"content": [
{
"styles": {},
- "text": "Nested Paragraph 1",
+ "text": "Paragraph 0",
"type": "text",
},
],
- "id": "nested-paragraph-1",
+ "id": "paragraph-0",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -5656,8 +5699,14 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
},
{
"children": [],
- "content": [],
- "id": "trailing-paragraph",
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -5665,21 +5714,16 @@ exports[`Test moveBlocksDown > Out of children 1`] = `
},
"type": "paragraph",
},
-]
-`;
-
-exports[`Test moveBlocksUp > Basic 1`] = `
-[
{
"children": [],
"content": [
{
"styles": {},
- "text": "Paragraph 1",
+ "text": "Paragraph with children",
"type": "text",
},
],
- "id": "paragraph-1",
+ "id": "paragraph-with-children",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -5692,48 +5736,30 @@ exports[`Test moveBlocksUp > Basic 1`] = `
"content": [
{
"styles": {},
- "text": "Paragraph 0",
+ "text": "Paragraph with props",
"type": "text",
},
],
- "id": "paragraph-0",
+ "id": "paragraph-with-props",
"props": {
"backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
},
"type": "paragraph",
},
{
"children": [
{
- "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",
- },
- ],
+ "children": [],
"content": [
{
"styles": {},
- "text": "Nested Paragraph 0",
+ "text": "Double Nested Paragraph 0",
"type": "text",
},
],
- "id": "nested-paragraph-0",
+ "id": "double-nested-paragraph-0",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -5745,11 +5771,11 @@ exports[`Test moveBlocksUp > Basic 1`] = `
"content": [
{
"styles": {},
- "text": "Paragraph with children",
+ "text": "Nested Paragraph 0",
"type": "text",
},
],
- "id": "paragraph-with-children",
+ "id": "nested-paragraph-0",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -5779,32 +5805,15 @@ exports[`Test moveBlocksUp > Basic 1`] = `
"content": [
{
"styles": {},
- "text": "Paragraph with props",
+ "text": "Paragraph 3",
"type": "text",
},
],
- "id": "paragraph-with-props",
+ "id": "paragraph-3",
"props": {
"backgroundColor": "default",
- "textAlignment": "center",
- "textColor": "red",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 3",
- "type": "text",
- },
- ],
- "id": "paragraph-3",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
},
"type": "paragraph",
},
@@ -5868,6 +5877,7 @@ exports[`Test moveBlocksUp > Basic 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -6214,6 +6224,2277 @@ exports[`Test moveBlocksUp > Basic 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test moveBlocksDown > Out of children 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test moveBlocksUp > Basic 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test moveBlocksUp > Explicit block argument moves the given block 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test moveBlocksUp > Explicit block argument with nested block 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -6223,7 +8504,7 @@ exports[`Test moveBlocksUp > Basic 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -6434,6 +8715,7 @@ exports[`Test moveBlocksUp > First block 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -6780,6 +9062,7 @@ exports[`Test moveBlocksUp > First block 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -6789,7 +9072,7 @@ exports[`Test moveBlocksUp > First block 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -7000,6 +9283,7 @@ exports[`Test moveBlocksUp > Into children 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -7346,6 +9630,7 @@ exports[`Test moveBlocksUp > Into children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -7355,7 +9640,7 @@ exports[`Test moveBlocksUp > Into children 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -7566,6 +9851,7 @@ exports[`Test moveBlocksUp > Multiple blocks 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -7912,6 +10198,7 @@ exports[`Test moveBlocksUp > Multiple blocks 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -7921,7 +10208,7 @@ exports[`Test moveBlocksUp > Multiple blocks 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -8132,6 +10419,7 @@ exports[`Test moveBlocksUp > Multiple blocks ending in block with children 1`] =
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -8478,6 +10766,7 @@ exports[`Test moveBlocksUp > Multiple blocks ending in block with children 1`] =
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -8487,7 +10776,7 @@ exports[`Test moveBlocksUp > Multiple blocks ending in block with children 1`] =
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -8698,6 +10987,7 @@ exports[`Test moveBlocksUp > Multiple blocks ending in nested block 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -9044,6 +11334,7 @@ exports[`Test moveBlocksUp > Multiple blocks ending in nested block 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -9053,7 +11344,7 @@ exports[`Test moveBlocksUp > Multiple blocks ending in nested block 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -9246,6 +11537,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting and ending in nested block
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -9592,6 +11884,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting and ending in nested block
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -9618,7 +11911,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting and ending in nested block
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -9829,6 +12122,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting in block with children 1`]
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -10175,6 +12469,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting in block with children 1`]
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -10184,7 +12479,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting in block with children 1`]
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -10394,6 +12689,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting in nested block 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -10740,6 +13036,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting in nested block 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -10749,7 +13046,7 @@ exports[`Test moveBlocksUp > Multiple blocks starting in nested block 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -10960,6 +13257,7 @@ exports[`Test moveBlocksUp > Out of children 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -11305,6 +13603,7 @@ exports[`Test moveBlocksUp > Out of children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -11314,7 +13613,7 @@ exports[`Test moveBlocksUp > Out of children 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
index 8c637d5985..763de289c5 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
@@ -2,7 +2,10 @@ import { NodeSelection, TextSelection } from "prosemirror-state";
import { CellSelection } from "prosemirror-tables";
import { describe, expect, it } from "vitest";
-import { getBlockInfoFromTransaction } from "../../../getBlockInfoFromPos.js";
+import {
+ getBlockInfoFromTransaction,
+ getNearestBlockPos,
+} from "../../../getBlockInfoFromPos.js";
import { setupTestEnv } from "../../setupTestEnv.js";
import {
moveBlocksDown,
@@ -204,6 +207,45 @@ describe("Test moveBlocksUp", () => {
expect(getEditor().document).toMatchSnapshot();
});
+
+ it("Explicit block argument moves the given block", () => {
+ getEditor().setTextCursorPosition("paragraph-0");
+
+ moveBlocksUp(getEditor(), "paragraph-2");
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Explicit block argument does not change the selection", () => {
+ getEditor().setTextCursorPosition("paragraph-1");
+ makeSelectionSpanContent("text");
+
+ moveBlocksUp(getEditor(), "paragraph-2");
+
+ const { anchor, head } = getEditor().transact((tr) => tr.selection);
+ const anchorBlockId = getEditor().transact(
+ (tr) => getNearestBlockPos(tr.doc, anchor).node.attrs.id,
+ );
+ const headBlockId = getEditor().transact(
+ (tr) => getNearestBlockPos(tr.doc, head).node.attrs.id,
+ );
+ expect(anchorBlockId).toBe("paragraph-1");
+ expect(headBlockId).toBe("paragraph-1");
+ });
+
+ it("Explicit block argument with first block is a no-op", () => {
+ const documentBefore = getEditor().document;
+
+ moveBlocksUp(getEditor(), "paragraph-0");
+
+ expect(getEditor().document).toEqual(documentBefore);
+ });
+
+ it("Explicit block argument with nested block", () => {
+ moveBlocksUp(getEditor(), "nested-paragraph-1");
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
});
describe("Test moveBlocksDown", () => {
@@ -232,7 +274,7 @@ describe("Test moveBlocksDown", () => {
});
it("Last block", () => {
- getEditor().setTextCursorPosition("trailing-paragraph");
+ getEditor().setTextCursorPosition("paragraph-9");
moveBlocksDown(getEditor());
@@ -286,4 +328,43 @@ describe("Test moveBlocksDown", () => {
expect(getEditor().document).toMatchSnapshot();
});
+
+ it("Explicit block argument moves the given block", () => {
+ getEditor().setTextCursorPosition("paragraph-9");
+
+ moveBlocksDown(getEditor(), "paragraph-0");
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Explicit block argument does not change the selection", () => {
+ getEditor().setTextCursorPosition("paragraph-1");
+ makeSelectionSpanContent("text");
+
+ moveBlocksDown(getEditor(), "paragraph-0");
+
+ const { anchor, head } = getEditor().transact((tr) => tr.selection);
+ const anchorBlockId = getEditor().transact(
+ (tr) => getNearestBlockPos(tr.doc, anchor).node.attrs.id,
+ );
+ const headBlockId = getEditor().transact(
+ (tr) => getNearestBlockPos(tr.doc, head).node.attrs.id,
+ );
+ expect(anchorBlockId).toBe("paragraph-1");
+ expect(headBlockId).toBe("paragraph-1");
+ });
+
+ it("Explicit block argument with last block is a no-op", () => {
+ const documentBefore = getEditor().document;
+
+ moveBlocksDown(getEditor(), "trailing-paragraph");
+
+ expect(getEditor().document).toEqual(documentBefore);
+ });
+
+ it("Explicit block argument with nested block", () => {
+ moveBlocksDown(getEditor(), "nested-paragraph-0");
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
});
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
index 8d4591123e..bb2f08dfca 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
@@ -123,29 +123,50 @@ function updateBlockSelectionFromData(
tr.setSelection(selection);
}
-/**
- * Replaces any `columnList` blocks with the children of their columns. This is
- * done here instead of in `getSelection` as we still need to remove the entire
- * `columnList` node but only insert the `blockContainer` nodes inside it.
- * @param blocks The blocks to flatten.
- */
+// Replaces top-level `column` blocks with their children, as a `column` is not
+// a valid block outside a `columnList`. Other blocks are returned as-is.
function flattenColumns(
blocks: Block[],
): Block[] {
- return blocks
- .map((block) => {
- if (block.type === "columnList") {
- return block.children
- .map((column) => flattenColumns(column.children))
- .flat();
+ return blocks.flatMap((block) =>
+ block.type === "column" ? block.children : [block],
+ );
+}
+
+/**
+ * Removes the given blocks from the editor, then inserts them before/after a
+ * reference block.
+ * @param editor The BlockNote editor instance to move the blocks in.
+ * @param blocks The blocks to move.
+ * @param referenceBlock The reference block to insert the blocks before/after.
+ * @param placement Whether to insert the blocks before or after the reference
+ * block.
+ */
+export function moveBlocks(
+ editor: BlockNoteEditor,
+ blocks: Block[],
+ referenceBlock: BlockIdentifier,
+ placement: "before" | "after",
+) {
+ editor.transact(() => {
+ // A `columnList` reference can be dissolved by `fixColumnList` when its
+ // `column`s are removed, leaving its ID invalid for re-insertion. Anchor
+ // to an adjacent block instead, which is unaffected by the removal.
+ const refBlock = editor.getBlock(referenceBlock);
+ if (refBlock?.type === "columnList") {
+ const adjacent =
+ placement === "after"
+ ? editor.getNextBlock(refBlock)
+ : editor.getPrevBlock(refBlock);
+ if (adjacent) {
+ referenceBlock = adjacent;
+ placement = placement === "after" ? "before" : "after";
}
+ }
- return {
- ...block,
- children: flattenColumns(block.children),
- };
- })
- .flat();
+ editor.removeBlocks(blocks);
+ editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement);
+ });
}
/**
@@ -170,8 +191,7 @@ export function moveSelectedBlocksAndSelection(
];
const selectionData = getBlockSelectionData(editor);
- editor.removeBlocks(blocks);
- editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement);
+ moveBlocks(editor, blocks, referenceBlock, placement);
updateBlockSelectionFromData(tr, selectionData);
});
@@ -289,50 +309,91 @@ function getMoveDownPlacement(
return { referenceBlock, placement };
}
-export function moveBlocksUp(editor: BlockNoteEditor) {
+export function moveBlocksUp(
+ editor: BlockNoteEditor,
+ blockIdentifier?: BlockIdentifier,
+) {
editor.transact(() => {
- const selection = editor.getSelection();
- const block = selection?.blocks[0] || editor.getTextCursorPosition().block;
+ let sourceBlock: Block | undefined;
+ if (blockIdentifier) {
+ sourceBlock = editor.getBlock(blockIdentifier);
+ if (!sourceBlock) {
+ return;
+ }
+ } else {
+ const selection = editor.getSelection();
+ sourceBlock =
+ selection?.blocks[0] || editor.getTextCursorPosition().block;
+ }
const moveUpPlacement = getMoveUpPlacement(
editor,
- editor.getPrevBlock(block),
- editor.getParentBlock(block),
+ editor.getPrevBlock(sourceBlock),
+ editor.getParentBlock(sourceBlock),
);
if (!moveUpPlacement) {
return;
}
- moveSelectedBlocksAndSelection(
- editor,
- moveUpPlacement.referenceBlock,
- moveUpPlacement.placement,
- );
+ if (blockIdentifier) {
+ moveBlocks(
+ editor,
+ [sourceBlock],
+ moveUpPlacement.referenceBlock,
+ moveUpPlacement.placement,
+ );
+ } else {
+ moveSelectedBlocksAndSelection(
+ editor,
+ moveUpPlacement.referenceBlock,
+ moveUpPlacement.placement,
+ );
+ }
});
}
-export function moveBlocksDown(editor: BlockNoteEditor) {
+export function moveBlocksDown(
+ editor: BlockNoteEditor,
+ blockIdentifier?: BlockIdentifier,
+) {
editor.transact(() => {
- const selection = editor.getSelection();
- const block =
- selection?.blocks[selection?.blocks.length - 1] ||
- editor.getTextCursorPosition().block;
+ let sourceBlock: Block | undefined;
+ if (blockIdentifier) {
+ sourceBlock = editor.getBlock(blockIdentifier);
+ if (!sourceBlock) {
+ return;
+ }
+ } else {
+ const selection = editor.getSelection();
+ sourceBlock =
+ selection?.blocks[selection?.blocks.length - 1] ||
+ editor.getTextCursorPosition().block;
+ }
const moveDownPlacement = getMoveDownPlacement(
editor,
- editor.getNextBlock(block),
- editor.getParentBlock(block),
+ editor.getNextBlock(sourceBlock),
+ editor.getParentBlock(sourceBlock),
);
if (!moveDownPlacement) {
return;
}
- moveSelectedBlocksAndSelection(
- editor,
- moveDownPlacement.referenceBlock,
- moveDownPlacement.placement,
- );
+ if (blockIdentifier) {
+ moveBlocks(
+ editor,
+ [sourceBlock],
+ moveDownPlacement.referenceBlock,
+ moveDownPlacement.placement,
+ );
+ } else {
+ moveSelectedBlocksAndSelection(
+ editor,
+ moveDownPlacement.referenceBlock,
+ moveDownPlacement.placement,
+ );
+ }
});
}
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/__snapshots__/nestBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/nestBlock/__snapshots__/nestBlock.test.ts.snap
new file mode 100644
index 0000000000..95a5419dab
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/__snapshots__/nestBlock.test.ts.snap
@@ -0,0 +1,952 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`unnestBlock / liftListItem > BLO-835: unnest block with siblings after and nested children > should handle unnesting the first of many siblings 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 3",
+ "type": "text",
+ },
+ ],
+ "id": "block3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 4",
+ "type": "text",
+ },
+ ],
+ "id": "block4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 2",
+ "type": "text",
+ },
+ ],
+ "id": "block2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-835: unnest block with siblings after and nested children > should move siblings after into lifted block's children 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 5",
+ "type": "text",
+ },
+ ],
+ "id": "block5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 2",
+ "type": "text",
+ },
+ ],
+ "id": "block2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-835: unnest block with siblings after and nested children > should not throw when unnesting a block that has siblings after it 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 3",
+ "type": "text",
+ },
+ ],
+ "id": "block3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 4",
+ "type": "text",
+ },
+ ],
+ "id": "block4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 5",
+ "type": "text",
+ },
+ ],
+ "id": "block5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 2",
+ "type": "text",
+ },
+ ],
+ "id": "block2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-844/847: unnest with complex nesting after parent operations > should handle sequential unnest operations 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 2",
+ "type": "text",
+ },
+ ],
+ "id": "block2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 4",
+ "type": "text",
+ },
+ ],
+ "id": "block4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 3",
+ "type": "text",
+ },
+ ],
+ "id": "block3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-844/847: unnest with complex nesting after parent operations > should handle unnesting when block is only child 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent",
+ "type": "text",
+ },
+ ],
+ "id": "parent",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child",
+ "type": "text",
+ },
+ ],
+ "id": "child",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-899: Shift-Tab on second-level nested block > should not throw when unnesting a deeply nested block with siblings 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Grandchild 2",
+ "type": "text",
+ },
+ ],
+ "id": "grandchild2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Grandchild 1",
+ "type": "text",
+ },
+ ],
+ "id": "grandchild1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Grandchild 3",
+ "type": "text",
+ },
+ ],
+ "id": "grandchild3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Grandchild 4",
+ "type": "text",
+ },
+ ],
+ "id": "grandchild4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 2",
+ "type": "text",
+ },
+ ],
+ "id": "child2",
+ "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",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-899: Shift-Tab on second-level nested block > should not throw when unnesting the last deeply nested block 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Grandchild 1",
+ "type": "text",
+ },
+ ],
+ "id": "grandchild1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Grandchild 2",
+ "type": "text",
+ },
+ ],
+ "id": "grandchild2",
+ "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",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-953: unnest block with multi-level nested children > should preserve all deeply nested content when unnesting 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 4",
+ "type": "text",
+ },
+ ],
+ "id": "block4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 3",
+ "type": "text",
+ },
+ ],
+ "id": "block3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 5",
+ "type": "text",
+ },
+ ],
+ "id": "block5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block A",
+ "type": "text",
+ },
+ ],
+ "id": "blockA",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > BLO-953: unnest block with multi-level nested children > should preserve content when unnesting only child 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 4",
+ "type": "text",
+ },
+ ],
+ "id": "block4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 3",
+ "type": "text",
+ },
+ ],
+ "id": "block3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block A",
+ "type": "text",
+ },
+ ],
+ "id": "blockA",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > Edge cases > should handle unnesting block with both existing children and siblings after 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent",
+ "type": "text",
+ },
+ ],
+ "id": "parent",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Existing Grandchild",
+ "type": "text",
+ },
+ ],
+ "id": "existing-grandchild",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 2",
+ "type": "text",
+ },
+ ],
+ "id": "child2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 3",
+ "type": "text",
+ },
+ ],
+ "id": "child3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > Edge cases > should handle unnesting with different block types 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Parent",
+ "type": "text",
+ },
+ ],
+ "id": "parent",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph Sibling",
+ "type": "text",
+ },
+ ],
+ "id": "para-sibling",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading Child",
+ "type": "text",
+ },
+ ],
+ "id": "heading-child",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > nestBlock > should nest a block under its previous sibling 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 2",
+ "type": "text",
+ },
+ ],
+ "id": "block2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`unnestBlock / liftListItem > nestBlock > should nest into a sibling that already has children (nestedBefore) 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Child 1",
+ "type": "text",
+ },
+ ],
+ "id": "child1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 2",
+ "type": "text",
+ },
+ ],
+ "id": "block2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Block 1",
+ "type": "text",
+ },
+ ],
+ "id": "block1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
new file mode 100644
index 0000000000..1938a3ea80
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts
@@ -0,0 +1,661 @@
+import { describe, expect, it } from "vitest";
+
+import { afterAll, beforeAll } from "vitest";
+import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+
+/**
+ * Custom test setup with a document designed to reproduce nesting/unnesting bugs.
+ *
+ * BLO-835 / BLO-899: liftListItem produces invalid content when a nested block
+ * has siblings after it in the same blockGroup.
+ * BLO-953: Backspace at start of indented block with multi-level children
+ * causes deeply nested content to be lost.
+ * BLO-844 / BLO-847: Deleting parent block then operating on children causes
+ * RangeError.
+ */
+
+function setupNestTestEnv() {
+ let editor: BlockNoteEditor;
+ const div = document.createElement("div");
+
+ beforeAll(() => {
+ editor = BlockNoteEditor.create();
+ editor.mount(div);
+ });
+
+ afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+ });
+
+ return (doc: PartialBlock[]) => {
+ editor.replaceBlocks(editor.document, doc);
+ return editor;
+ };
+}
+
+const withEditor = setupNestTestEnv();
+
+describe("unnestBlock / liftListItem", () => {
+ // BLO-835: liftListItem error with siblings after nested children
+ // Structure:
+ // block1
+ // block2 ← unnest this
+ // block3
+ // block4
+ // block5
+ //
+ // Expected: block2 lifts out, block5 becomes child of block2
+ describe("BLO-835: unnest block with siblings after and nested children", () => {
+ it("should not throw when unnesting a block that has siblings after it", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ children: [
+ {
+ id: "block2",
+ type: "paragraph",
+ content: "Block 2",
+ children: [
+ {
+ id: "block3",
+ type: "paragraph",
+ content: "Block 3",
+ },
+ {
+ id: "block4",
+ type: "paragraph",
+ content: "Block 4",
+ },
+ ],
+ },
+ {
+ id: "block5",
+ type: "paragraph",
+ content: "Block 5",
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("block2", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ expect(editor.document).toMatchSnapshot();
+ });
+
+ it("should move siblings after into lifted block's children", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ children: [
+ {
+ id: "block2",
+ type: "paragraph",
+ content: "Block 2",
+ },
+ {
+ id: "block5",
+ type: "paragraph",
+ content: "Block 5",
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("block2", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ // block2 should now be at root level after block1
+ // block5 should be a child of block2
+ expect(editor.document).toMatchSnapshot();
+ });
+
+ it("should handle unnesting the first of many siblings", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ children: [
+ {
+ id: "block2",
+ type: "paragraph",
+ content: "Block 2",
+ },
+ {
+ id: "block3",
+ type: "paragraph",
+ content: "Block 3",
+ },
+ {
+ id: "block4",
+ type: "paragraph",
+ content: "Block 4",
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("block2", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ // block2 at root, block3 and block4 become children of block2
+ expect(editor.document).toMatchSnapshot();
+ });
+ });
+
+ // BLO-899: Shift-Tab on second-level nested child (not last) causes error
+ // Structure:
+ // parent
+ // child1
+ // grandchild1 ← unnest this
+ // grandchild2
+ // child2
+ // grandchild3
+ // grandchild4
+ //
+ describe("BLO-899: Shift-Tab on second-level nested block", () => {
+ it("should not throw when unnesting a deeply nested block with siblings", () => {
+ const editor = withEditor([
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [
+ {
+ id: "child1",
+ type: "paragraph",
+ content: "Child 1",
+ children: [
+ {
+ id: "grandchild1",
+ type: "paragraph",
+ content: "Grandchild 1",
+ },
+ {
+ id: "grandchild2",
+ type: "paragraph",
+ content: "Grandchild 2",
+ },
+ ],
+ },
+ {
+ id: "child2",
+ type: "paragraph",
+ content: "Child 2",
+ children: [
+ {
+ id: "grandchild3",
+ type: "paragraph",
+ content: "Grandchild 3",
+ },
+ {
+ id: "grandchild4",
+ type: "paragraph",
+ content: "Grandchild 4",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("grandchild1", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ // grandchild1 should become a sibling of child1 (at same level)
+ // grandchild2 should become a child of grandchild1
+ expect(editor.document).toMatchSnapshot();
+ });
+
+ it("should not throw when unnesting the last deeply nested block", () => {
+ const editor = withEditor([
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [
+ {
+ id: "child1",
+ type: "paragraph",
+ content: "Child 1",
+ children: [
+ {
+ id: "grandchild1",
+ type: "paragraph",
+ content: "Grandchild 1",
+ },
+ {
+ id: "grandchild2",
+ type: "paragraph",
+ content: "Grandchild 2",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ // Unnesting the LAST child should always work (no siblings after)
+ editor.setTextCursorPosition("grandchild2", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ expect(editor.document).toMatchSnapshot();
+ });
+ });
+
+ // BLO-953: Backspace at start of indented block loses deeply nested content
+ // Structure:
+ // block1
+ // blockA "text A" ← Backspace at start (unnest via keyboard)
+ // block3
+ // block4
+ // block5
+ //
+ // Expected: blockA moves to root, all children preserved
+ describe("BLO-953: unnest block with multi-level nested children", () => {
+ it("should preserve all deeply nested content when unnesting", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ children: [
+ {
+ id: "blockA",
+ type: "paragraph",
+ content: "Block A",
+ children: [
+ {
+ id: "block3",
+ type: "paragraph",
+ content: "Block 3",
+ children: [
+ {
+ id: "block4",
+ type: "paragraph",
+ content: "Block 4",
+ },
+ ],
+ },
+ {
+ id: "block5",
+ type: "paragraph",
+ content: "Block 5",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("blockA", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ const doc = editor.document;
+
+ // All blocks should still exist in the document
+ const allBlockIds = flattenBlockIds(doc);
+ expect(allBlockIds).toContain("block1");
+ expect(allBlockIds).toContain("blockA");
+ expect(allBlockIds).toContain("block3");
+ expect(allBlockIds).toContain("block4");
+ expect(allBlockIds).toContain("block5");
+
+ expect(doc).toMatchSnapshot();
+ });
+
+ it("should preserve content when unnesting only child", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ children: [
+ {
+ id: "blockA",
+ type: "paragraph",
+ content: "Block A",
+ children: [
+ {
+ id: "block3",
+ type: "paragraph",
+ content: "Block 3",
+ children: [
+ {
+ id: "block4",
+ type: "paragraph",
+ content: "Block 4",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("blockA", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ const doc = editor.document;
+ const allBlockIds = flattenBlockIds(doc);
+ expect(allBlockIds).toContain("block1");
+ expect(allBlockIds).toContain("blockA");
+ expect(allBlockIds).toContain("block3");
+ expect(allBlockIds).toContain("block4");
+
+ expect(doc).toMatchSnapshot();
+ });
+ });
+
+ // BLO-844 / BLO-847: Operations after deleting parent cause RangeError
+ // These bugs manifest when backspace merges/deletes a parent block and
+ // then further operations on the (now re-parented) children fail.
+ //
+ // The core issue is liftListItem failing when the children need to be
+ // reorganized. Testing the unnest operation directly.
+ describe("BLO-844/847: unnest with complex nesting after parent operations", () => {
+ it("should handle unnesting when block is only child", () => {
+ const editor = withEditor([
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [
+ {
+ id: "child",
+ type: "paragraph",
+ content: "Child",
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("child", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ expect(editor.document).toMatchSnapshot();
+ });
+
+ it("should handle sequential unnest operations", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ children: [
+ {
+ id: "block2",
+ type: "paragraph",
+ content: "Block 2",
+ children: [
+ {
+ id: "block3",
+ type: "paragraph",
+ content: "Block 3",
+ },
+ ],
+ },
+ {
+ id: "block4",
+ type: "paragraph",
+ content: "Block 4",
+ },
+ ],
+ },
+ ]);
+
+ // First unnest block2
+ editor.setTextCursorPosition("block2", "start");
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ // Then unnest block3 (which should now be child of block2)
+ editor.setTextCursorPosition("block3", "start");
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ expect(editor.document).toMatchSnapshot();
+ });
+ });
+
+ // Additional edge cases
+ describe("Edge cases", () => {
+ it("should not unnest a root-level block", () => {
+ const editor = withEditor([
+ {
+ id: "root-block",
+ type: "paragraph",
+ content: "Root Block",
+ },
+ ]);
+
+ editor.setTextCursorPosition("root-block", "start");
+
+ // Should be a no-op (can't unnest root level)
+ const canUnnest = editor.canUnnestBlock();
+ expect(canUnnest).toBe(false);
+ });
+
+ it("should handle unnesting block with both existing children and siblings after", () => {
+ const editor = withEditor([
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [
+ {
+ id: "child1",
+ type: "paragraph",
+ content: "Child 1",
+ children: [
+ {
+ id: "existing-grandchild",
+ type: "paragraph",
+ content: "Existing Grandchild",
+ },
+ ],
+ },
+ {
+ id: "child2",
+ type: "paragraph",
+ content: "Child 2",
+ },
+ {
+ id: "child3",
+ type: "paragraph",
+ content: "Child 3",
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("child1", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ // child1 should be at root level
+ // existing-grandchild should still be a child of child1
+ // child2 and child3 should also become children of child1
+ const doc = editor.document;
+ const allBlockIds = flattenBlockIds(doc);
+ expect(allBlockIds).toContain("parent");
+ expect(allBlockIds).toContain("child1");
+ expect(allBlockIds).toContain("existing-grandchild");
+ expect(allBlockIds).toContain("child2");
+ expect(allBlockIds).toContain("child3");
+
+ expect(doc).toMatchSnapshot();
+ });
+
+ it("should handle unnesting with different block types", () => {
+ const editor = withEditor([
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [
+ {
+ id: "heading-child",
+ type: "heading",
+ content: "Heading Child",
+ },
+ {
+ id: "para-sibling",
+ type: "paragraph",
+ content: "Paragraph Sibling",
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("heading-child", "start");
+
+ expect(() => {
+ editor.unnestBlock();
+ }).not.toThrow();
+
+ expect(editor.document).toMatchSnapshot();
+ });
+ });
+
+ // nestBlock tests (sinkListItem) - ensuring nesting works correctly
+ describe("nestBlock", () => {
+ it("should nest a block under its previous sibling", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ },
+ {
+ id: "block2",
+ type: "paragraph",
+ content: "Block 2",
+ },
+ ]);
+
+ editor.setTextCursorPosition("block2", "start");
+ editor.nestBlock();
+
+ expect(editor.document).toMatchSnapshot();
+ });
+
+ it("should not nest the first block (no previous sibling)", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ },
+ ]);
+
+ editor.setTextCursorPosition("block1", "start");
+
+ const canNest = editor.canNestBlock();
+ expect(canNest).toBe(false);
+ });
+
+ it("should nest into a sibling that already has children (nestedBefore)", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ children: [
+ {
+ id: "child1",
+ type: "paragraph",
+ content: "Child 1",
+ },
+ ],
+ },
+ {
+ id: "block2",
+ type: "paragraph",
+ content: "Block 2",
+ },
+ ]);
+
+ editor.setTextCursorPosition("block2", "start");
+ editor.nestBlock();
+
+ expect(editor.document).toMatchSnapshot();
+ });
+
+ it("nest then unnest should be a round trip", () => {
+ const editor = withEditor([
+ {
+ id: "block1",
+ type: "paragraph",
+ content: "Block 1",
+ },
+ {
+ id: "block2",
+ type: "paragraph",
+ content: "Block 2",
+ },
+ ]);
+
+ const originalDoc = JSON.parse(JSON.stringify(editor.document));
+
+ editor.setTextCursorPosition("block2", "start");
+ editor.nestBlock();
+ editor.unnestBlock();
+
+ // Content should be preserved (IDs may differ but structure/content same)
+ expect(editor.document.length).toBe(originalDoc.length);
+ expect(editor.document[0].content).toEqual(originalDoc[0].content);
+ expect(editor.document[1].content).toEqual(originalDoc[1].content);
+ });
+ });
+});
+
+/** Recursively collects all block IDs from a document */
+function flattenBlockIds(blocks: any[]): string[] {
+ const ids: string[] = [];
+ for (const block of blocks) {
+ if (block.id) {
+ ids.push(block.id);
+ }
+ if (block.children) {
+ ids.push(...flattenBlockIds(block.children));
+ }
+ }
+ return ids;
+}
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
index 2ef86d71cc..c995faeda1 100644
--- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
@@ -1,82 +1,198 @@
-import { Fragment, NodeType, Slice } from "prosemirror-model";
-import { EditorState, Transaction } from "prosemirror-state";
-import { ReplaceAroundStep } from "prosemirror-transform";
+import { Fragment, NodeRange, NodeType, Slice } from "prosemirror-model";
+import { Transaction } from "prosemirror-state";
+import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import { getBlockInfoFromTransaction } from "../../../getBlockInfoFromPos.js";
-// TODO: Unit tests
/**
- * This is a modified version of https://github.com/ProseMirror/prosemirror-schema-list/blob/569c2770cbb8092d8f11ea53ecf78cb7a4e8f15a/src/schema-list.ts#L232
+ * Modified version of prosemirror-schema-list's sinkItem.
+ * https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts
*
- * The original function derives too many information from the parentnode and itemtype
+ * Changes from the original:
+ * 1. Range predicate checks node.type instead of firstChild.type
+ * 2. nestedBefore checks groupType instead of parent.type
+ * 3. Slice creates groupType instead of parent.type
+ * 4. Operates on Transaction directly instead of state+dispatch
*/
-function sinkListItem(itemType: NodeType, groupType: NodeType) {
- return function (state: EditorState, dispatch?: (tr: Transaction) => void) {
- const { $from, $to } = state.selection;
- const range = $from.blockRange(
- $to,
- (node) =>
- node.childCount > 0 &&
- (node.type.name === "blockGroup" || node.type.name === "column"), // change necessary to not look at first item child type
+function sinkItem(
+ tr: Transaction,
+ itemType: NodeType,
+ groupType: NodeType,
+) {
+ const { $from, $to } = tr.selection;
+ const range = $from.blockRange(
+ $to,
+ (node) =>
+ node.childCount > 0 &&
+ (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
+ );
+ if (!range) {
+ return false;
+ }
+ const startIndex = range.startIndex;
+ if (startIndex === 0) {
+ return false;
+ }
+ const parent = range.parent;
+ const nodeBefore = parent.child(startIndex - 1);
+ if (nodeBefore.type !== itemType) {
+ return false;
+ }
+ const nestedBefore =
+ nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change 2
+ const inner = Fragment.from(nestedBefore ? itemType.create() : null);
+ const slice = new Slice(
+ Fragment.from(
+ itemType.create(null, Fragment.from(groupType.create(null, inner))), // change 3
+ ),
+ nestedBefore ? 3 : 1,
+ 0,
+ );
+
+ const before = range.start;
+ const after = range.end;
+
+ tr.step(
+ new ReplaceAroundStep(
+ before - (nestedBefore ? 3 : 1),
+ after,
+ before,
+ after,
+ slice,
+ 1,
+ true,
+ ),
+ ).scrollIntoView();
+
+ return true;
+}
+
+export function nestBlock(editor: BlockNoteEditor) {
+ return editor.transact((tr) => {
+ return sinkItem(
+ tr,
+ editor.pmSchema.nodes["blockContainer"],
+ editor.pmSchema.nodes["blockGroup"],
);
- if (!range) {
- return false;
- }
- const startIndex = range.startIndex;
- if (startIndex === 0) {
- return false;
- }
- const parent = range.parent;
- const nodeBefore = parent.child(startIndex - 1);
- if (nodeBefore.type !== itemType) {
- return false;
- }
- if (dispatch) {
- const nestedBefore =
- nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change necessary to check groupType instead of parent.type
- const inner = Fragment.from(nestedBefore ? itemType.create() : null);
- const slice = new Slice(
- Fragment.from(
- itemType.create(null, Fragment.from(groupType.create(null, inner))), // change necessary to create "groupType" instead of parent.type
+ });
+}
+
+/**
+ * Modified version of prosemirror-schema-list's liftToOuterList.
+ * https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts
+ *
+ * Changes from the original:
+ * 1. Operates on Transaction directly instead of state+dispatch (TipTap compat)
+ * 2. When the lifted block already has children (a groupType child), uses deeper
+ * openStart/offset so siblings merge into the existing group instead of
+ * creating a second one (which would violate blockContainer's schema)
+ * 3. Uses groupType.create() instead of range.parent.copy() (same as sinkItem)
+ */
+function liftToOuterList(
+ tr: Transaction,
+ itemType: NodeType,
+ groupType: NodeType, // change 3
+ range: NodeRange,
+) {
+ const end = range.end;
+ const endOfList = range.$to.end(range.depth);
+
+ if (end < endOfList) {
+ // There are siblings after the lifted items, which must become
+ // children of the last item
+ const blockBeingLifted = range.parent.child(range.endIndex - 1);
+ const nestedAfter =
+ blockBeingLifted.lastChild &&
+ blockBeingLifted.lastChild.type === groupType; // change 2
+
+ tr.step(
+ new ReplaceAroundStep(
+ end - (nestedAfter ? 2 : 1), // change 2: go deeper when merging into existing children
+ endOfList,
+ end,
+ endOfList,
+ new Slice(
+ Fragment.from(
+ itemType.create(null, groupType.create()), // change 3
+ ),
+ nestedAfter ? 2 : 1, // change 2: open deeper when merging into existing children
+ 0,
),
- nestedBefore ? 3 : 1,
- 0,
- );
-
- const before = range.start;
- const after = range.end;
- dispatch(
- state.tr
- .step(
- new ReplaceAroundStep(
- before - (nestedBefore ? 3 : 1),
- after,
- before,
- after,
- slice,
- 1,
- true,
- ),
- )
- .scrollIntoView(),
- );
- }
- return true;
- };
+ nestedAfter ? 0 : 1, // change 2: Slice.insertAt offsets by openStart, so 0+2=2 lands inside existing bg
+ true,
+ ),
+ );
+ range = new NodeRange(
+ tr.doc.resolve(range.$from.pos),
+ tr.doc.resolve(endOfList),
+ range.depth,
+ );
+ }
+
+ const target = liftTarget(range);
+ if (target == null) {
+ return false;
+ }
+
+ tr.lift(range, target);
+
+ const $after = tr.doc.resolve(tr.mapping.map(end, -1) - 1);
+ if (
+ canJoin(tr.doc, $after.pos) &&
+ $after.nodeBefore!.type === $after.nodeAfter!.type
+ ) {
+ tr.join($after.pos);
+ }
+
+ tr.scrollIntoView();
+ return true;
}
-export function nestBlock(editor: BlockNoteEditor) {
- return editor.exec((state, dispatch) =>
- sinkListItem(
- state.schema.nodes["blockContainer"],
- state.schema.nodes["blockGroup"],
- )(state, dispatch),
+/**
+ * Modified version of prosemirror-schema-list's liftListItem.
+ * https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.ts
+ *
+ * Changes from the original:
+ * 1. Range predicate checks node.type instead of firstChild.type (same as sinkItem)
+ * 2. Passes groupType to liftToOuterList
+ * 3. Operates on Transaction directly instead of state+dispatch
+ * 4. Skips liftOutOfList (root-level blocks can't be unnested in BlockNote)
+ */
+export function liftItem(
+ tr: Transaction,
+ itemType: NodeType,
+ groupType: NodeType, // change 2
+) {
+ const { $from, $to } = tr.selection;
+ const range = $from.blockRange(
+ $to,
+ (node) =>
+ node.childCount > 0 &&
+ (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
);
+ if (!range) {
+ return false;
+ }
+
+ if ($from.node(range.depth - 1).type === itemType) {
+ // Inside a parent node
+ return liftToOuterList(tr, itemType, groupType, range); // change 2
+ }
+
+ // This is the "liftOutOfList" path — lifting out of a list entirely.
+ // Not applicable to BlockNote (root-level blocks can't be unnested). // change 4
+ return false;
}
export function unnestBlock(editor: BlockNoteEditor) {
- editor._tiptapEditor.commands.liftListItem("blockContainer");
+ return editor.transact((tr) =>
+ liftItem(
+ tr,
+ editor.pmSchema.nodes["blockContainer"],
+ editor.pmSchema.nodes["blockGroup"],
+ ),
+ );
}
export function canNestBlock(editor: BlockNoteEditor) {
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap b/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap
index 04e9a71328..d876b31175 100644
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap
@@ -113,6 +113,7 @@ exports[`Test replaceBlocks > Remove multiple consecutive blocks 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -459,6 +460,7 @@ exports[`Test replaceBlocks > Remove multiple consecutive blocks 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -468,7 +470,7 @@ exports[`Test replaceBlocks > Remove multiple consecutive blocks 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -662,6 +664,7 @@ exports[`Test replaceBlocks > Remove multiple non-consecutive blocks 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -764,7 +767,7 @@ exports[`Test replaceBlocks > Remove multiple non-consecutive blocks 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -958,6 +961,7 @@ exports[`Test replaceBlocks > Remove single block 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1304,6 +1308,7 @@ exports[`Test replaceBlocks > Remove single block 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1313,7 +1318,7 @@ exports[`Test replaceBlocks > Remove single block 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1488,6 +1493,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with multiple
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1834,6 +1840,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with multiple
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1843,7 +1850,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with multiple
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1978,6 +1985,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single ba
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2324,6 +2332,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single ba
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2333,7 +2342,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single ba
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2408,6 +2417,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single co
"id": "inserted-heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2525,6 +2535,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single co
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2871,6 +2882,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single co
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2880,7 +2892,7 @@ exports[`Test replaceBlocks > Replace multiple consecutive blocks with single co
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3125,6 +3137,7 @@ exports[`Test replaceBlocks > Replace multiple non-consecutive blocks with multi
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3227,7 +3240,7 @@ exports[`Test replaceBlocks > Replace multiple non-consecutive blocks with multi
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3432,6 +3445,7 @@ exports[`Test replaceBlocks > Replace multiple non-consecutive blocks with singl
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3534,7 +3548,7 @@ exports[`Test replaceBlocks > Replace multiple non-consecutive blocks with singl
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3609,6 +3623,7 @@ exports[`Test replaceBlocks > Replace multiple non-consecutive blocks with singl
"id": "inserted-heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3796,6 +3811,7 @@ exports[`Test replaceBlocks > Replace multiple non-consecutive blocks with singl
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3898,7 +3914,7 @@ exports[`Test replaceBlocks > Replace multiple non-consecutive blocks with singl
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -4143,6 +4159,7 @@ exports[`Test replaceBlocks > Replace single block with multiple 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -4489,6 +4506,7 @@ exports[`Test replaceBlocks > Replace single block with multiple 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -4498,7 +4516,7 @@ exports[`Test replaceBlocks > Replace single block with multiple 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -4703,6 +4721,7 @@ exports[`Test replaceBlocks > Replace single block with single basic 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -5049,6 +5068,7 @@ exports[`Test replaceBlocks > Replace single block with single basic 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -5058,7 +5078,7 @@ exports[`Test replaceBlocks > Replace single block with single basic 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -5133,6 +5153,7 @@ exports[`Test replaceBlocks > Replace single block with single complex 1`] = `
"id": "inserted-heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -5320,6 +5341,7 @@ exports[`Test replaceBlocks > Replace single block with single complex 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -5666,6 +5688,7 @@ exports[`Test replaceBlocks > Replace single block with single complex 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -5675,7 +5698,7 @@ exports[`Test replaceBlocks > Replace single block with single complex 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
index afa97afe04..f1e946f909 100644
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
@@ -1,5 +1,5 @@
-import type { Node } from "prosemirror-model";
-import type { Transaction } from "prosemirror-state";
+import { type Node } from "prosemirror-model";
+import { type Transaction } from "prosemirror-state";
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type {
BlockIdentifier,
@@ -10,6 +10,7 @@ import type {
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getPmSchema } from "../../../pmUtil.js";
+import { fixColumnList } from "./util/fixColumnList.js";
export function removeAndInsertBlocks<
BSchema extends BlockSchema,
@@ -26,9 +27,11 @@ export function removeAndInsertBlocks<
const pmSchema = getPmSchema(tr);
// Converts the `PartialBlock`s to ProseMirror nodes to insert them into the
// document.
- const nodesToInsert: Node[] = blocksToInsert.map((block) =>
- blockToNode(block, pmSchema),
- );
+ const nodesToInsert: Node[] = blocksToInsert.map((block) => {
+ const node = blockToNode(block, pmSchema);
+ node.check(); // `blockToNode` is lenient; validate before mutating the doc
+ return node;
+ });
const idsOfBlocksToRemove = new Set(
blocksToRemove.map((block) =>
@@ -36,6 +39,7 @@ export function removeAndInsertBlocks<
),
);
const removedBlocks: Block[] = [];
+ const columnListPositions = new Set();
const idOfFirstBlock =
typeof blocksToRemove[0] === "string"
@@ -70,26 +74,35 @@ export function removeAndInsertBlocks<
}
const oldDocSize = tr.doc.nodeSize;
- // Checks if the block is the only child of its parent. In this case, we
- // need to delete the parent `blockGroup` node instead of just the
- // `blockContainer`.
+
const $pos = tr.doc.resolve(pos - removedSize);
+
+ if ($pos.node().type.name === "column") {
+ columnListPositions.add($pos.before(-1));
+ } else if ($pos.node().type.name === "columnList") {
+ columnListPositions.add($pos.before());
+ }
+
if (
$pos.node().type.name === "blockGroup" &&
$pos.node($pos.depth - 1).type.name !== "doc" &&
$pos.node().childCount === 1
) {
+ // Checks if the block is the only child of a parent `blockGroup` node.
+ // In this case, we need to delete the parent `blockGroup` node instead
+ // of just the `blockContainer`.
tr.delete($pos.before(), $pos.after());
} else {
tr.delete(pos - removedSize, pos - removedSize + node.nodeSize);
}
+
const newDocSize = tr.doc.nodeSize;
removedSize += oldDocSize - newDocSize;
return false;
});
- // Throws an error if now all blocks could be found.
+ // Throws an error if not all blocks could be found.
if (idsOfBlocksToRemove.size > 0) {
const notFoundIds = [...idsOfBlocksToRemove].join("\n");
@@ -99,10 +112,12 @@ export function removeAndInsertBlocks<
);
}
+ columnListPositions.forEach((pos) => fixColumnList(tr, pos));
+
// Converts the nodes created from `blocksToInsert` into full `Block`s.
const insertedBlocks = nodesToInsert.map((node) =>
nodeToBlock(node, pmSchema),
- );
+ ) as Block[];
return { insertedBlocks, removedBlocks };
}
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
new file mode 100644
index 0000000000..3097851f47
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
@@ -0,0 +1,173 @@
+import { Slice, type Node } from "prosemirror-model";
+import { type Transaction } from "prosemirror-state";
+import { ReplaceAroundStep } from "prosemirror-transform";
+
+/**
+ * Checks if a `column` node is empty, i.e. if it has only a single empty
+ * paragraph.
+ * @param column The column to check.
+ * @returns Whether the column is empty.
+ */
+export function isEmptyColumn(column: Node) {
+ if (!column || column.type.name !== "column") {
+ throw new Error("Invalid columnPos: does not point to column node.");
+ }
+
+ const blockContainer = column.firstChild;
+ if (!blockContainer) {
+ throw new Error("Invalid column: does not have child node.");
+ }
+
+ const blockContent = blockContainer.firstChild;
+ if (!blockContent) {
+ throw new Error("Invalid blockContainer: does not have child node.");
+ }
+
+ return (
+ column.childCount === 1 &&
+ blockContainer.childCount === 1 &&
+ blockContent.type.name === "paragraph" &&
+ blockContent.content.content.length === 0
+ );
+}
+
+/**
+ * Removes all empty `column` nodes in a `columnList`. A `column` node is empty
+ * if it has only a single empty block. If, however, removing the `column`s
+ * leaves the `columnList` that has fewer than two, ProseMirror will re-add
+ * empty columns.
+ * @param tr The `Transaction` to add the changes to.
+ * @param columnListPos The position just before the `columnList` node.
+ */
+export function removeEmptyColumns(tr: Transaction, columnListPos: number) {
+ const $columnListPos = tr.doc.resolve(columnListPos);
+ const columnList = $columnListPos.nodeAfter;
+ if (!columnList || columnList.type.name !== "columnList") {
+ throw new Error(
+ "Invalid columnListPos: does not point to columnList node.",
+ );
+ }
+
+ for (
+ let columnIndex = columnList.childCount - 1;
+ columnIndex >= 0;
+ columnIndex--
+ ) {
+ const columnPos = tr.doc
+ .resolve($columnListPos.pos + 1)
+ .posAtIndex(columnIndex);
+ const $columnPos = tr.doc.resolve(columnPos);
+ const column = $columnPos.nodeAfter;
+ if (!column || column.type.name !== "column") {
+ throw new Error("Invalid columnPos: does not point to column node.");
+ }
+
+ if (isEmptyColumn(column)) {
+ tr.delete(columnPos, columnPos + column.nodeSize);
+ }
+ }
+}
+
+/**
+ * Fixes potential issues in a `columnList` node after a
+ * `blockContainer`/`column` node is (re)moved from it:
+ *
+ * - Removes all empty `column` nodes. A `column` node is empty if it has only
+ * a single empty block.
+ * - If all but one `column` nodes are empty, replaces the `columnList` with
+ * the content of the non-empty `column`.
+ * - If all `column` nodes are empty, removes the `columnList` entirely.
+ * @param tr The `Transaction` to add the changes to.
+ * @param columnListPos
+ * @returns The position just before the `columnList` node.
+ */
+export function fixColumnList(tr: Transaction, columnListPos: number) {
+ removeEmptyColumns(tr, columnListPos);
+
+ const $columnListPos = tr.doc.resolve(columnListPos);
+ const columnList = $columnListPos.nodeAfter;
+ if (!columnList || columnList.type.name !== "columnList") {
+ throw new Error(
+ "Invalid columnListPos: does not point to columnList node.",
+ );
+ }
+
+ if (columnList.childCount > 2) {
+ // Do nothing if the `columnList` has more than two non-empty `column`s. In
+ // the case that the `columnList` has exactly two columns, we may need to
+ // still remove it, as it's possible that one or both columns are empty.
+ // This is because after `removeEmptyColumns` is called, if the
+ // `columnList` has fewer than two `column`s, ProseMirror will re-add empty
+ // `column`s until there are two total, in order to fit the schema.
+ return;
+ }
+
+ if (columnList.childCount < 2) {
+ // Throw an error if the `columnList` has fewer than two columns. After
+ // `removeEmptyColumns` is called, if the `columnList` has fewer than two
+ // `column`s, ProseMirror will re-add empty `column`s until there are two
+ // total, in order to fit the schema. So if there are fewer than two here,
+ // either the schema, or ProseMirror's internals, must have changed.
+ throw new Error("Invalid columnList: contains fewer than two children.");
+ }
+
+ const firstColumnBeforePos = columnListPos + 1;
+ const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos);
+ const firstColumn = $firstColumnBeforePos.nodeAfter;
+
+ const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1;
+ const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos);
+ const lastColumn = $lastColumnAfterPos.nodeBefore;
+
+ if (!firstColumn || !lastColumn) {
+ throw new Error("Invalid columnList: does not contain children.");
+ }
+
+ const firstColumnEmpty = isEmptyColumn(firstColumn);
+ const lastColumnEmpty = isEmptyColumn(lastColumn);
+
+ if (firstColumnEmpty && lastColumnEmpty) {
+ // Removes `columnList`
+ tr.delete(columnListPos, columnListPos + columnList.nodeSize);
+
+ return;
+ }
+
+ if (firstColumnEmpty) {
+ tr.step(
+ new ReplaceAroundStep(
+ // Replaces `columnList`.
+ columnListPos,
+ columnListPos + columnList.nodeSize,
+ // Replaces with content of last `column`.
+ lastColumnAfterPos - lastColumn.nodeSize + 1,
+ lastColumnAfterPos - 1,
+ // Doesn't append anything.
+ Slice.empty,
+ 0,
+ false,
+ ),
+ );
+
+ return;
+ }
+
+ if (lastColumnEmpty) {
+ tr.step(
+ new ReplaceAroundStep(
+ // Replaces `columnList`.
+ columnListPos,
+ columnListPos + columnList.nodeSize,
+ // Replaces with content of first `column`.
+ firstColumnBeforePos + 1,
+ firstColumnBeforePos + firstColumn.nodeSize - 1,
+ // Doesn't append anything.
+ Slice.empty,
+ 0,
+ false,
+ ),
+ );
+
+ return;
+ }
+}
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap
index b6adcef363..8cd297eaee 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap
@@ -217,6 +217,7 @@ exports[`Test splitBlocks > Basic 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -563,6 +564,7 @@ exports[`Test splitBlocks > Basic 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -572,7 +574,7 @@ exports[`Test splitBlocks > Basic 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -800,6 +802,7 @@ exports[`Test splitBlocks > Block has children 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1146,6 +1149,7 @@ exports[`Test splitBlocks > Block has children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1155,7 +1159,7 @@ exports[`Test splitBlocks > Block has children 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1383,6 +1387,7 @@ exports[`Test splitBlocks > Don't keep props 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1729,6 +1734,7 @@ exports[`Test splitBlocks > Don't keep props 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -1738,7 +1744,7 @@ exports[`Test splitBlocks > Don't keep props 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1949,6 +1955,7 @@ exports[`Test splitBlocks > Don't keep type 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2312,6 +2319,7 @@ exports[`Test splitBlocks > Don't keep type 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2321,7 +2329,7 @@ exports[`Test splitBlocks > Don't keep type 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2543,6 +2551,7 @@ exports[`Test splitBlocks > End of content 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2889,6 +2898,7 @@ exports[`Test splitBlocks > End of content 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2898,7 +2908,7 @@ exports[`Test splitBlocks > End of content 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3109,6 +3119,7 @@ exports[`Test splitBlocks > Keep type 1`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3127,6 +3138,7 @@ exports[`Test splitBlocks > Keep type 1`] = `
"id": "0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3473,6 +3485,7 @@ exports[`Test splitBlocks > Keep type 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3482,7 +3495,7 @@ exports[`Test splitBlocks > Keep type 1`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
index 6a08ae4254..1e73471d23 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
@@ -1,9 +1,10 @@
-import { EditorState } from "prosemirror-state";
+import { EditorState, Transaction } from "prosemirror-state";
import {
getBlockInfo,
getNearestBlockPos,
} from "../../../getBlockInfoFromPos.js";
+import { getPmSchema } from "../../../pmUtil.js";
export const splitBlockCommand = (
posInBlock: number,
@@ -17,33 +18,41 @@ export const splitBlockCommand = (
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
- const nearestBlockContainerPos = getNearestBlockPos(state.doc, posInBlock);
-
- const info = getBlockInfo(nearestBlockContainerPos);
-
- if (!info.isBlockContainer) {
- throw new Error(
- `BlockContainer expected when calling splitBlock, position ${posInBlock}`,
- );
- }
-
- const types = [
- {
- type: info.bnBlock.node.type, // always keep blockcontainer type
- attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
- },
- {
- type: keepType
- ? info.blockContent.node.type
- : state.schema.nodes["paragraph"],
- attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
- },
- ];
-
if (dispatch) {
- state.tr.split(posInBlock, 2, types);
+ return splitBlockTr(state.tr, posInBlock, keepType, keepProps);
}
return true;
};
};
+
+export const splitBlockTr = (
+ tr: Transaction,
+ posInBlock: number,
+ keepType?: boolean,
+ keepProps?: boolean,
+): boolean => {
+ const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock);
+
+ const info = getBlockInfo(nearestBlockContainerPos);
+
+ if (!info.isBlockContainer) {
+ return false;
+ }
+ const schema = getPmSchema(tr);
+
+ const types = [
+ {
+ type: info.bnBlock.node.type, // always keep blockcontainer type
+ attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
+ },
+ {
+ type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"],
+ attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
+ },
+ ];
+
+ tr.split(posInBlock, 2, types);
+
+ return true;
+};
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap
index 4849b8bebe..e4559884da 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap
@@ -63,6 +63,7 @@ exports[`Test updateBlock > Revert all props 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -271,6 +272,7 @@ exports[`Test updateBlock > Revert all props 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -617,6 +619,7 @@ exports[`Test updateBlock > Revert all props 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -626,7 +629,7 @@ exports[`Test updateBlock > Revert all props 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -700,6 +703,7 @@ exports[`Test updateBlock > Revert single prop 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 1,
"textAlignment": "center",
"textColor": "red",
@@ -908,6 +912,7 @@ exports[`Test updateBlock > Revert single prop 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1254,6 +1259,7 @@ exports[`Test updateBlock > Revert single prop 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 1,
"textAlignment": "center",
"textColor": "red",
@@ -1263,7 +1269,7 @@ exports[`Test updateBlock > Revert single prop 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1337,6 +1343,7 @@ exports[`Test updateBlock > Update all props 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "blue",
+ "isToggleable": false,
"level": 3,
"textAlignment": "right",
"textColor": "blue",
@@ -1545,6 +1552,7 @@ exports[`Test updateBlock > Update all props 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -1891,6 +1899,7 @@ exports[`Test updateBlock > Update all props 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "blue",
+ "isToggleable": false,
"level": 3,
"textAlignment": "right",
"textColor": "blue",
@@ -1900,7 +1909,7 @@ exports[`Test updateBlock > Update all props 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -1974,6 +1983,7 @@ exports[`Test updateBlock > Update children 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2182,6 +2192,7 @@ exports[`Test updateBlock > Update children 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -2528,6 +2539,7 @@ exports[`Test updateBlock > Update children 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -2537,7 +2549,7 @@ exports[`Test updateBlock > Update children 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -2762,6 +2774,7 @@ exports[`Test updateBlock > Update inline content to no content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -3108,6 +3121,7 @@ exports[`Test updateBlock > Update inline content to no content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -3117,7 +3131,7 @@ exports[`Test updateBlock > Update inline content to no content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -3684,6 +3698,7 @@ exports[`Test updateBlock > Update inline content to table content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -4030,6 +4045,7 @@ exports[`Test updateBlock > Update inline content to table content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -4039,7 +4055,7 @@ exports[`Test updateBlock > Update inline content to table content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -4264,6 +4280,7 @@ exports[`Test updateBlock > Update no content to empty inline content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -4607,6 +4624,7 @@ exports[`Test updateBlock > Update no content to empty inline content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -4616,7 +4634,7 @@ exports[`Test updateBlock > Update no content to empty inline content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -4845,6 +4863,7 @@ exports[`Test updateBlock > Update no content to empty table content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -5188,6 +5207,7 @@ exports[`Test updateBlock > Update no content to empty table content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -5197,7 +5217,7 @@ exports[`Test updateBlock > Update no content to empty table content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -5428,6 +5448,7 @@ exports[`Test updateBlock > Update no content to inline content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -5777,6 +5798,7 @@ exports[`Test updateBlock > Update no content to inline content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -5786,7 +5808,7 @@ exports[`Test updateBlock > Update no content to inline content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -6185,6 +6207,7 @@ exports[`Test updateBlock > Update no content to table content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -6702,6 +6725,7 @@ exports[`Test updateBlock > Update no content to table content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -6711,7 +6735,2833 @@ exports[`Test updateBlock > Update no content to table content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update partial (offset start + end) 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " without styles and with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update partial (offset start) 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " without styles",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update partial (props + offset end) 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Title with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update partial (table cell) 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "updated cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update partial (table row) 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "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",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with children",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-children",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph with props",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-props",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Paragraph",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-with-styled-content",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 4",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Heading 1",
+ "type": "text",
+ },
+ ],
+ "id": "heading-0",
+ "props": {
+ "backgroundColor": "default",
+ "isToggleable": false,
+ "level": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 5",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-5",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": undefined,
+ "id": "image-0",
+ "props": {
+ "backgroundColor": "default",
+ "caption": "",
+ "name": "",
+ "showPreview": true,
+ "textAlignment": "left",
+ "url": "https://via.placeholder.com/150",
+ },
+ "type": "image",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 6",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-6",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": {
+ "columnWidths": [
+ undefined,
+ undefined,
+ undefined,
+ ],
+ "headerCols": undefined,
+ "headerRows": undefined,
+ "rows": [
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "updated cell 1",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "updated cell 2",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "updated cell 3",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 4",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 5",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 6",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ {
+ "cells": [
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 7",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 8",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ {
+ "content": [
+ {
+ "styles": {},
+ "text": "Cell 9",
+ "type": "text",
+ },
+ ],
+ "props": {
+ "backgroundColor": "default",
+ "colspan": 1,
+ "rowspan": 1,
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "tableCell",
+ },
+ ],
+ },
+ ],
+ "type": "tableContent",
+ },
+ "id": "table-0",
+ "props": {
+ "textColor": "default",
+ },
+ "type": "table",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 7",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-7",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "empty-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 8",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-8",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Double Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "double-nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": [
+ {
+ "styles": {
+ "bold": true,
+ },
+ "text": "Heading",
+ "type": "text",
+ },
+ {
+ "styles": {},
+ "text": " with styled ",
+ "type": "text",
+ },
+ {
+ "styles": {
+ "italic": true,
+ },
+ "text": "content",
+ "type": "text",
+ },
+ ],
+ "id": "heading-with-everything",
+ "props": {
+ "backgroundColor": "red",
+ "isToggleable": false,
+ "level": 2,
+ "textAlignment": "center",
+ "textColor": "red",
+ },
+ "type": "heading",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -6785,6 +9635,7 @@ exports[`Test updateBlock > Update single prop 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 3,
"textAlignment": "center",
"textColor": "red",
@@ -6993,6 +9844,7 @@ exports[`Test updateBlock > Update single prop 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -7339,6 +10191,7 @@ exports[`Test updateBlock > Update single prop 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 3,
"textAlignment": "center",
"textColor": "red",
@@ -7348,7 +10201,7 @@ exports[`Test updateBlock > Update single prop 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -7573,6 +10426,7 @@ exports[`Test updateBlock > Update table content to empty inline content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -7745,6 +10599,7 @@ exports[`Test updateBlock > Update table content to empty inline content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -7754,7 +10609,7 @@ exports[`Test updateBlock > Update table content to empty inline content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -7985,6 +10840,7 @@ exports[`Test updateBlock > Update table content to inline content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -8163,6 +11019,7 @@ exports[`Test updateBlock > Update table content to inline content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -8172,7 +11029,7 @@ exports[`Test updateBlock > Update table content to inline content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -8400,6 +11257,7 @@ exports[`Test updateBlock > Update table content to no content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -8575,6 +11433,7 @@ exports[`Test updateBlock > Update table content to no content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -8584,7 +11443,7 @@ exports[`Test updateBlock > Update table content to no content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -8865,6 +11724,7 @@ exports[`Test updateBlock > Update type 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -9219,7 +12079,7 @@ exports[`Test updateBlock > Update type 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -9279,6 +12139,7 @@ exports[`Test updateBlock > Update with plain content 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -9487,6 +12348,7 @@ exports[`Test updateBlock > Update with plain content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -9819,6 +12681,7 @@ exports[`Test updateBlock > Update with plain content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -9828,7 +12691,7 @@ exports[`Test updateBlock > Update with plain content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
@@ -9902,6 +12765,7 @@ exports[`Test updateBlock > Update with styled content 1`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -10110,6 +12974,7 @@ exports[`Test updateBlock > Update with styled content 2`] = `
"id": "heading-0",
"props": {
"backgroundColor": "default",
+ "isToggleable": false,
"level": 1,
"textAlignment": "left",
"textColor": "default",
@@ -10456,6 +13321,7 @@ exports[`Test updateBlock > Update with styled content 2`] = `
"id": "heading-with-everything",
"props": {
"backgroundColor": "red",
+ "isToggleable": false,
"level": 2,
"textAlignment": "center",
"textColor": "red",
@@ -10465,7 +13331,7 @@ exports[`Test updateBlock > Update with styled content 2`] = `
{
"children": [],
"content": [],
- "id": "trailing-paragraph",
+ "id": "paragraph-9",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
index 0cc82a15a2..95ea4dc4e7 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
+import { getBlockInfo } from "../../../getBlockInfoFromPos.js";
+import { getNodeById } from "../../../nodeUtil.js";
import { setupTestEnv } from "../../setupTestEnv.js";
import { updateBlock } from "./updateBlock.js";
@@ -173,6 +175,162 @@ describe("Test updateBlock", () => {
expect(getEditor().document).toMatchSnapshot();
});
+ it("Update partial (offset start)", () => {
+ const info = getBlockInfo(
+ getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
+ );
+
+ if (!info.isBlockContainer) {
+ throw new Error("heading-with-everything is not a block container");
+ }
+
+ getEditor().transact((tr) =>
+ updateBlock(
+ tr,
+ "heading-with-everything",
+ {
+ content: [
+ {
+ type: "text",
+ text: "without styles",
+ styles: {},
+ },
+ ],
+ },
+ info.blockContent.beforePos + 9,
+ ),
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update partial (offset start + end)", () => {
+ const info = getBlockInfo(
+ getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
+ );
+
+ if (!info.isBlockContainer) {
+ throw new Error("heading-with-everything is not a block container");
+ }
+
+ getEditor().transact((tr) =>
+ updateBlock(
+ tr,
+ "heading-with-everything",
+ {
+ content: [
+ {
+ type: "text",
+ text: "without styles and ",
+ styles: {},
+ },
+ ],
+ },
+ info.blockContent.beforePos + 9,
+ info.blockContent.beforePos + 9,
+ ),
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update partial (props + offset end)", () => {
+ const info = getBlockInfo(
+ getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
+ );
+
+ if (!info.isBlockContainer) {
+ throw new Error("heading-with-everything is not a block container");
+ }
+
+ getEditor().transact((tr) => {
+ updateBlock(
+ tr,
+ "heading-with-everything",
+ {
+ props: {
+ level: 1,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Title",
+ styles: {},
+ },
+ ],
+ },
+ undefined,
+ info.blockContent.beforePos + 8,
+ );
+ });
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update partial (table cell)", () => {
+ const info = getBlockInfo(
+ getNodeById("table-0", getEditor().prosemirrorState.doc)!,
+ );
+
+ if (!info.isBlockContainer) {
+ throw new Error("table-0 is not a block container");
+ }
+
+ const cell = info.blockContent.node.resolve(2);
+
+ getEditor().transact((tr) =>
+ updateBlock(
+ tr,
+ "table-0",
+ {
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [{ cells: ["updated cell 1"] }],
+ },
+ },
+ info.blockContent.beforePos + 2,
+ info.blockContent.beforePos + 2 + cell.node().nodeSize,
+ ),
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update partial (table row)", () => {
+ const info = getBlockInfo(
+ getNodeById("table-0", getEditor().prosemirrorState.doc)!,
+ );
+
+ if (!info.isBlockContainer) {
+ throw new Error("table-0 is not a block container");
+ }
+
+ const cell = info.blockContent.node.resolve(1);
+
+ getEditor().transact((tr) =>
+ updateBlock(
+ tr,
+ "table-0",
+ {
+ type: "table",
+ content: {
+ type: "tableContent",
+ rows: [
+ {
+ cells: ["updated cell 1", "updated cell 2", "updated cell 3"],
+ },
+ ],
+ },
+ },
+ info.blockContent.beforePos + 1,
+ info.blockContent.beforePos + 1 + cell.node().nodeSize,
+ ),
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
it("Update children", () => {
expect(
getEditor().transact((tr) =>
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
index 8510f8974d..a3e2b3b0db 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
@@ -4,9 +4,10 @@ import {
type Node as PMNode,
Slice,
} from "prosemirror-model";
-import type { Transaction } from "prosemirror-state";
+import { TextSelection, Transaction } from "prosemirror-state";
+import { TableMap } from "prosemirror-tables";
+import { ReplaceStep, Transform } from "prosemirror-transform";
-import { ReplaceStep } from "prosemirror-transform";
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type {
BlockIdentifier,
@@ -28,6 +29,7 @@ import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
import { getPmSchema } from "../../../pmUtil.js";
+// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
export const updateBlockCommand = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -50,18 +52,34 @@ export const updateBlockCommand = <
};
};
-const updateBlockTr = <
+export function updateBlockTr<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(
- tr: Transaction,
+ tr: Transform | Transaction,
posBeforeBlock: number,
block: PartialBlock,
-) => {
+ replaceFromPos?: number,
+ replaceToPos?: number,
+) {
const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock));
+ let cellAnchor: CellAnchor | null = null;
+ if (blockInfo.blockNoteType === "table") {
+ cellAnchor = captureCellAnchor(tr);
+ }
+
const pmSchema = getPmSchema(tr);
+
+ if (
+ replaceFromPos !== undefined &&
+ replaceToPos !== undefined &&
+ replaceFromPos > replaceToPos
+ ) {
+ throw new Error("Invalid replaceFromPos or replaceToPos");
+ }
+
// Adds blockGroup node with child blocks if necessary.
const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType];
@@ -71,10 +89,32 @@ const updateBlockTr = <
: pmSchema.nodes["blockContainer"];
if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) {
+ const replaceFromOffset =
+ replaceFromPos !== undefined &&
+ replaceFromPos > blockInfo.blockContent.beforePos &&
+ replaceFromPos < blockInfo.blockContent.afterPos
+ ? replaceFromPos - blockInfo.blockContent.beforePos - 1
+ : undefined;
+
+ const replaceToOffset =
+ replaceToPos !== undefined &&
+ replaceToPos > blockInfo.blockContent.beforePos &&
+ replaceToPos < blockInfo.blockContent.afterPos
+ ? replaceToPos - blockInfo.blockContent.beforePos - 1
+ : undefined;
+
updateChildren(block, tr, blockInfo);
// The code below determines the new content of the block.
// or "keep" to keep as-is
- updateBlockContentNode(block, tr, oldNodeType, newNodeType, blockInfo);
+ updateBlockContentNode(
+ block,
+ tr,
+ oldNodeType,
+ newNodeType,
+ blockInfo,
+ replaceFromOffset,
+ replaceToOffset,
+ );
} else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) {
updateChildren(block, tr, blockInfo);
// old node was a bnBlock type (like column or columnList) and new block as well
@@ -88,16 +128,18 @@ const updateBlockTr = <
// for this, we do a nodeToBlock on the existing block to get the children.
// it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case
const existingBlock = nodeToBlock(blockInfo.bnBlock.node, pmSchema);
+ const replacementNode = blockToNode(
+ {
+ children: existingBlock.children, // if no children are passed in, use existing children
+ ...block,
+ },
+ pmSchema,
+ );
+ replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc
tr.replaceWith(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
- blockToNode(
- {
- children: existingBlock.children, // if no children are passed in, use existing children
- ...block,
- },
- pmSchema,
- ),
+ replacementNode,
);
return;
@@ -109,7 +151,11 @@ const updateBlockTr = <
...blockInfo.bnBlock.node.attrs,
...block.props,
});
-};
+
+ if (cellAnchor) {
+ restoreCellAnchor(tr, blockInfo, cellAnchor);
+ }
+}
function updateBlockContentNode<
BSchema extends BlockSchema,
@@ -117,7 +163,7 @@ function updateBlockContentNode<
S extends StyleSchema,
>(
block: PartialBlock,
- tr: Transaction,
+ tr: Transform,
oldNodeType: NodeType,
newNodeType: NodeType,
blockInfo: {
@@ -126,6 +172,8 @@ function updateBlockContentNode<
| undefined;
blockContent: { node: PMNode; beforePos: number; afterPos: number };
},
+ replaceFromOffset?: number,
+ replaceToOffset?: number,
) {
const pmSchema = getPmSchema(tr);
let content: PMNode[] | "keep" = "keep";
@@ -172,17 +220,43 @@ function updateBlockContentNode<
// content is being replaced or not.
if (content === "keep") {
// use setNodeMarkup to only update the type and attributes
- tr.setNodeMarkup(
- blockInfo.blockContent.beforePos,
- block.type === undefined ? undefined : pmSchema.nodes[block.type],
- {
- ...blockInfo.blockContent.node.attrs,
- ...block.props,
- },
+ tr.setNodeMarkup(blockInfo.blockContent.beforePos, newNodeType, {
+ ...blockInfo.blockContent.node.attrs,
+ ...block.props,
+ });
+ } else if (replaceFromOffset !== undefined || replaceToOffset !== undefined) {
+ // first update markup of the containing node
+ tr.setNodeMarkup(blockInfo.blockContent.beforePos, newNodeType, {
+ ...blockInfo.blockContent.node.attrs,
+ ...block.props,
+ });
+
+ const start =
+ blockInfo.blockContent.beforePos + 1 + (replaceFromOffset ?? 0);
+ const end =
+ blockInfo.blockContent.beforePos +
+ 1 +
+ (replaceToOffset ?? blockInfo.blockContent.node.content.size);
+
+ // for content like table cells (where the blockcontent has nested PM nodes),
+ // we need to figure out the correct openStart and openEnd for the slice when replacing
+
+ const contentDepth = tr.doc.resolve(blockInfo.blockContent.beforePos).depth;
+ const startDepth = tr.doc.resolve(start).depth;
+ const endDepth = tr.doc.resolve(end).depth;
+
+ tr.replace(
+ start,
+ end,
+ new Slice(
+ Fragment.from(content),
+ startDepth - contentDepth - 1,
+ endDepth - contentDepth - 1,
+ ),
);
} else {
// use replaceWith to replace the content and the block itself
- // also reset the selection since replacing the block content
+ // also reset the selection since replacing the block content
// sets it to the next block.
tr.replaceWith(
blockInfo.blockContent.beforePos,
@@ -202,11 +276,13 @@ function updateChildren<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
->(block: PartialBlock, tr: Transaction, blockInfo: BlockInfo) {
+>(block: PartialBlock, tr: Transform, blockInfo: BlockInfo) {
const pmSchema = getPmSchema(tr);
if (block.children !== undefined && block.children.length > 0) {
const childNodes = block.children.map((child) => {
- return blockToNode(child, pmSchema);
+ const node = blockToNode(child, pmSchema);
+ node.check(); // `blockToNode` is lenient; validate before mutating the doc
+ return node;
});
// Checks if a blockGroup node already exists.
@@ -239,9 +315,11 @@ export function updateBlock<
I extends InlineContentSchema = any,
S extends StyleSchema = any,
>(
- tr: Transaction,
+ tr: Transform,
blockToUpdate: BlockIdentifier,
update: PartialBlock,
+ replaceFromPos?: number,
+ replaceToPos?: number,
): Block {
const id =
typeof blockToUpdate === "string" ? blockToUpdate : blockToUpdate.id;
@@ -250,7 +328,13 @@ export function updateBlock<
throw new Error(`Block with ID ${id} not found`);
}
- updateBlockTr(tr, posInfo.posBeforeNode, update);
+ updateBlockTr(
+ tr,
+ posInfo.posBeforeNode,
+ update,
+ replaceFromPos,
+ replaceToPos,
+ );
const blockContainerNode = tr.doc
.resolve(posInfo.posBeforeNode + 1) // TODO: clean?
@@ -259,3 +343,121 @@ export function updateBlock<
const pmSchema = getPmSchema(tr);
return nodeToBlock(blockContainerNode, pmSchema);
}
+
+type CellAnchor = { row: number; col: number; offset: number };
+
+/**
+ * Captures the cell anchor from the current selection.
+ * @param tr - The transaction to capture the cell anchor from.
+ *
+ * @returns The cell anchor, or null if no cell is selected.
+ */
+export function captureCellAnchor(tr: Transform): CellAnchor | null {
+ const sel = "selection" in tr ? tr.selection : null;
+ if (!(sel instanceof TextSelection)) {
+ return null;
+ }
+
+ const $head = tr.doc.resolve(sel.head);
+ // Find enclosing cell and table
+ let cellDepth = -1;
+ let tableDepth = -1;
+ for (let d = $head.depth; d >= 0; d--) {
+ const name = $head.node(d).type.name;
+ if (cellDepth < 0 && (name === "tableCell" || name === "tableHeader")) {
+ cellDepth = d;
+ }
+ if (name === "table") {
+ tableDepth = d;
+ break;
+ }
+ }
+ if (cellDepth < 0 || tableDepth < 0) {
+ return null;
+ }
+
+ // Absolute positions (before the cell)
+ const cellPos = $head.before(cellDepth);
+ const tablePos = $head.before(tableDepth);
+ const table = tr.doc.nodeAt(tablePos);
+ if (!table || table.type.name !== "table") {
+ return null;
+ }
+
+ // Visual grid position via TableMap (handles spans)
+ const map = TableMap.get(table);
+ const rel = cellPos - (tablePos + 1); // relative to inside table
+ const idx = map.map.indexOf(rel);
+ if (idx < 0) {
+ return null;
+ }
+
+ const row = Math.floor(idx / map.width);
+ const col = idx % map.width;
+
+ // Caret offset relative to the start of paragraph text
+ const paraPos = cellPos + 1; // pos BEFORE tableParagraph
+ const textStart = paraPos + 1; // start of paragraph text
+ const offset = Math.max(0, sel.head - textStart);
+
+ return { row, col, offset };
+}
+
+function restoreCellAnchor(
+ tr: Transform | Transaction,
+ blockInfo: BlockInfo,
+ a: CellAnchor,
+): boolean {
+ if (blockInfo.blockNoteType !== "table") {
+ return false;
+ }
+
+ // 1) Resolve the table node in the current document
+ let tablePos = -1;
+
+ if (blockInfo.isBlockContainer) {
+ // Prefer the blockContent position when available (points directly at the PM table node)
+ tablePos = tr.mapping.map(blockInfo.blockContent.beforePos);
+ } else {
+ // Fallback: scan within the mapped bnBlock range to find the inner table node
+ const start = tr.mapping.map(blockInfo.bnBlock.beforePos);
+ const end = start + (tr.doc.nodeAt(start)?.nodeSize || 0);
+ tr.doc.nodesBetween(start, end, (node, pos) => {
+ if (node.type.name === "table") {
+ tablePos = pos;
+ return false;
+ }
+ return true;
+ });
+ }
+
+ const table = tablePos >= 0 ? tr.doc.nodeAt(tablePos) : null;
+ if (!table || table.type.name !== "table") {
+ return false;
+ }
+
+ // 2) Clamp row/col to the table’s current grid
+ const map = TableMap.get(table);
+ const row = Math.max(0, Math.min(a.row, map.height - 1));
+ const col = Math.max(0, Math.min(a.col, map.width - 1));
+
+ // 3) Compute the absolute position of the target cell (pos BEFORE the cell)
+ const cellIndex = row * map.width + col;
+ const relCellPos = map.map[cellIndex]; // relative to (tablePos + 1)
+ if (relCellPos == null) {
+ return false;
+ }
+ const cellPos = tablePos + 1 + relCellPos;
+
+ // 4) Place the caret inside the cell, clamping the text offset
+ const textPos = cellPos + 1;
+ const textNode = tr.doc.nodeAt(textPos);
+ const textStart = textPos + 1;
+ const max = textNode ? textNode.content.size : 0;
+ const head = textStart + Math.max(0, Math.min(a.offset, max));
+
+ if ("selection" in tr) {
+ tr.setSelection(TextSelection.create(tr.doc, head));
+ }
+ return true;
+}
diff --git a/packages/core/src/api/blockManipulation/selections/__snapshots__/selection.test.ts.snap b/packages/core/src/api/blockManipulation/selections/__snapshots__/selection.test.ts.snap
deleted file mode 100644
index b5bde19afc..0000000000
--- a/packages/core/src/api/blockManipulation/selections/__snapshots__/selection.test.ts.snap
+++ /dev/null
@@ -1,844 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`Test getSelection & setSelection > Basic 1`] = `
-{
- "blocks": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 0",
- "type": "text",
- },
- ],
- "id": "paragraph-0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
-}
-`;
-
-exports[`Test getSelection & setSelection > Contains block with children 1`] = `
-{
- "blocks": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [
- {
- "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",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with children",
- "type": "text",
- },
- ],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 2",
- "type": "text",
- },
- ],
- "id": "paragraph-2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
-}
-`;
-
-exports[`Test getSelection & setSelection > Ends in block with children 1`] = `
-{
- "blocks": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [
- {
- "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",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with children",
- "type": "text",
- },
- ],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
-}
-`;
-
-exports[`Test getSelection & setSelection > Ends in nested block 1`] = `
-{
- "blocks": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [
- {
- "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",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with children",
- "type": "text",
- },
- ],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
-}
-`;
-
-exports[`Test getSelection & setSelection > Ends in table 1`] = `
-{
- "blocks": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 6",
- "type": "text",
- },
- ],
- "id": "paragraph-6",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": {
- "columnWidths": [
- undefined,
- undefined,
- undefined,
- ],
- "headerCols": undefined,
- "headerRows": undefined,
- "rows": [
- {
- "cells": [
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 1",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 2",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 3",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- ],
- },
- {
- "cells": [
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 4",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 5",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 6",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- ],
- },
- {
- "cells": [
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 7",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 8",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 9",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- ],
- },
- ],
- "type": "tableContent",
- },
- "id": "table-0",
- "props": {
- "textColor": "default",
- },
- "type": "table",
- },
- ],
-}
-`;
-
-exports[`Test getSelection & setSelection > Starts in block with children 1`] = `
-{
- "blocks": [
- {
- "children": [
- {
- "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",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with children",
- "type": "text",
- },
- ],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 2",
- "type": "text",
- },
- ],
- "id": "paragraph-2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
-}
-`;
-
-exports[`Test getSelection & setSelection > Starts in nested block 1`] = `
-{
- "blocks": [
- {
- "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",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 2",
- "type": "text",
- },
- ],
- "id": "paragraph-2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
-}
-`;
-
-exports[`Test getSelection & setSelection > Starts in table 1`] = `
-{
- "blocks": [
- {
- "children": [],
- "content": {
- "columnWidths": [
- undefined,
- undefined,
- undefined,
- ],
- "headerCols": undefined,
- "headerRows": undefined,
- "rows": [
- {
- "cells": [
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 1",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 2",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 3",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- ],
- },
- {
- "cells": [
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 4",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 5",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 6",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- ],
- },
- {
- "cells": [
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 7",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 8",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- {
- "content": [
- {
- "styles": {},
- "text": "Cell 9",
- "type": "text",
- },
- ],
- "props": {
- "backgroundColor": "default",
- "colspan": 1,
- "rowspan": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "tableCell",
- },
- ],
- },
- ],
- "type": "tableContent",
- },
- "id": "table-0",
- "props": {
- "textColor": "default",
- },
- "type": "table",
- },
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 7",
- "type": "text",
- },
- ],
- "id": "paragraph-7",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
-}
-`;
diff --git a/packages/core/src/api/blockManipulation/selections/selection.test.ts b/packages/core/src/api/blockManipulation/selections/selection.test.ts
deleted file mode 100644
index d39869b4be..0000000000
--- a/packages/core/src/api/blockManipulation/selections/selection.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { setupTestEnv } from "../setupTestEnv.js";
-import { getSelection, setSelection } from "./selection.js";
-
-const getEditor = setupTestEnv();
-
-describe("Test getSelection & setSelection", () => {
- it("Basic", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "paragraph-0", "paragraph-1");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-
- it("Starts in block with children", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "paragraph-with-children", "paragraph-2");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-
- it("Starts in nested block", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "nested-paragraph-0", "paragraph-2");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-
- it("Ends in block with children", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "paragraph-1", "paragraph-with-children");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-
- it("Ends in nested block", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "paragraph-1", "nested-paragraph-0");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-
- it("Contains block with children", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "paragraph-1", "paragraph-2");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-
- it("Starts in table", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "table-0", "paragraph-7");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-
- it("Ends in table", () => {
- getEditor().transact((tr) => {
- setSelection(tr, "paragraph-6", "table-0");
- });
-
- expect(getEditor().transact((tr) => getSelection(tr))).toMatchSnapshot();
- });
-});
diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts
index a3019dd802..e5bd761918 100644
--- a/packages/core/src/api/blockManipulation/selections/selection.ts
+++ b/packages/core/src/api/blockManipulation/selections/selection.ts
@@ -1,6 +1,5 @@
import { TextSelection, type Transaction } from "prosemirror-state";
import { TableMap } from "prosemirror-tables";
-
import { Block } from "../../../blocks/defaultBlocks.js";
import { Selection } from "../../../editor/selectionTypes.js";
import {
@@ -9,8 +8,12 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
+import { expandPMRangeToWords } from "../../../util/expandToWords.js";
import { getBlockInfo, getNearestBlockPos } from "../../getBlockInfoFromPos.js";
-import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js";
+import {
+ nodeToBlock,
+ prosemirrorSliceToSlicedBlocks,
+} from "../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../nodeUtil.js";
import { getBlockNoteSchema, getPmSchema } from "../../pmUtil.js";
@@ -216,3 +219,52 @@ export function setSelection(
// restriction that the start/end blocks must have content.
tr.setSelection(TextSelection.create(tr.doc, startPos, endPos));
}
+
+export function getSelectionCutBlocks(tr: Transaction, expandToWords = false) {
+ // TODO: fix image node selection
+
+ const pmSchema = getPmSchema(tr);
+
+ const range = expandToWords
+ ? expandPMRangeToWords(tr.doc, tr.selection)
+ : tr.selection;
+
+ let start = range.$from;
+ let end = range.$to;
+
+ // the selection moves below are used to make sure `prosemirrorSliceToSlicedBlocks` returns
+ // the correct information about whether content is cut at the start or end of a block
+
+ // if the end is at the end of a node (|
) move it forward so we include all closing tags (|)
+ while (end.parentOffset >= end.parent.nodeSize - 2 && end.depth > 0) {
+ end = tr.doc.resolve(end.pos + 1);
+ }
+
+ // if the end is at the start of an empty node (|) move it backwards so we drop empty start tags (
|)
+ while (end.parentOffset === 0 && end.depth > 0) {
+ end = tr.doc.resolve(end.pos - 1);
+ }
+
+ // if the start is at the start of a node (|) 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/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
similarity index 89%
rename from packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts
rename to packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
index 19a890976c..83f5340698 100644
--- a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts
+++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
@@ -4,21 +4,21 @@ import {
TextSelection,
type Transaction,
} from "prosemirror-state";
-import type { TextCursorPosition } from "../../../../editor/cursorPositionTypes.js";
+import type { TextCursorPosition } from "../../../editor/cursorPositionTypes.js";
import type {
BlockIdentifier,
BlockSchema,
InlineContentSchema,
StyleSchema,
-} from "../../../../schema/index.js";
-import { UnreachableCaseError } from "../../../../util/typescript.js";
+} 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";
+} 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,
diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition/__snapshots__/textCursorPosition.test.ts.snap b/packages/core/src/api/blockManipulation/selections/textCursorPosition/__snapshots__/textCursorPosition.test.ts.snap
deleted file mode 100644
index 7e506a213e..0000000000
--- a/packages/core/src/api/blockManipulation/selections/textCursorPosition/__snapshots__/textCursorPosition.test.ts.snap
+++ /dev/null
@@ -1,316 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`Test getTextCursorPosition & setTextCursorPosition > Basic 1`] = `
-{
- "block": {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- "nextBlock": {
- "children": [
- {
- "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",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with children",
- "type": "text",
- },
- ],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- "parentBlock": undefined,
- "prevBlock": {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 0",
- "type": "text",
- },
- ],
- "id": "paragraph-0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
-}
-`;
-
-exports[`Test getTextCursorPosition & setTextCursorPosition > First block 1`] = `
-{
- "block": {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 0",
- "type": "text",
- },
- ],
- "id": "paragraph-0",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- "nextBlock": {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Paragraph 1",
- "type": "text",
- },
- ],
- "id": "paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- "parentBlock": undefined,
- "prevBlock": undefined,
-}
-`;
-
-exports[`Test getTextCursorPosition & setTextCursorPosition > Last block 1`] = `
-{
- "block": {
- "children": [],
- "content": [],
- "id": "trailing-paragraph",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- "nextBlock": undefined,
- "parentBlock": undefined,
- "prevBlock": {
- "children": [
- {
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Double Nested Paragraph 1",
- "type": "text",
- },
- ],
- "id": "double-nested-paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Nested Paragraph 1",
- "type": "text",
- },
- ],
- "id": "nested-paragraph-1",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
- "content": [
- {
- "styles": {
- "bold": true,
- },
- "text": "Heading",
- "type": "text",
- },
- {
- "styles": {},
- "text": " with styled ",
- "type": "text",
- },
- {
- "styles": {
- "italic": true,
- },
- "text": "content",
- "type": "text",
- },
- ],
- "id": "heading-with-everything",
- "props": {
- "backgroundColor": "red",
- "level": 2,
- "textAlignment": "center",
- "textColor": "red",
- },
- "type": "heading",
- },
-}
-`;
-
-exports[`Test getTextCursorPosition & setTextCursorPosition > Nested block 1`] = `
-{
- "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",
- },
- "nextBlock": undefined,
- "parentBlock": {
- "children": [
- {
- "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",
- },
- ],
- "content": [
- {
- "styles": {},
- "text": "Paragraph with children",
- "type": "text",
- },
- ],
- "id": "paragraph-with-children",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- "prevBlock": undefined,
-}
-`;
diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.test.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.test.ts
deleted file mode 100644
index 21f4578652..0000000000
--- a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { setupTestEnv } from "../../setupTestEnv.js";
-import {
- getTextCursorPosition,
- setTextCursorPosition,
-} from "./textCursorPosition.js";
-
-const getEditor = setupTestEnv();
-
-describe("Test getTextCursorPosition & setTextCursorPosition", () => {
- it("Basic", () => {
- getEditor().transact((tr) => {
- setTextCursorPosition(tr, "paragraph-1");
- });
-
- expect(
- getEditor().transact((tr) => getTextCursorPosition(tr)),
- ).toMatchSnapshot();
- });
-
- it("First block", () => {
- getEditor().transact((tr) => {
- setTextCursorPosition(tr, "paragraph-0");
- });
-
- expect(
- getEditor().transact((tr) => getTextCursorPosition(tr)),
- ).toMatchSnapshot();
- });
-
- it("Last block", () => {
- getEditor().transact((tr) => {
- setTextCursorPosition(tr, "trailing-paragraph");
- });
-
- expect(
- getEditor().transact((tr) => getTextCursorPosition(tr)),
- ).toMatchSnapshot();
- });
-
- it("Nested block", () => {
- getEditor().transact((tr) => {
- setTextCursorPosition(tr, "nested-paragraph-0");
- });
-
- expect(
- getEditor().transact((tr) => getTextCursorPosition(tr)),
- ).toMatchSnapshot();
- });
-
- it("Set to start", () => {
- getEditor().transact((tr) => {
- setTextCursorPosition(tr, "paragraph-1", "start");
- });
-
- expect(
- getEditor().transact((tr) => tr.selection.$from.parentOffset) === 0,
- ).toBeTruthy();
- });
-
- it("Set to end", () => {
- getEditor().transact((tr) => {
- setTextCursorPosition(tr, "paragraph-1", "end");
- });
-
- expect(
- getEditor().transact((tr) => tr.selection.$from.parentOffset) ===
- getEditor().transact(
- (tr) => tr.selection.$from.node().firstChild!.nodeSize,
- ),
- ).toBeTruthy();
- });
-});
diff --git a/packages/core/src/api/blockManipulation/setupTestEnv.ts b/packages/core/src/api/blockManipulation/setupTestEnv.ts
index 537847a810..bd1caf6300 100644
--- a/packages/core/src/api/blockManipulation/setupTestEnv.ts
+++ b/packages/core/src/api/blockManipulation/setupTestEnv.ts
@@ -13,7 +13,6 @@ export function setupTestEnv() {
});
afterAll(() => {
- editor.mount(undefined);
editor._tiptapEditor.destroy();
editor = undefined as any;
});
@@ -186,7 +185,7 @@ const testDocument: PartialBlock[] = [
],
},
{
- id: "trailing-paragraph",
+ id: "paragraph-9",
type: "paragraph",
},
];
diff --git a/packages/core/src/api/blockManipulation/tables/tables.test.ts b/packages/core/src/api/blockManipulation/tables/tables.test.ts
index 166076ef0f..b386ea211b 100644
--- a/packages/core/src/api/blockManipulation/tables/tables.test.ts
+++ b/packages/core/src/api/blockManipulation/tables/tables.test.ts
@@ -546,6 +546,140 @@ const tableWithColspansAndRowspans = {
any
>;
+const invalidTableShape = {
+ type: "table",
+ id: "table-0",
+ props: {
+ textColor: "default",
+ },
+ content: {
+ type: "tableContent",
+ columnWidths: [100, 100],
+ rows: [
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Table Cell",
+ styles: {},
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ },
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Table Cell",
+ styles: {},
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ },
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Table Cell",
+ styles: {},
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Table Cell",
+ styles: {},
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ props: {
+ colspan: 2,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Table x",
+ styles: {},
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ props: {
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ children: [],
+} satisfies Block<
+ {
+ table: DefaultBlockSchema["table"];
+ },
+ any,
+ any
+>;
+
/**
* Normal table
* | 1-1 | 1-2 | 1-3 | 1-4 |
@@ -882,6 +1016,12 @@ describe("Test getAbsoluteTableCellIndices", () => {
cell: tableWithComplexRowspansAndColspans.content.rows[2].cells[2],
});
});
+
+ it("should not crash at an invalid table shape", () => {
+ expect(() =>
+ getAbsoluteTableCells({ row: 2, col: 2 }, invalidTableShape),
+ ).not.toThrow();
+ });
});
describe("Test getRelativeTableCellIndices", () => {
diff --git a/packages/core/src/api/blockManipulation/tables/tables.ts b/packages/core/src/api/blockManipulation/tables/tables.ts
index f770a34afd..6e19d6f45e 100644
--- a/packages/core/src/api/blockManipulation/tables/tables.ts
+++ b/packages/core/src/api/blockManipulation/tables/tables.ts
@@ -305,9 +305,9 @@ export function getAbsoluteTableCells(
} {
for (let r = 0; r < occupancyGrid.length; r++) {
for (let c = 0; c < occupancyGrid[r].length; c++) {
- // console.log(r, c, occupancyGrid);
const cell = occupancyGrid[r][c];
if (
+ cell &&
cell.row === relativeCellIndices.row &&
cell.col === relativeCellIndices.col
) {
diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
index 588763e9a9..ced8f59b14 100644
--- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
@@ -2,7 +2,6 @@ import { Block, PartialBlock } from "../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
import {
BlockSchema,
- FileBlockConfig,
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
@@ -49,6 +48,7 @@ function insertOrUpdateBlock<
editor: BlockNoteEditor,
referenceBlock: Block,
newBlock: PartialBlock,
+ placement: "before" | "after" = "after",
) {
let insertedBlockId: string | undefined;
@@ -61,7 +61,7 @@ function insertOrUpdateBlock<
insertedBlockId = editor.insertBlocks(
[newBlock],
referenceBlock,
- "after",
+ placement,
)[0].id;
}
@@ -105,15 +105,12 @@ export async function handleFileInsertion<
event.preventDefault();
- const fileBlockConfigs = Object.values(editor.schema.blockSchema).filter(
- (blockConfig) => blockConfig.isFileBlock,
- ) as FileBlockConfig[];
-
for (let i = 0; i < items.length; i++) {
// Gets file block corresponding to MIME type.
let fileBlockType = "file";
- for (const fileBlockConfig of fileBlockConfigs) {
- for (const mimeType of fileBlockConfig.fileBlockAccept || []) {
+ for (const blockSpec of Object.values(editor.schema.blockSpecs)) {
+ for (const mimeType of blockSpec.implementation.meta?.fileBlockAccept ||
+ []) {
const isFileExtension = mimeType.startsWith(".");
const file = items[i].getAsFile();
@@ -128,7 +125,7 @@ export async function handleFileInsertion<
mimeType,
))
) {
- fileBlockType = fileBlockConfig.type;
+ fileBlockType = blockSpec.config.type;
break;
}
}
@@ -155,17 +152,27 @@ export async function handleFileInsertion<
top: (event as DragEvent).clientY,
};
- const pos = editor.prosemirrorView?.posAtCoords(coords);
+ const pos = editor.prosemirrorView.posAtCoords(coords);
+
if (!pos) {
return;
}
insertedBlockId = editor.transact((tr) => {
const posInfo = getNearestBlockPos(tr.doc, pos.pos);
+ const blockElement = editor.domElement?.querySelector(
+ `[data-id="${posInfo.node.attrs.id}"]`,
+ );
+
+ const blockRect = blockElement?.getBoundingClientRect();
+
return insertOrUpdateBlock(
editor,
editor.getBlock(posInfo.node.attrs.id)!,
fileBlock,
+ blockRect && (blockRect.top + blockRect.bottom) / 2 > coords.top
+ ? "before"
+ : "after",
);
});
} else {
diff --git a/packages/core/src/api/clipboard/fromClipboard/handleVSCodePaste.ts b/packages/core/src/api/clipboard/fromClipboard/handleVSCodePaste.ts
index b566dfdbe2..ffb298544f 100644
--- a/packages/core/src/api/clipboard/fromClipboard/handleVSCodePaste.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/handleVSCodePaste.ts
@@ -1,9 +1,6 @@
import { EditorView } from "prosemirror-view";
-export async function handleVSCodePaste(
- event: ClipboardEvent,
- view: EditorView,
-) {
+export function handleVSCodePaste(event: ClipboardEvent, view: EditorView) {
const { schema } = view.state;
if (!event.clipboardData) {
@@ -17,8 +14,7 @@ export async function handleVSCodePaste(
}
if (!schema.nodes.codeBlock) {
- view.pasteText(text);
- return true;
+ return false;
}
const vscode = event.clipboardData!.getData("vscode-editor-data");
diff --git a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
index a0eb03889e..9fa4ed3c55 100644
--- a/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/pasteExtension.ts
@@ -57,8 +57,13 @@ function defaultPasteHandler({
}
if (format === "vscode-editor-data") {
- handleVSCodePaste(event, editor.prosemirrorView!);
- return true;
+ // If VSCode clipboard data cannot be parsed as a code block, try parsing
+ // `text/plain` as a fallback.
+ if (handleVSCodePaste(event, editor.prosemirrorView)) {
+ return true;
+ }
+
+ format = "text/plain";
}
if (format === "Files") {
diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
index e5f4fec1bd..e150af1309 100644
--- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
+++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
@@ -94,7 +94,7 @@ function fragmentToExternalHTML<
);
externalHTML = externalHTMLExporter.exportInlineContent(ic, {});
} else {
- const blocks = fragmentToBlocks(selectedFragment);
+ const blocks = fragmentToBlocks(selectedFragment);
externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
}
return externalHTML;
@@ -140,16 +140,29 @@ export function selectedFragmentToHTML<
editor,
);
- const markdown = cleanHTMLToMarkdown(externalHTML);
+ // Code blocks are treated differently for copying: text/plain is the raw
+ // selected text instead of markdown.
+ const { $from, $to } = view.state.selection;
+ const parentBlockType = $from.parent.type.name;
+ const parentBlockSpec = editor.blockImplementations[parentBlockType as any];
+ const isPurelyInsideCodeBlock =
+ $from.sameParent($to) &&
+ parentBlockSpec?.implementation.meta?.code === true;
+
+ const markdown = isPurelyInsideCodeBlock
+ ? view.state.doc.textBetween($from.pos, $to.pos)
+ : cleanHTMLToMarkdown(externalHTML);
return { clipboardHTML, externalHTML, markdown };
}
-const checkIfSelectionInNonEditableBlock = () => {
- // Let browser handle event if selection is empty (nothing
- // happens).
- const selection = window.getSelection();
- if (!selection || selection.isCollapsed) {
+const checkIfSelectionInNonEditableBlock = (view: EditorView) => {
+ // Use ProseMirror's internal selection state to check for empty selection.
+ // window.getSelection() returns null or a collapsed selection inside Shadow
+ // DOM (Firefox, Safari, and Chromium edge cases), causing this guard to
+ // misfire and silently skip clipboard writes. view.state.selection is always
+ // accurate regardless of DOM mode.
+ if (view.state.selection.empty) {
return true;
}
@@ -158,16 +171,19 @@ const checkIfSelectionInNonEditableBlock = () => {
// non-editable block. We only need to check one node as it's
// not possible for the browser selection to start in an
// editable block and end in a non-editable one.
- let node = selection.focusNode;
- while (node) {
- if (
- node instanceof HTMLElement &&
- node.getAttribute("contenteditable") === "false"
- ) {
- return true;
+ const selection = window.getSelection();
+ if (selection && !selection.isCollapsed) {
+ let node = selection.focusNode;
+ while (node) {
+ if (
+ node instanceof HTMLElement &&
+ node.getAttribute("contenteditable") === "false"
+ ) {
+ return true;
+ }
+
+ node = node.parentElement;
}
-
- node = node.parentElement;
}
return false;
@@ -213,7 +229,7 @@ export const createCopyToClipboardExtension = <
props: {
handleDOMEvents: {
copy(view, event) {
- if (checkIfSelectionInNonEditableBlock()) {
+ if (checkIfSelectionInNonEditableBlock(view)) {
return true;
}
@@ -222,7 +238,7 @@ export const createCopyToClipboardExtension = <
return true;
},
cut(view, event) {
- if (checkIfSelectionInNonEditableBlock()) {
+ if (checkIfSelectionInNonEditableBlock(view)) {
return true;
}
diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
index 715dded6ad..2149c884e7 100644
--- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts
+++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts
@@ -28,7 +28,6 @@ import {
// 4. The HTML is wrapped in a single `div` element.
// Needs to be sync because it's used in drag handler event (SideMenuPlugin)
-// Ideally, call `await initializeESMDependencies()` before calling this function
export const createExternalHTMLExporter = <
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -49,7 +48,7 @@ export const createExternalHTMLExporter = <
blocks,
serializer,
new Set(["numberedListItem"]),
- new Set(["bulletListItem", "checkListItem"]),
+ new Set(["bulletListItem", "checkListItem", "toggleListItem"]),
options,
);
const div = document.createElement("div");
diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
index 5b3003cf55..33376b2835 100644
--- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
+++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts
@@ -1,5 +1,7 @@
import { DOMSerializer, Schema } from "prosemirror-model";
+
import { PartialBlock } from "../../../blocks/defaultBlocks.js";
+import { EMPTY_CELL_WIDTH } from "../../../blocks/index.js";
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import {
BlockSchema,
@@ -7,6 +9,123 @@ import {
StyleSchema,
} from "../../../schema/index.js";
import { serializeBlocksInternalHTML } from "./util/serializeBlocksInternalHTML.js";
+
+// This is normally handled using decorations in the
+// `NumberedListIndexingDecorationPlugin`. This does not run when exporting, so
+// we have to add the necessary HTML attributes ourselves.
+const addIndexToNumberedListItems = (element: HTMLElement) => {
+ const numberedListItems = element.querySelectorAll(
+ '[data-content-type="numberedListItem"]',
+ );
+ numberedListItems.forEach((numberedListItem) => {
+ const prevNumberedListItem = numberedListItem
+ .closest(".bn-block-outer")
+ ?.previousElementSibling?.querySelector(
+ '[data-content-type="numberedListItem"]',
+ );
+
+ if (!prevNumberedListItem) {
+ numberedListItem.setAttribute(
+ "data-index",
+ numberedListItem.getAttribute("data-start") || "1",
+ );
+ } else {
+ const prevNumberedListItemIndex =
+ prevNumberedListItem.getAttribute("data-index");
+ numberedListItem.setAttribute(
+ "data-index",
+ (parseInt(prevNumberedListItemIndex || "0") + 1).toString(),
+ );
+ }
+ });
+
+ return element;
+};
+
+// Makes the checkboxes in check list items read-only, as the HTML should be
+// static and therefore read-only when rendered.
+const makeCheckListItemsReadOnly = (element: HTMLElement) => {
+ const checkboxes: NodeListOf = element.querySelectorAll(
+ '[data-content-type="checkListItem"] input',
+ );
+ checkboxes.forEach((checkbox) => {
+ checkbox.disabled = true;
+ });
+
+ return element;
+};
+
+// Forces toggle blocks (toggle headings, toggle list items) to be expanded.
+// This is because event listeners for the toggle button are lost when
+// serializing HTML elements to a string, so the button no longer works if the
+// HTML string is rendered out.
+const forceToggleBlocksShow = (element: HTMLElement) => {
+ const hiddenToggleWrappers = element.querySelectorAll(
+ '.bn-toggle-wrapper[data-show-children="false"]',
+ );
+ hiddenToggleWrappers.forEach((toggleWrapper) => {
+ toggleWrapper.setAttribute("data-show-children", "true");
+ });
+
+ return element;
+};
+
+// Adds minimum cell widths, which would normally be done by the
+// `columnResizing` extension. This extension doesn't run when exporting to
+// HTML, so we have to add this manually.
+const addTableMinCellWidths = (element: HTMLElement) => {
+ const tables = element.querySelectorAll('[data-content-type="table"] table');
+ tables.forEach((table) => {
+ table.setAttribute(
+ "style",
+ `--default-cell-min-width: ${EMPTY_CELL_WIDTH}px;`,
+ );
+ table.setAttribute("data-show-children", "true");
+ });
+
+ return element;
+};
+
+// Adds table wrapping elements, which would normally be done by the
+// `columnResizing` extension. This extension doesn't run when exporting to
+// HTML, so we have to add this manually. This adds the correct padding to
+// tables.
+const addTableWrappers = (element: HTMLElement) => {
+ const tables = element.querySelectorAll('[data-content-type="table"] table');
+ tables.forEach((table) => {
+ const tableWrapper = document.createElement("div");
+ tableWrapper.className = "tableWrapper";
+ const tableWrapperInner = document.createElement("div");
+ tableWrapperInner.className = "tableWrapper-inner";
+
+ tableWrapper.appendChild(tableWrapperInner);
+ table.parentElement?.appendChild(tableWrapper);
+ tableWrapper.appendChild(table);
+ });
+
+ return element;
+};
+
+// Adds trailing breaks to blocks with empty inline content. This is normally
+// done by ProseMirror, but only when rendering an actual editor. Without them,
+// empty inline content has a height of 0.
+const addTrailingBreakToEmptyInlineContent = (element: HTMLElement) => {
+ const emptyInlineContent = element.querySelectorAll(
+ ".bn-inline-content:empty",
+ );
+ emptyInlineContent.forEach((inlineContent) => {
+ // We actually use a `span` instead of a `br` to avoid potential false
+ // positives when parsing.
+ const trailingBreak = document.createElement("span");
+ trailingBreak.className = "ProseMirror-trailingBreak";
+ trailingBreak.setAttribute("style", "display: inline-block;");
+
+ inlineContent.appendChild(trailingBreak);
+ });
+
+ return element;
+};
+
// Used to serialize BlockNote blocks and ProseMirror nodes to HTML without
// losing data. Blocks are exported using the `toInternalHTML` method in their
// `blockSpec`.
@@ -26,13 +145,38 @@ export const createInternalHTMLSerializer = <
) => {
const serializer = DOMSerializer.fromSchema(schema);
+ // Set of transforms to run on the output HTML element after serializing
+ // blocks. These are used to add HTML elements, attributes, or class names
+ // which would normally be done by extensions and plugins. Since these don't
+ // run when converting blocks to HTML, tranforms are used to mock their
+ // functionality so that the rendered HTML looks identical to that of a live
+ // editor.
+ const transforms: ((element: HTMLElement) => HTMLElement)[] = [
+ addIndexToNumberedListItems,
+ makeCheckListItemsReadOnly,
+ forceToggleBlocksShow,
+ addTableMinCellWidths,
+ addTableWrappers,
+ addTrailingBreakToEmptyInlineContent,
+ ];
+
return {
serializeBlocks: (
blocks: PartialBlock[],
options: { document?: Document },
) => {
- return serializeBlocksInternalHTML(editor, blocks, serializer, options)
- .outerHTML;
+ let element = serializeBlocksInternalHTML(
+ editor,
+ blocks,
+ serializer,
+ options,
+ );
+
+ for (const transform of transforms) {
+ element = transform(element);
+ }
+
+ return element.outerHTML;
},
};
};
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
index f74757c8d7..72569ffced 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
@@ -1,8 +1,9 @@
-import { DOMSerializer, Fragment } from "prosemirror-model";
+import { DOMSerializer, Fragment, Node } from "prosemirror-model";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
+ BlockImplementation,
BlockSchema,
InlineContentSchema,
StyleSchema,
@@ -12,6 +13,7 @@ import {
inlineContentToNodes,
tableContentToNodes,
} from "../../../nodeConversions/blockToNode.js";
+import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";
function addAttributesAndRemoveClasses(element: HTMLElement) {
// Removes all BlockNote specific class names.
@@ -35,33 +37,129 @@ export function serializeInlineContentExternalHTML<
editor: BlockNoteEditor,
blockContent: PartialBlock["content"],
serializer: DOMSerializer,
- options?: { document?: Document },
+ options?: { document?: Document; blockType?: string },
) {
- let nodes: any;
+ let nodes: Node[];
// TODO: reuse function from nodeconversions?
if (!blockContent) {
throw new Error("blockContent is required");
} else if (typeof blockContent === "string") {
- nodes = inlineContentToNodes([blockContent], editor.pmSchema);
+ nodes = inlineContentToNodes(
+ [blockContent],
+ editor.pmSchema,
+ options?.blockType,
+ );
} else if (Array.isArray(blockContent)) {
- nodes = inlineContentToNodes(blockContent, editor.pmSchema);
+ nodes = inlineContentToNodes(
+ blockContent,
+ editor.pmSchema,
+ options?.blockType,
+ );
} else if (blockContent.type === "tableContent") {
nodes = tableContentToNodes(blockContent, editor.pmSchema);
} else {
throw new UnreachableCaseError(blockContent.type);
}
- // We call the prosemirror serializer here because it handles Marks and Inline Content nodes nicely.
- // If we'd want to support custom serialization or externalHTML for Inline Content, we'd have to implement
- // a custom serializer here.
- const dom = serializer.serializeFragment(Fragment.from(nodes), options);
+ // Check if any of the nodes are custom inline content with toExternalHTML
+ const doc = options?.document ?? document;
+ const fragment = doc.createDocumentFragment();
+
+ for (const node of nodes) {
+ // Check if this is a custom inline content node with toExternalHTML
+ if (
+ node.type.name !== "text" &&
+ editor.schema.inlineContentSchema[node.type.name]
+ ) {
+ const inlineContentImplementation =
+ editor.schema.inlineContentSpecs[node.type.name].implementation;
+
+ if (inlineContentImplementation) {
+ // Convert the node to inline content format
+ const inlineContent = nodeToCustomInlineContent(
+ node,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema,
+ );
+
+ // Use the custom toExternalHTML method or fallback to `render`
+ const output = inlineContentImplementation.toExternalHTML
+ ? inlineContentImplementation.toExternalHTML(
+ inlineContent as any,
+ editor as any,
+ )
+ : inlineContentImplementation.render.call(
+ {
+ renderType: "dom",
+ props: undefined,
+ },
+ inlineContent as any,
+ () => {
+ // No-op
+ },
+ editor as any,
+ );
+
+ if (output) {
+ fragment.appendChild(output.dom);
+
+ // If contentDOM exists, render the inline content into it
+ if (output.contentDOM) {
+ const contentFragment = serializer.serializeFragment(
+ node.content,
+ options,
+ );
+ output.contentDOM.dataset.editable = "";
+ output.contentDOM.appendChild(contentFragment);
+ }
+ continue;
+ }
+ }
+ } else if (node.type.name === "text") {
+ // We serialize text nodes manually as we need to serialize the styles/
+ // marks using `styleSpec.implementation.render`. When left up to
+ // ProseMirror, it'll use `toDOM` which is incorrect.
+ let dom: globalThis.Node | Text = document.createTextNode(
+ node.textContent,
+ );
+ // Reverse the order of marks to maintain the correct priority.
+ for (const mark of node.marks.toReversed()) {
+ if (mark.type.name in editor.schema.styleSpecs) {
+ const newDom = (
+ editor.schema.styleSpecs[mark.type.name].implementation
+ .toExternalHTML ??
+ editor.schema.styleSpecs[mark.type.name].implementation.render
+ )(mark.attrs["stringValue"], editor);
+ newDom.contentDOM!.appendChild(dom);
+ dom = newDom.dom;
+ } else {
+ const domOutputSpec = mark.type.spec.toDOM!(mark, true);
+ const newDom = DOMSerializer.renderSpec(document, domOutputSpec);
+ newDom.contentDOM!.appendChild(dom);
+ dom = newDom.dom;
+ }
+ }
- if (dom.nodeType === 1 /* Node.ELEMENT_NODE */) {
- addAttributesAndRemoveClasses(dom as HTMLElement);
+ fragment.appendChild(dom);
+ } else {
+ // Fall back to default serialization for this node
+ const nodeFragment = serializer.serializeFragment(
+ Fragment.from([node]),
+ options,
+ );
+ fragment.appendChild(nodeFragment);
+ }
+ }
+
+ if (
+ fragment.childNodes.length === 1 &&
+ fragment.firstChild?.nodeType === 1 /* Node.ELEMENT_NODE */
+ ) {
+ addAttributesAndRemoveClasses(fragment.firstChild as HTMLElement);
}
- return dom;
+ return fragment;
}
/**
@@ -80,21 +178,19 @@ function serializeBlock<
serializer: DOMSerializer,
orderedListItemBlockTypes: Set,
unorderedListItemBlockTypes: Set,
+ nestingLevel: number,
options?: { document?: Document },
) {
const doc = options?.document ?? document;
const BC_NODE = editor.pmSchema.nodes["blockContainer"];
- let props = block.props;
// set default props in case we were passed a partial block
- if (!block.props) {
- props = {};
- for (const [name, spec] of Object.entries(
- editor.schema.blockSchema[block.type as any].propSchema,
- )) {
- if (spec.default !== undefined) {
- (props as any)[name] = spec.default;
- }
+ const props = block.props || {};
+ for (const [name, spec] of Object.entries(
+ editor.schema.blockSchema[block.type as any].propSchema,
+ )) {
+ if (!(name in props) && spec.default !== undefined) {
+ (props as any)[name] = spec.default;
}
}
@@ -112,15 +208,29 @@ function serializeBlock<
// we should change toExternalHTML so that this is not necessary
const attrs = Array.from(bc.dom.attributes);
- const ret = editor.blockImplementations[
- block.type as any
- ].implementation.toExternalHTML({ ...block, props } as any, editor as any);
+ const blockImplementation = editor.blockImplementations[block.type as any]
+ .implementation as BlockImplementation;
+ const ret =
+ blockImplementation.toExternalHTML?.call(
+ {},
+ { ...block, props } as any,
+ editor as any,
+ {
+ nestingLevel,
+ },
+ ) ||
+ blockImplementation.render.call(
+ {},
+ { ...block, props } as any,
+ editor as any,
+ );
const elementFragment = doc.createDocumentFragment();
- if (ret.dom.classList.contains("bn-block-content")) {
+
+ if ((ret.dom as HTMLElement).classList.contains("bn-block-content")) {
const blockContentDataAttributes = [
...attrs,
- ...Array.from(ret.dom.attributes),
+ ...Array.from((ret.dom as HTMLElement).attributes),
].filter(
(attr) =>
attr.name.startsWith("data") &&
@@ -129,7 +239,6 @@ function serializeBlock<
attr.name !== "data-node-view-wrapper" &&
attr.name !== "data-node-type" &&
attr.name !== "data-id" &&
- attr.name !== "data-index" &&
attr.name !== "data-editable",
);
@@ -139,9 +248,21 @@ function serializeBlock<
}
addAttributesAndRemoveClasses(ret.dom.firstChild! as HTMLElement);
+ if (nestingLevel > 0) {
+ (ret.dom.firstChild! as HTMLElement).setAttribute(
+ "data-nesting-level",
+ nestingLevel.toString(),
+ );
+ }
elementFragment.append(...Array.from(ret.dom.childNodes));
} else {
elementFragment.append(ret.dom);
+ if (nestingLevel > 0) {
+ (ret.dom as HTMLElement).setAttribute(
+ "data-nesting-level",
+ nestingLevel.toString(),
+ );
+ }
}
if (ret.contentDOM && block.content) {
@@ -149,7 +270,7 @@ function serializeBlock<
editor,
block.content as any, // TODO
serializer,
- options,
+ { ...options, blockType: block.type },
);
ret.contentDOM.appendChild(ic);
@@ -166,14 +287,17 @@ function serializeBlock<
if (fragment.lastChild?.nodeName !== listType) {
const list = doc.createElement(listType);
- if (listType === "OL" && props?.start && props?.start !== 1) {
+ if (
+ listType === "OL" &&
+ "start" in props &&
+ props.start &&
+ props?.start !== 1
+ ) {
list.setAttribute("start", props.start + "");
}
fragment.append(list);
}
- const li = doc.createElement("li");
- li.append(elementFragment);
- fragment.lastChild!.appendChild(li);
+ fragment.lastChild!.appendChild(elementFragment);
} else {
fragment.append(elementFragment);
}
@@ -187,6 +311,7 @@ function serializeBlock<
serializer,
orderedListItemBlockTypes,
unorderedListItemBlockTypes,
+ nestingLevel + 1,
options,
);
if (
@@ -202,7 +327,13 @@ function serializeBlock<
}
}
- if (editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")) {
+ if ("childrenDOM" in ret && ret.childrenDOM) {
+ // block specifies where children should go (e.g. toggle blocks
+ // place children inside )
+ ret.childrenDOM.append(childFragment);
+ } else if (
+ editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")
+ ) {
// default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment
fragment.append(childFragment);
} else {
@@ -223,6 +354,7 @@ const serializeBlocksToFragment = <
serializer: DOMSerializer,
orderedListItemBlockTypes: Set,
unorderedListItemBlockTypes: Set,
+ nestingLevel = 0,
options?: { document?: Document },
) => {
for (const block of blocks) {
@@ -233,6 +365,7 @@ const serializeBlocksToFragment = <
serializer,
orderedListItemBlockTypes,
unorderedListItemBlockTypes,
+ nestingLevel,
options,
);
}
@@ -260,6 +393,7 @@ export const serializeBlocksExternalHTML = <
serializer,
orderedListItemBlockTypes,
unorderedListItemBlockTypes,
+ 0,
options,
);
return fragment;
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
index 0bd7722172..0f890b77ab 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
@@ -1,4 +1,4 @@
-import { DOMSerializer, Fragment } from "prosemirror-model";
+import { DOMSerializer, Fragment, Node } from "prosemirror-model";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
@@ -13,6 +13,7 @@ import {
tableContentToNodes,
} from "../../../nodeConversions/blockToNode.js";
+import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";
export function serializeInlineContentInternalHTML<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -24,7 +25,7 @@ export function serializeInlineContentInternalHTML<
blockType?: string,
options?: { document?: Document },
) {
- let nodes: any;
+ let nodes: Node[];
// TODO: reuse function from nodeconversions?
if (!blockContent) {
@@ -39,12 +40,90 @@ export function serializeInlineContentInternalHTML<
throw new UnreachableCaseError(blockContent.type);
}
- // We call the prosemirror serializer here because it handles Marks and Inline Content nodes nicely.
- // If we'd want to support custom serialization or externalHTML for Inline Content, we'd have to implement
- // a custom serializer here.
- const dom = serializer.serializeFragment(Fragment.from(nodes), options);
+ // Check if any of the nodes are custom inline content with toExternalHTML
+ const doc = options?.document ?? document;
+ const fragment = doc.createDocumentFragment();
+
+ for (const node of nodes) {
+ // Check if this is a custom inline content node with toExternalHTML
+ if (
+ node.type.name !== "text" &&
+ editor.schema.inlineContentSchema[node.type.name]
+ ) {
+ const inlineContentImplementation =
+ editor.schema.inlineContentSpecs[node.type.name].implementation;
+
+ if (inlineContentImplementation) {
+ // Convert the node to inline content format
+ const inlineContent = nodeToCustomInlineContent(
+ node,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema,
+ );
+
+ // Use the custom toExternalHTML method
+ const output = inlineContentImplementation.render.call(
+ {
+ renderType: "dom",
+ props: undefined,
+ },
+ inlineContent as any,
+ () => {
+ // No-op
+ },
+ editor as any,
+ );
+
+ if (output) {
+ fragment.appendChild(output.dom);
+
+ // If contentDOM exists, render the inline content into it
+ if (output.contentDOM) {
+ const contentFragment = serializer.serializeFragment(
+ node.content,
+ options,
+ );
+ output.contentDOM.dataset.editable = "";
+ output.contentDOM.appendChild(contentFragment);
+ }
+ continue;
+ }
+ }
+ } else if (node.type.name === "text") {
+ // We serialize text nodes manually as we need to serialize the styles/
+ // marks using `styleSpec.implementation.render`. When left up to
+ // ProseMirror, it'll use `toDOM` which is incorrect.
+ let dom: globalThis.Node | Text = document.createTextNode(
+ node.textContent,
+ );
+ // Reverse the order of marks to maintain the correct priority.
+ for (const mark of node.marks.toReversed()) {
+ if (mark.type.name in editor.schema.styleSpecs) {
+ const newDom = editor.schema.styleSpecs[
+ mark.type.name
+ ].implementation.render(mark.attrs["stringValue"], editor);
+ newDom.contentDOM!.appendChild(dom);
+ dom = newDom.dom;
+ } else {
+ const domOutputSpec = mark.type.spec.toDOM!(mark, true);
+ const newDom = DOMSerializer.renderSpec(document, domOutputSpec);
+ newDom.contentDOM!.appendChild(dom);
+ dom = newDom.dom;
+ }
+ }
- return dom;
+ fragment.appendChild(dom);
+ } else {
+ // Fall back to default serialization for this node
+ const nodeFragment = serializer.serializeFragment(
+ Fragment.from([node]),
+ options,
+ );
+ fragment.appendChild(nodeFragment);
+ }
+ }
+
+ return fragment;
}
function serializeBlock<
@@ -55,36 +134,30 @@ function serializeBlock<
editor: BlockNoteEditor,
block: PartialBlock,
serializer: DOMSerializer,
- listIndex: number,
options?: { document?: Document },
) {
const BC_NODE = editor.pmSchema.nodes["blockContainer"];
- let props = block.props;
// set default props in case we were passed a partial block
- if (!block.props) {
- props = {};
- for (const [name, spec] of Object.entries(
- editor.schema.blockSchema[block.type as any].propSchema,
- )) {
- if (spec.default !== undefined) {
- (props as any)[name] = spec.default;
- }
+ const props = block.props || {};
+ for (const [name, spec] of Object.entries(
+ editor.schema.blockSchema[block.type as any].propSchema,
+ )) {
+ if (!(name in props) && spec.default !== undefined) {
+ (props as any)[name] = spec.default;
}
}
+ const children = block.children || [];
const impl = editor.blockImplementations[block.type as any].implementation;
- const ret = impl.toInternalHTML({ ...block, props } as any, editor as any);
-
- if (block.type === "numberedListItem") {
- // This is a workaround to make sure there's a list index set.
- // Normally, this is set on the internal prosemirror nodes by the NumberedListIndexingPlugin,
- // but:
- // - (a) this information is not available on the Blocks passed to the serializer. (we only have access to BlockNote Blocks)
- // - (b) the NumberedListIndexingPlugin might not even have run, because we can manually call blocksToFullHTML
- // with blocks that are not part of the active document
- ret.dom.setAttribute("data-index", listIndex.toString());
- }
+ const ret = impl.render.call(
+ {
+ renderType: "dom",
+ props: undefined,
+ },
+ { ...block, props, children } as any,
+ editor as any,
+ );
if (ret.contentDOM && block.content) {
const ic = serializeInlineContentInternalHTML(
@@ -147,20 +220,8 @@ function serializeBlocks<
const doc = options?.document ?? document;
const fragment = doc.createDocumentFragment();
- let listIndex = 0;
for (const block of blocks) {
- if (block.type === "numberedListItem") {
- listIndex++;
- } else {
- listIndex = 0;
- }
- const blockDOM = serializeBlock(
- editor,
- block,
- serializer,
- listIndex,
- options,
- );
+ const blockDOM = serializeBlock(editor, block, serializer, options);
fragment.appendChild(blockDOM);
}
diff --git a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts
new file mode 100644
index 0000000000..7faa154dc6
--- /dev/null
+++ b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts
@@ -0,0 +1,813 @@
+/**
+ * Custom HTML-to-Markdown serializer for BlockNote.
+ * Replaces the unified/rehype-remark pipeline with a direct DOM-based implementation.
+ *
+ * Input: HTML string from createExternalHTMLExporter
+ * Output: GFM-compatible markdown string
+ */
+
+/**
+ * Convert an HTML string (from BlockNote's external HTML exporter) to markdown.
+ */
+export function htmlToMarkdown(html: string): string {
+ // Use a temporary element to parse HTML. This works in both browser and
+ // server (JSDOM) environments, unlike `new DOMParser()` which may not be
+ // globally available in Node.js.
+ const container = document.createElement("div");
+ container.innerHTML = html;
+ const result = serializeChildren(container, {
+ indent: "",
+ inListItem: false,
+ });
+ return result.trim() + "\n";
+}
+
+interface SerializeContext {
+ indent: string; // current indentation prefix for list nesting
+ // True when the current node is being serialized as continuation content
+ // of a parent list item. Used to suppress trailing blank lines that would
+ // otherwise turn the parent list into a "loose" list.
+ inListItem: boolean;
+}
+
+// ─── Main Serializer ─────────────────────────────────────────────────────────
+
+function serializeChildren(node: Node, ctx: SerializeContext): string {
+ let result = "";
+ const children = Array.from(node.childNodes);
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ result += serializeNode(child, ctx);
+ }
+
+ return result;
+}
+
+function serializeNode(node: Node, ctx: SerializeContext): string {
+ if (node.nodeType === 3 /* Node.TEXT_NODE */) {
+ return node.textContent || "";
+ }
+
+ if (node.nodeType !== 1 /* Node.ELEMENT_NODE */) {
+ return "";
+ }
+
+ const el = node as HTMLElement;
+ const tag = el.tagName.toLowerCase();
+
+ switch (tag) {
+ case "p":
+ return serializeParagraph(el, ctx);
+ case "h1":
+ case "h2":
+ case "h3":
+ case "h4":
+ case "h5":
+ case "h6":
+ return serializeHeading(el, ctx);
+ case "blockquote":
+ return serializeBlockquote(el, ctx);
+ case "pre":
+ return serializeCodeBlock(el, ctx);
+ case "ul":
+ return serializeUnorderedList(el, ctx);
+ case "ol":
+ return serializeOrderedList(el, ctx);
+ case "table":
+ return serializeTable(el, ctx);
+ case "hr":
+ return ctx.indent + "***\n\n";
+ case "img":
+ return serializeImage(el, ctx);
+ case "video":
+ return serializeVideo(el, ctx);
+ case "audio":
+ return serializeAudio(el, ctx);
+ case "embed":
+ return serializeEmbed(el, ctx);
+ case "figure":
+ return serializeFigure(el, ctx);
+ case "a":
+ // Block-level link (file block)
+ return serializeBlockLink(el, ctx);
+ case "details":
+ return serializeDetails(el, ctx);
+ case "div":
+ // Page break or generic container — serialize children
+ return serializeChildren(el, ctx);
+ case "br":
+ return "";
+ default:
+ return serializeChildren(el, ctx);
+ }
+}
+
+// ─── Block Serializers ───────────────────────────────────────────────────────
+
+function serializeParagraph(el: HTMLElement, ctx: SerializeContext): string {
+ const content = serializeInlineContent(el);
+ // Trim leading/trailing hard breaks (matching remark behavior)
+ const trimmed = trimHardBreaks(content);
+ if (ctx.inListItem) {
+ return trimmed;
+ }
+ return ctx.indent + trimmed + "\n\n";
+}
+
+function serializeHeading(el: HTMLElement, ctx: SerializeContext): string {
+ const level = parseInt(el.tagName[1], 10);
+ const prefix = "#".repeat(level) + " ";
+ const content = serializeInlineContent(el);
+ return ctx.indent + prefix + content + "\n\n";
+}
+
+function serializeBlockquote(el: HTMLElement, ctx: SerializeContext): string {
+ // Check if blockquote contains block-level elements (like )
+ 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 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 blocks through verbatim and BlockNote's audio block parser
+ // recognizes them, giving a clean round-trip.
+ return ctx.indent + ` \n\n`;
+}
+
+function serializeEmbed(el: HTMLElement, ctx: SerializeContext): string {
+ const src = el.getAttribute("src") || "";
+ if (!src) {return "\n\n";}
+ return ctx.indent + `[](${src})\n\n`;
+}
+
+function serializeFigure(el: HTMLElement, ctx: SerializeContext): string {
+ const img = el.querySelector("img");
+ const video = el.querySelector("video");
+ const audio = el.querySelector("audio");
+ const link = el.querySelector("a");
+
+ const figcaption = el.querySelector("figcaption");
+ const captionText = figcaption?.textContent?.trim() || "";
+
+ if (img) {
+ return serializeMediaFigure(
+ "img",
+ img.getAttribute("src") || "",
+ img.getAttribute("alt") || "",
+ captionText,
+ ctx,
+ );
+ }
+ if (video) {
+ const src =
+ video.getAttribute("src") || video.getAttribute("data-url") || "";
+ const name =
+ video.getAttribute("data-name") || video.getAttribute("title") || "";
+ return serializeMediaFigure("video", src, name, captionText, ctx);
+ }
+ if (audio) {
+ return serializeMediaFigure(
+ "audio",
+ audio.getAttribute("src") || "",
+ "",
+ captionText,
+ ctx,
+ );
+ }
+ if (link) {
+ return serializeBlockLink(link as HTMLElement, ctx);
+ }
+ return "";
+}
+
+function serializeMediaFigure(
+ kind: "img" | "video" | "audio",
+ src: string,
+ descriptor: string,
+ captionText: string,
+ ctx: SerializeContext,
+): string {
+ if (!src) {return "";}
+
+ // No caption + has a markdown shorthand → use it.
+ if (!captionText && kind !== "audio") {
+ return ctx.indent + `\n\n`;
+ }
+
+ // The descriptor (alt / data-name) is dropped when it duplicates the
+ // caption text; otherwise on round-trip both `name` and `caption` would
+ // get set to the same string (BlockNote's HTML exporter writes alt =
+ // name || caption, so a caption-only image has alt === figcaption text).
+ const showDescriptor = descriptor && descriptor !== captionText;
+ const descAttr =
+ !showDescriptor
+ ? ""
+ : kind === "img"
+ ? ` alt="${escapeHtmlAttr(descriptor)}"`
+ : kind === "video"
+ ? ` data-name="${escapeHtmlAttr(descriptor)}"`
+ : "";
+
+ const tag =
+ kind === "img"
+ ? ` `
+ : `<${kind} src="${escapeHtmlAttr(src)}"${descAttr} controls>${kind}>`;
+
+ const captionPart = captionText
+ ? `${escapeHtmlText(captionText)} `
+ : "";
+ return ctx.indent + `${tag}${captionPart} \n\n`;
+}
+
+function escapeHtmlAttr(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
+
+function escapeHtmlText(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+function serializeBlockLink(el: HTMLElement, ctx: SerializeContext): string {
+ const href = el.getAttribute("href") || "";
+ const text = el.textContent?.trim() || "";
+ if (!href) {return ctx.indent + text + "\n\n";}
+ return ctx.indent + formatLink(text, href) + "\n\n";
+}
+
+/**
+ * Render a link, mirroring the remark-stringify behavior from
+ * TypeCellOS/BlockNote#2661: when the link label equals the URL (or is
+ * empty), emit the bare URL so that pasting the link into another input
+ * produces a valid href instead of ``-autolink brackets or redundant
+ * `[url](url)` markup. Otherwise emit `[text](url)` with the URL escaped so
+ * a `)` inside the URL does not prematurely close the destination.
+ */
+function formatLink(text: string, href: string): string {
+ if (!text || text === href) {
+ return href;
+ }
+ return `[${text}](${escapeLinkDestination(href)})`;
+}
+
+function escapeLinkDestination(url: string): string {
+ return url.replace(/[\\()]/g, "\\$&");
+}
+
+function serializeDetails(el: HTMLElement, ctx: SerializeContext): string {
+ // Toggle heading or toggle list item
+ const summary = el.querySelector("summary");
+ if (!summary) {return serializeChildren(el, ctx);}
+
+ // Check if summary contains a heading
+ const heading = summary.querySelector("h1, h2, h3, h4, h5, h6");
+ if (heading) {
+ let result = serializeHeading(heading as HTMLElement, ctx);
+ // Also serialize non-summary children of details
+ for (const child of Array.from(el.children)) {
+ if (child !== summary) {
+ result += serializeNode(child, ctx);
+ }
+ }
+ return result;
+ }
+
+ // Otherwise serialize the summary content
+ return serializeChildren(summary, ctx);
+}
+
+// ─── Inline Content Serializer ───────────────────────────────────────────────
+
+function serializeInlineContent(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 childEl = child as HTMLElement;
+ const tag = childEl.tagName.toLowerCase();
+
+ switch (tag) {
+ case "strong":
+ case "b": {
+ const inner = serializeInlineContent(childEl);
+ const { content, trailing } = extractTrailingWhitespace(inner);
+ if (content) {
+ result += `**${content}**${trailing}`;
+ } else {
+ // All whitespace — just output it without emphasis
+ result += trailing;
+ }
+ break;
+ }
+ case "em":
+ case "i": {
+ const inner = serializeInlineContent(childEl);
+ const { content, trailing } = extractTrailingWhitespace(inner);
+ if (content) {
+ result += `*${content}*${trailing}`;
+ } else {
+ result += trailing;
+ }
+ break;
+ }
+ case "s":
+ case "del":
+ result += `~~${serializeInlineContent(childEl)}~~`;
+ break;
+ case "code": {
+ const text = childEl.textContent || "";
+ const longestRun = Math.max(
+ 0,
+ ...((text.match(/`+/g) ?? []).map((run) => run.length))
+ );
+ const fence = "`".repeat(longestRun + 1);
+ const needsPadding =
+ text.startsWith("`") || text.endsWith("`");
+ result += fence + (needsPadding ? ` ${text} ` : text) + fence;
+ break;
+ }
+ case "u":
+ // No markdown equivalent — strip the tag, keep content
+ result += serializeInlineContent(childEl);
+ break;
+ case "a": {
+ const href = childEl.getAttribute("href") || "";
+ const text = serializeInlineContent(childEl);
+ result += formatLink(text, href);
+ break;
+ }
+ case "br":
+ result += "\\\n";
+ break;
+ case "span":
+ // Color spans, etc. — strip the tag, keep content
+ result += serializeInlineContent(childEl);
+ break;
+ case "img": {
+ const src = childEl.getAttribute("src") || "";
+ const alt = childEl.getAttribute("alt") || "";
+ result += ``;
+ break;
+ }
+ case "video": {
+ const src =
+ childEl.getAttribute("src") ||
+ childEl.getAttribute("data-url") ||
+ "";
+ const name =
+ childEl.getAttribute("data-name") ||
+ childEl.getAttribute("title") ||
+ "";
+ result += ``;
+ break;
+ }
+ case "p":
+ // Paragraph inside inline context (e.g., table cell)
+ result += serializeInlineContent(childEl);
+ break;
+ case "input":
+ // Checkbox in task list — handled at block level
+ break;
+ default:
+ result += serializeInlineContent(childEl);
+ break;
+ }
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Extract trailing whitespace from emphasis content.
+ * Moves trailing spaces outside the emphasis delimiters to produce valid markdown.
+ * E.g., `Bold ` → `**Bold** ` instead of `**Bold **`.
+ */
+function extractTrailingWhitespace(text: string): {
+ content: string;
+ trailing: string;
+} {
+ const match = text.match(/^(.*?)(\s*)$/);
+ if (match) {
+ return { content: match[1], trailing: match[2] };
+ }
+ return { content: text, trailing: "" };
+}
+
+/**
+ * Escape leading character after emphasis if it could break parsing.
+ * For example, "Heading" after "**Bold **" — the 'H' should be escaped
+ * if the trailing space was escaped.
+ */
+
+/**
+ * Trim leading/trailing hard breaks from inline content.
+ * Matches remark behavior where at start/end of paragraph is dropped.
+ */
+function trimHardBreaks(content: string): string {
+ // Remove leading hard breaks
+ let result = content.replace(/^(\\\n)+/, "");
+ // Remove trailing hard breaks produced by ` `
+ result = result.replace(/(\\\n)+$/, "");
+ return result;
+}
diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts
index 488886c76d..2f73616dc0 100644
--- a/packages/core/src/api/exporters/markdown/markdownExporter.ts
+++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts
@@ -1,4 +1,5 @@
import { Schema } from "prosemirror-model";
+
import { PartialBlock } from "../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import {
@@ -6,41 +7,15 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
-import {
- esmDependencies,
- initializeESMDependencies,
-} from "../../../util/esmDependencies.js";
import { createExternalHTMLExporter } from "../html/externalHTMLExporter.js";
-import { removeUnderlines } from "./removeUnderlinesRehypePlugin.js";
-import { addSpacesToCheckboxes } from "./util/addSpacesToCheckboxesRehypePlugin.js";
+import { htmlToMarkdown } from "./htmlToMarkdown.js";
// Needs to be sync because it's used in drag handler event (SideMenuPlugin)
-// Ideally, call `await initializeESMDependencies()` before calling this function
export function cleanHTMLToMarkdown(cleanHTMLString: string) {
- const deps = esmDependencies;
-
- if (!deps) {
- throw new Error(
- "cleanHTMLToMarkdown requires ESM dependencies to be initialized",
- );
- }
-
- const markdownString = deps.unified
- .unified()
- .use(deps.rehypeParse.default, { fragment: true })
- .use(removeUnderlines)
- .use(addSpacesToCheckboxes)
- .use(deps.rehypeRemark.default)
- .use(deps.remarkGfm.default)
- .use(deps.remarkStringify.default, {
- handlers: { text: (node) => node.value },
- })
- .processSync(cleanHTMLString);
-
- return markdownString.value as string;
+ return htmlToMarkdown(cleanHTMLString);
}
-export async function blocksToMarkdown<
+export function blocksToMarkdown<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
@@ -49,8 +24,7 @@ export async function blocksToMarkdown<
schema: Schema,
editor: BlockNoteEditor,
options: { document?: Document },
-): Promise {
- await initializeESMDependencies();
+): string {
const exporter = createExternalHTMLExporter(schema, editor);
const externalHTML = exporter.exportBlocks(blocks, options);
diff --git a/packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts
deleted file mode 100644
index 5b455d1b53..0000000000
--- a/packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Element as HASTElement, Parent as HASTParent } from "hast";
-
-/**
- * Rehype plugin which removes tags. Used to remove underlines before converting HTML to markdown, as Markdown
- * doesn't support underlines.
- */
-export function removeUnderlines() {
- const removeUnderlinesHelper = (tree: HASTParent) => {
- let numChildElements = tree.children.length;
-
- for (let i = 0; i < numChildElements; i++) {
- const node = tree.children[i];
-
- if (node.type === "element") {
- // Recursively removes underlines from child elements.
- removeUnderlinesHelper(node);
-
- if ((node as HASTElement).tagName === "u") {
- // Lifts child nodes outside underline element, deletes the underline element, and updates current index &
- // the number of child elements.
- if (node.children.length > 0) {
- tree.children.splice(i, 1, ...node.children);
-
- const numElementsAdded = node.children.length - 1;
- numChildElements += numElementsAdded;
- i += numElementsAdded;
- } else {
- tree.children.splice(i, 1);
-
- numChildElements--;
- i--;
- }
- }
- }
- }
- };
-
- return removeUnderlinesHelper;
-}
diff --git a/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts
deleted file mode 100644
index 2553faa111..0000000000
--- a/packages/core/src/api/exporters/markdown/util/addSpacesToCheckboxesRehypePlugin.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Element as HASTElement, Parent as HASTParent } from "hast";
-import { esmDependencies } from "../../../../util/esmDependencies.js";
-
-/**
- * Rehype plugin which adds a space after each checkbox input element. This is
- * because remark doesn't add any spaces between the checkbox input and the text
- * itself, but these are needed for correct Markdown syntax.
- */
-export function addSpacesToCheckboxes() {
- const deps = esmDependencies;
-
- if (!deps) {
- throw new Error(
- "addSpacesToCheckboxes requires ESM dependencies to be initialized",
- );
- }
-
- const helper = (tree: HASTParent) => {
- if (tree.children && "length" in tree.children && tree.children.length) {
- for (let i = tree.children.length - 1; i >= 0; i--) {
- const child = tree.children[i];
- const nextChild =
- i + 1 < tree.children.length ? tree.children[i + 1] : undefined;
-
- // Checks for paragraph element after checkbox input element.
- if (
- child.type === "element" &&
- child.tagName === "input" &&
- child.properties?.type === "checkbox" &&
- nextChild?.type === "element" &&
- nextChild.tagName === "p"
- ) {
- // Converts paragraph to span, otherwise remark will think it needs to
- // be on a new line.
- nextChild.tagName = "span";
- // Adds a space after the checkbox input element.
- nextChild.children.splice(
- 0,
- 0,
- deps.hastUtilFromDom.fromDom(
- document.createTextNode(" "),
- ) as HASTElement,
- );
- } else {
- helper(child as HASTParent);
- }
- }
- }
- };
-
- return helper;
-}
diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts
index 019c89f9ed..b0768a2cc8 100644
--- a/packages/core/src/api/getBlockInfoFromPos.ts
+++ b/packages/core/src/api/getBlockInfoFromPos.ts
@@ -126,7 +126,7 @@ export function getBlockInfoWithManualOffset(
): BlockInfo {
if (!node.type.isInGroup("bnBlock")) {
throw new Error(
- `Attempted to get bnBlock node at position but found node of different type ${node.type}`,
+ `Attempted to get bnBlock node at position but found node of different type ${node.type.name}`,
);
}
diff --git a/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts
new file mode 100644
index 0000000000..03a3b464e8
--- /dev/null
+++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts
@@ -0,0 +1,571 @@
+import { describe, expect, it, beforeEach } from "vitest";
+
+import { setupTestEnv } from "./blockManipulation/setupTestEnv.js";
+import { getBlocksChangedByTransaction } from "./getBlocksChangedByTransaction.js";
+import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+
+const getEditor = setupTestEnv();
+
+describe("getBlocksChangedByTransaction", () => {
+ let editor: BlockNoteEditor;
+
+ beforeEach(() => {
+ editor = getEditor();
+ });
+
+ it("should return the correct blocks changed by a transaction", () => {
+ const blocksChanged = editor.transact((tr) => {
+ return getBlocksChangedByTransaction(tr);
+ });
+ expect(blocksChanged).toEqual([]);
+ });
+
+ it("should return blocks inserted by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.insertBlocks([{ type: "paragraph" }], "paragraph-0", "after");
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-inserted.json",
+ );
+ });
+
+ it("should return nested blocks inserted by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.insertBlocks(
+ [
+ {
+ type: "paragraph",
+ children: [{ type: "paragraph", content: "Nested" }],
+ },
+ ],
+ "paragraph-0",
+ "after",
+ );
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-inserted-nested.json",
+ );
+ });
+
+ it("should return blocks deleted by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.removeBlocks(["paragraph-0"]);
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-deleted.json",
+ );
+ });
+
+ it("should return deeply nested blocks deleted by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.removeBlocks(["double-nested-paragraph-0"]);
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-deleted-nested-deep.json",
+ );
+ });
+
+ it("should return nested blocks deleted by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.removeBlocks(["nested-paragraph-0"]);
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-deleted-nested.json",
+ );
+ });
+
+ it("should return blocks updated by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.updateBlock("paragraph-0", {
+ props: {
+ backgroundColor: "red",
+ },
+ });
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated.json",
+ );
+ });
+
+ it("should return nested blocks updated by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.updateBlock("nested-paragraph-0", {
+ props: {
+ backgroundColor: "red",
+ },
+ });
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated-nested.json",
+ );
+ });
+
+ it("should return deeply nested blocks updated by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.updateBlock("double-nested-paragraph-0", {
+ content: "Example Text",
+ });
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated-nested-deep.json",
+ );
+ });
+
+ it("should return multiple nested blocks updated by a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.updateBlock("nested-paragraph-0", {
+ props: {
+ backgroundColor: "red",
+ },
+ });
+ editor.updateBlock("double-nested-paragraph-0", {
+ content: "Example Text",
+ });
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated-nested-multiple.json",
+ );
+ });
+
+ it("should only return a single block, if multiple updates change a single block in a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.updateBlock("paragraph-0", {
+ props: {
+ backgroundColor: "red",
+ },
+ });
+ editor.updateBlock("paragraph-0", {
+ props: {
+ backgroundColor: "blue",
+ },
+ });
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated-single.json",
+ );
+ });
+
+ it("should return multiple blocks, if multiple updates change multiple blocks in a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.updateBlock("paragraph-0", {
+ props: {
+ backgroundColor: "red",
+ },
+ });
+ editor.updateBlock("paragraph-1", {
+ props: {
+ backgroundColor: "blue",
+ },
+ });
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated-multiple.json",
+ );
+ });
+
+ it("should return multiple blocks, if multiple inserts add new blocks in a transaction", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.insertBlocks(
+ [{ type: "paragraph", content: "ABC" }],
+ "paragraph-0",
+ "after",
+ );
+ editor.insertBlocks(
+ [{ type: "paragraph", content: "DEF" }],
+ "paragraph-1",
+ "after",
+ );
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated-multiple-insert.json",
+ );
+ });
+
+ it("should return blocks which have had content inserted into them", async () => {
+ const blocksChanged = editor.transact((tr) => {
+ editor.setTextCursorPosition("paragraph-2", "start");
+ editor.insertInlineContent("Hello");
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-updated-content-inserted.json",
+ );
+ });
+
+ it("should return blocks which have been indented", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "paragraph-with-children",
+ type: "paragraph",
+ content: "A",
+ children: [
+ {
+ id: "nested-paragraph-0",
+ type: "paragraph",
+ content: "B",
+ children: [],
+ },
+ {
+ id: "double-nested-paragraph-0",
+ type: "paragraph",
+ content: "C",
+ },
+ ],
+ },
+ ]);
+ const blocksChanged = editor.transact((tr) => {
+ editor.setTextCursorPosition("double-nested-paragraph-0", "start");
+ editor.nestBlock();
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-indented-changed.json",
+ );
+ });
+
+ it("should return blocks which have been outdented", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "paragraph-with-children",
+ type: "paragraph",
+ content: "A",
+ children: [
+ {
+ id: "nested-paragraph-0",
+ type: "paragraph",
+ content: "B",
+ children: [
+ {
+ id: "double-nested-paragraph-0",
+ type: "paragraph",
+ content: "C",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ // This test is different from the other tests because it uses the onChange hook to get the blocks changed
+ // This is because unnesting a block is not allowed within a transaction
+ let blocksChanged: any = null;
+ const unsubscribe = editor.onChange((_e, { getChanges }) => {
+ blocksChanged = getChanges();
+ });
+
+ // Make the change
+ editor.setTextCursorPosition("double-nested-paragraph-0", "start");
+ editor.unnestBlock();
+
+ // Clean up
+ if (unsubscribe) {
+ unsubscribe();
+ }
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-outdented-changed.json",
+ );
+ });
+
+ it("should return blocks which have been moved to a different parent", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "parent-1",
+ type: "paragraph",
+ content: "Parent 1",
+ children: [
+ {
+ id: "child-1",
+ type: "paragraph",
+ content: "Child 1",
+ },
+ ],
+ },
+ {
+ id: "parent-2",
+ type: "paragraph",
+ content: "Parent 2",
+ children: [],
+ },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ const childBlock = editor.getBlock("child-1");
+ editor.removeBlocks(["child-1"]);
+ editor.insertBlocks([{ ...childBlock }], "parent-2", "after");
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-to-different-parent.json",
+ );
+ });
+
+ it("should return blocks which have been moved to root level", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [
+ {
+ id: "child",
+ type: "paragraph",
+ content: "Child",
+ },
+ ],
+ },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ const childBlock = editor.getBlock("child");
+ editor.removeBlocks(["child"]);
+ editor.insertBlocks([{ ...childBlock }], "parent", "after");
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-to-root-level.json",
+ );
+ });
+
+ it("should return blocks which have been moved deeper into nesting", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "root",
+ type: "paragraph",
+ content: "Root",
+ children: [
+ {
+ id: "level-1",
+ type: "paragraph",
+ content: "Level 1",
+ children: [
+ {
+ id: "level-2",
+ type: "paragraph",
+ content: "Level 2",
+ },
+ ],
+ },
+ {
+ id: "target",
+ type: "paragraph",
+ content: "Target",
+ },
+ ],
+ },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ const targetBlock = editor.getBlock("target");
+ editor.removeBlocks(["target"]);
+ editor.insertBlocks([{ ...targetBlock }], "level-2", "after");
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-deeper-into-nesting.json",
+ );
+ });
+
+ it("should return multiple blocks when multiple blocks are moved in the same transaction", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "parent-1",
+ type: "paragraph",
+ content: "Parent 1",
+ children: [
+ {
+ id: "child-1",
+ type: "paragraph",
+ content: "Child 1",
+ },
+ {
+ id: "child-2",
+ type: "paragraph",
+ content: "Child 2",
+ },
+ ],
+ },
+ {
+ id: "parent-2",
+ type: "paragraph",
+ content: "Parent 2",
+ children: [],
+ },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ const child1Block = editor.getBlock("child-1");
+ const child2Block = editor.getBlock("child-2");
+ editor.removeBlocks(["child-1", "child-2"]);
+ editor.insertBlocks(
+ [{ ...child1Block }, { ...child2Block }],
+ "parent-2",
+ "after",
+ );
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-multiple-in-same-transaction.json",
+ );
+ });
+
+ it("should return blocks which have been moved up or down in the same transaction", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "top",
+ type: "paragraph",
+ content: "Top",
+ },
+ {
+ id: "middle",
+ type: "paragraph",
+ content: "Middle",
+ },
+ {
+ id: "bottom",
+ type: "paragraph",
+ content: "Bottom",
+ },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ editor.setTextCursorPosition("top");
+ editor.moveBlocksDown();
+
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ // Should report a single minimal move within the same parent
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-up-down-in-same-transaction.json",
+ );
+ });
+
+ it("should detect moving the bottom block up within the same parent", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "top", type: "paragraph", content: "Top" },
+ { id: "middle", type: "paragraph", content: "Middle" },
+ { id: "bottom", type: "paragraph", content: "Bottom" },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ editor.setTextCursorPosition("bottom");
+ editor.moveBlocksUp();
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-up-down-in-same-parent.json",
+ );
+ });
+
+ it("should detect moving a block down twice within the same parent as a single move", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "a", type: "paragraph", content: "A" },
+ { id: "b", type: "paragraph", content: "B" },
+ { id: "c", type: "paragraph", content: "C" },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ editor.setTextCursorPosition("a");
+ editor.moveBlocksDown();
+ editor.moveBlocksDown();
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-down-twice-in-same-parent.json",
+ );
+ });
+
+ it("should detect nested sibling reorder within the same parent", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "parent",
+ type: "paragraph",
+ content: "Parent",
+ children: [
+ { id: "child-a", type: "paragraph", content: "A" },
+ { id: "child-b", type: "paragraph", content: "B" },
+ { id: "child-c", type: "paragraph", content: "C" },
+ ],
+ },
+ { id: "sibling", type: "paragraph", content: "S" },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ editor.setTextCursorPosition("child-a");
+ editor.moveBlocksDown();
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-nested-sibling-reorder.json",
+ );
+ });
+
+ it("should not report moves when an insert changes sibling order", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "a", type: "paragraph", content: "A" },
+ { id: "b", type: "paragraph", content: "B" },
+ { id: "c", type: "paragraph", content: "C" },
+ ]);
+
+ const blocksChanged = editor.transact((tr) => {
+ editor.insertBlocks(
+ [{ id: "x", type: "paragraph", content: "X" }],
+ "a",
+ "after",
+ );
+ return getBlocksChangedByTransaction(tr);
+ });
+
+ await expect(blocksChanged).toMatchFileSnapshot(
+ "__snapshots__/blocks-moved-insert-changes-sibling-order.json",
+ );
+ });
+});
diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts
new file mode 100644
index 0000000000..c45af4cb71
--- /dev/null
+++ b/packages/core/src/api/getBlocksChangedByTransaction.ts
@@ -0,0 +1,422 @@
+import { combineTransactionSteps } from "@tiptap/core";
+import deepEqual from "fast-deep-equal";
+import type { Node } from "prosemirror-model";
+import type { Transaction } from "prosemirror-state";
+import {
+ Block,
+ DefaultBlockSchema,
+ DefaultInlineContentSchema,
+ DefaultStyleSchema,
+} from "../blocks/defaultBlocks.js";
+import type { BlockSchema } from "../schema/index.js";
+import type { InlineContentSchema } from "../schema/inlineContent/types.js";
+import type { StyleSchema } from "../schema/styles/types.js";
+import { nodeToBlock } from "./nodeConversions/nodeToBlock.js";
+import { isNodeBlock } from "./nodeUtil.js";
+import { getPmSchema } from "./pmUtil.js";
+
+/**
+ * Change detection utilities for BlockNote.
+ *
+ * High-level algorithm used by getBlocksChangedByTransaction:
+ * 1) Merge appended transactions into one document change.
+ * 2) Collect a snapshot of blocks before and after (flat map by id, and per-parent child order).
+ * 3) Emit inserts and deletes by diffing ids between snapshots.
+ * 4) For ids present in both snapshots:
+ * - If parentId changed, emit a move
+ * - Else if block changed (ignoring children), emit an update
+ * 5) Finally, detect same-parent sibling reorders by comparing child order per parent.
+ * We use an inlined O(n log n) LIS inside detectReorderedChildren to keep a
+ * longest already-ordered subsequence and mark only the remaining items as moved.
+ */
+/**
+ * Gets the parent block of a node, if it has one.
+ */
+function getParentBlockId(doc: Node, pos: number): string | undefined {
+ if (pos === 0) {
+ return undefined;
+ }
+ const resolvedPos = doc.resolve(pos);
+ for (let i = resolvedPos.depth; i > 0; i--) {
+ const parent = resolvedPos.node(i);
+ if (isNodeBlock(parent)) {
+ return parent.attrs.id;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * This attributes the changes to a specific source.
+ */
+export type BlockChangeSource =
+ | { type: "local" }
+ | { type: "paste" }
+ | { type: "drop" }
+ | { type: "undo" | "redo" | "undo-redo" }
+ | { type: "yjs-remote" };
+
+export type BlocksChanged<
+ BSchema extends BlockSchema = DefaultBlockSchema,
+ ISchema extends InlineContentSchema = DefaultInlineContentSchema,
+ SSchema extends StyleSchema = DefaultStyleSchema,
+> = Array<
+ {
+ /**
+ * The affected block.
+ */
+ block: Block;
+ /**
+ * The source of the change.
+ */
+ source: BlockChangeSource;
+ } & (
+ | {
+ type: "insert" | "delete";
+ /**
+ * Insert and delete changes don't have a previous block.
+ */
+ prevBlock: undefined;
+ }
+ | {
+ type: "update";
+ /**
+ * The previous block.
+ */
+ prevBlock: Block;
+ }
+ | {
+ type: "move";
+ /**
+ * The affected block.
+ */
+ block: Block;
+ /**
+ * The block before the move.
+ */
+ prevBlock: Block;
+ /**
+ * The previous parent block (if it existed).
+ */
+ prevParent?: Block;
+ /**
+ * The current parent block (if it exists).
+ */
+ currentParent?: Block;
+ }
+ )
+>;
+
+function determineChangeSource(transaction: Transaction): BlockChangeSource {
+ if (transaction.getMeta("paste")) {
+ return { type: "paste" };
+ }
+ if (transaction.getMeta("uiEvent") === "drop") {
+ return { type: "drop" };
+ }
+ if (transaction.getMeta("history$")) {
+ return {
+ type: transaction.getMeta("history$").redo ? "redo" : "undo",
+ };
+ }
+ if (transaction.getMeta("y-sync$")) {
+ if (transaction.getMeta("y-sync$").isUndoRedoOperation) {
+ return { type: "undo-redo" };
+ }
+ return { type: "yjs-remote" };
+ }
+ return { type: "local" };
+}
+
+type BlockSnapshot<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+> = {
+ byId: Record<
+ string,
+ {
+ block: Block;
+ parentId: string | undefined;
+ }
+ >;
+ childrenByParent: Record;
+};
+
+/**
+ * Collects a snapshot of blocks and per-parent child order in a single traversal.
+ * Uses "__root__" to represent the root level where parentId is undefined.
+ */
+function collectSnapshot<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+>(doc: Node): BlockSnapshot {
+ const ROOT_KEY = "__root__";
+ const byId: Record<
+ string,
+ {
+ block: Block;
+ parentId: string | undefined;
+ }
+ > = {};
+ const childrenByParent: Record = {};
+ const pmSchema = getPmSchema(doc);
+ doc.descendants((node, pos) => {
+ if (!isNodeBlock(node)) {
+ return true;
+ }
+ const parentId = getParentBlockId(doc, pos);
+ const key = parentId ?? ROOT_KEY;
+ if (!childrenByParent[key]) {
+ childrenByParent[key] = [];
+ }
+ const block = nodeToBlock(node, pmSchema);
+ byId[node.attrs.id] = { block, parentId };
+ childrenByParent[key].push(node.attrs.id);
+ return true;
+ });
+ return { byId, childrenByParent };
+}
+
+/**
+ * Determines which child ids have been reordered (moved) within the same parent.
+ * Uses LIS to keep the longest ordered subsequence and marks the rest as moved.
+ */
+function detectReorderedChildren(
+ prevOrder: string[] | undefined,
+ nextOrder: string[] | undefined,
+): Set {
+ const moved = new Set();
+ if (!prevOrder || !nextOrder) {
+ return moved;
+ }
+ // Consider only ids present in both orders (ignore inserts/deletes handled elsewhere)
+ const prevIds = new Set(prevOrder);
+ const commonNext: string[] = nextOrder.filter((id) => prevIds.has(id));
+ const commonPrev: string[] = prevOrder.filter((id) =>
+ commonNext.includes(id),
+ );
+
+ if (commonPrev.length <= 1 || commonNext.length <= 1) {
+ return moved;
+ }
+
+ // Map ids to their index in previous order
+ const indexInPrev: Record = {};
+ for (let i = 0; i < commonPrev.length; i++) {
+ indexInPrev[commonPrev[i]] = i;
+ }
+
+ // Build sequence of indices representing next order in terms of previous indices
+ const sequence: number[] = commonNext.map((id) => indexInPrev[id]);
+
+ // Inline O(n log n) LIS with reconstruction.
+ // Why LIS? We want the smallest set of siblings to label as "moved".
+ // Keeping the longest subsequence that is already in order achieves this,
+ // so only items outside the LIS are reported as moves.
+ const n = sequence.length;
+ const tailsValues: number[] = [];
+ const tailsEndsAtIndex: number[] = [];
+ const previousIndexInLis: number[] = new Array(n).fill(-1);
+
+ const lowerBound = (arr: number[], target: number): number => {
+ let lo = 0;
+ let hi = arr.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >>> 1;
+ if (arr[mid] < target) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ return lo;
+ };
+
+ for (let i = 0; i < n; i++) {
+ const value = sequence[i];
+ const pos = lowerBound(tailsValues, value);
+ if (pos > 0) {
+ previousIndexInLis[i] = tailsEndsAtIndex[pos - 1];
+ }
+ if (pos === tailsValues.length) {
+ tailsValues.push(value);
+ tailsEndsAtIndex.push(i);
+ } else {
+ tailsValues[pos] = value;
+ tailsEndsAtIndex[pos] = i;
+ }
+ }
+
+ const lisIndexSet = new Set();
+ let k = tailsEndsAtIndex[tailsEndsAtIndex.length - 1] ?? -1;
+ while (k !== -1) {
+ lisIndexSet.add(k);
+ k = previousIndexInLis[k];
+ }
+
+ // Items not part of LIS are considered moved
+ for (let i = 0; i < commonNext.length; i++) {
+ if (!lisIndexSet.has(i)) {
+ moved.add(commonNext[i]);
+ }
+ }
+ return moved;
+}
+
+/**
+ * Get the blocks that were changed by a transaction.
+ */
+export function getBlocksChangedByTransaction<
+ BSchema extends BlockSchema = DefaultBlockSchema,
+ ISchema extends InlineContentSchema = DefaultInlineContentSchema,
+ SSchema extends StyleSchema = DefaultStyleSchema,
+>(
+ transaction: Transaction,
+ appendedTransactions: Transaction[] = [],
+): BlocksChanged {
+ const source = determineChangeSource(transaction);
+ const combinedTransaction = combineTransactionSteps(transaction.before, [
+ transaction,
+ ...appendedTransactions,
+ ]);
+
+ const prevSnap = collectSnapshot(
+ combinedTransaction.before,
+ );
+ const nextSnap = collectSnapshot(
+ combinedTransaction.doc,
+ );
+
+ const changes: BlocksChanged = [];
+ const changedIds = new Set();
+
+ // Handle inserted blocks
+ Object.keys(nextSnap.byId)
+ .filter((id) => !(id in prevSnap.byId))
+ .forEach((id) => {
+ changes.push({
+ type: "insert",
+ block: nextSnap.byId[id].block,
+ source,
+ prevBlock: undefined,
+ });
+ changedIds.add(id);
+ });
+
+ // Handle deleted blocks
+ Object.keys(prevSnap.byId)
+ .filter((id) => !(id in nextSnap.byId))
+ .forEach((id) => {
+ changes.push({
+ type: "delete",
+ block: prevSnap.byId[id].block,
+ source,
+ prevBlock: undefined,
+ });
+ changedIds.add(id);
+ });
+
+ // Handle updated, moved to different parent, indented, outdented blocks
+ Object.keys(nextSnap.byId)
+ .filter((id) => id in prevSnap.byId)
+ .forEach((id) => {
+ const prev = prevSnap.byId[id];
+ const next = nextSnap.byId[id];
+ const isParentDifferent = prev.parentId !== next.parentId;
+
+ if (isParentDifferent) {
+ changes.push({
+ type: "move",
+ block: next.block,
+ prevBlock: prev.block,
+ source,
+ prevParent: prev.parentId
+ ? prevSnap.byId[prev.parentId]?.block
+ : undefined,
+ currentParent: next.parentId
+ ? nextSnap.byId[next.parentId]?.block
+ : undefined,
+ });
+ changedIds.add(id);
+ } else if (
+ // Compare blocks while ignoring children to avoid reporting a parent
+ // update when only descendants changed.
+ !deepEqual(
+ { ...prev.block, children: undefined } as any,
+ { ...next.block, children: undefined } as any,
+ )
+ ) {
+ changes.push({
+ type: "update",
+ block: next.block,
+ prevBlock: prev.block,
+ source,
+ });
+ changedIds.add(id);
+ }
+ });
+
+ // Handle sibling reorders (parent unchanged but relative order changed)
+ const prevOrderByParent = prevSnap.childrenByParent;
+ const nextOrderByParent = nextSnap.childrenByParent;
+
+ // Use a special key for root-level siblings
+ const ROOT_KEY = "__root__";
+ const parents = new Set([
+ ...Object.keys(prevOrderByParent),
+ ...Object.keys(nextOrderByParent),
+ ]);
+
+ const addedMoveForId = new Set();
+
+ parents.forEach((parentKey) => {
+ const movedWithinParent = detectReorderedChildren(
+ prevOrderByParent[parentKey],
+ nextOrderByParent[parentKey],
+ );
+ if (movedWithinParent.size === 0) {
+ return;
+ }
+ movedWithinParent.forEach((id) => {
+ // Only consider ids that exist in both snapshots and whose parent truly did not change
+ const prev = prevSnap.byId[id];
+ const next = nextSnap.byId[id];
+ if (!prev || !next) {
+ return;
+ }
+ if (prev.parentId !== next.parentId) {
+ return;
+ }
+ // Skip if already accounted for by insert/delete/update/parent move
+ if (changedIds.has(id)) {
+ return;
+ }
+ // Verify we're addressing the right parent bucket
+ const bucketKey = prev.parentId ?? ROOT_KEY;
+ if (bucketKey !== parentKey) {
+ return;
+ }
+ if (addedMoveForId.has(id)) {
+ return;
+ }
+ addedMoveForId.add(id);
+ changes.push({
+ type: "move",
+ block: next.block,
+ prevBlock: prev.block,
+ source,
+ prevParent: prev.parentId
+ ? prevSnap.byId[prev.parentId]?.block
+ : undefined,
+ currentParent: next.parentId
+ ? nextSnap.byId[next.parentId]?.block
+ : undefined,
+ });
+ changedIds.add(id);
+ });
+ });
+
+ return changes;
+}
diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts
index 8035f32d2b..d970227a49 100644
--- a/packages/core/src/api/nodeConversions/blockToNode.ts
+++ b/packages/core/src/api/nodeConversions/blockToNode.ts
@@ -1,6 +1,6 @@
import { Attrs, Fragment, Mark, Node, Schema } from "@tiptap/pm/model";
-import UniqueID from "../../extensions/UniqueID/UniqueID.js";
+import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import type {
InlineContentSchema,
PartialCustomInlineContentFromConfig,
@@ -33,16 +33,20 @@ function styledTextToNodes(
): Node[] {
const marks: Mark[] = [];
- for (const [style, value] of Object.entries(styledText.styles)) {
+ for (const [style, value] of Object.entries(styledText.styles || {})) {
const config = styleSchema[style];
if (!config) {
throw new Error(`style ${style} not found in styleSchema`);
}
if (config.propSchema === "boolean") {
- marks.push(schema.mark(style));
+ if (value) {
+ marks.push(schema.mark(style));
+ }
} else if (config.propSchema === "string") {
- marks.push(schema.mark(style, { stringValue: value }));
+ if (value) {
+ marks.push(schema.mark(style, { stringValue: value }));
+ }
} else {
throw new UnreachableCaseError(config.propSchema);
}
@@ -51,7 +55,9 @@ function styledTextToNodes(
const parseHardBreaks = !blockType || !schema.nodes[blockType].spec.code;
if (!parseHardBreaks) {
- return [schema.text(styledText.text, marks)];
+ return styledText.text.length > 0
+ ? [schema.text(styledText.text, marks)]
+ : [];
}
return (
@@ -259,6 +265,7 @@ export function tableContentToNodes<
);
columnNodes.push(cellNode);
}
+
const rowNode = schema.nodes["tableRow"].createChecked({}, columnNodes);
rowNodes.push(rowNode);
}
@@ -359,8 +366,9 @@ export function blockToNode(
groupNode ? [contentNode, groupNode] : contentNode,
);
} else if (schema.nodes[block.type].isInGroup("bnBlock")) {
- // this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node
- return schema.nodes[block.type].createChecked(
+ // `create` (not `createChecked`) so partial container blocks pass through;
+ // callers that mutate the doc validate via `node.check()` before inserting.
+ return schema.nodes[block.type].create(
{
id: id,
...block.props,
diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts
index 6ad2236ef4..5048f91a2b 100644
--- a/packages/core/src/api/nodeConversions/nodeToBlock.ts
+++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts
@@ -1,6 +1,6 @@
-import { Mark, Node, Schema } from "@tiptap/pm/model";
-
-import UniqueID from "../../extensions/UniqueID/UniqueID.js";
+import { Mark, Node, Schema, Slice } from "@tiptap/pm/model";
+import type { Block } from "../../blocks/defaultBlocks.js";
+import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import type {
BlockSchema,
CustomInlineContentConfig,
@@ -13,17 +13,19 @@ import type {
TableCell,
TableContent,
} from "../../schema/index.js";
-import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js";
-
-import type { Block } from "../../blocks/defaultBlocks.js";
import {
isLinkInlineContent,
isStyledTextInlineContent,
} from "../../schema/inlineContent/types.js";
import { UnreachableCaseError } from "../../util/typescript.js";
-import { getBlockCache, getStyleSchema } from "../pmUtil.js";
-import { getInlineContentSchema } from "../pmUtil.js";
-import { getBlockSchema } from "../pmUtil.js";
+import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js";
+import {
+ getBlockCache,
+ getBlockSchema,
+ getInlineContentSchema,
+ getPmSchema,
+ getStyleSchema,
+} from "../pmUtil.js";
/**
* Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent
@@ -492,3 +494,199 @@ export function nodeToBlock<
return block;
}
+
+/**
+ * Convert a Prosemirror document to a BlockNote document (array of blocks)
+ */
+export function docToBlocks<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(
+ doc: Node,
+ schema: Schema = getPmSchema(doc),
+ blockSchema: BSchema = getBlockSchema(schema) as BSchema,
+ inlineContentSchema: I = getInlineContentSchema(schema) as I,
+ styleSchema: S = getStyleSchema(schema) as S,
+ blockCache = getBlockCache(schema),
+) {
+ const blocks: Block[] = [];
+ if (doc.firstChild) {
+ doc.firstChild.descendants((node) => {
+ blocks.push(
+ nodeToBlock(
+ node,
+ schema,
+ blockSchema,
+ inlineContentSchema,
+ styleSchema,
+ blockCache,
+ ),
+ );
+ return false;
+ });
+ }
+ return blocks;
+}
+
+/**
+ *
+ * Parse a Prosemirror Slice into a BlockNote selection. The prosemirror schema looks like this:
+ *
+ *
+ * (main content of block)
+ *
+ * (only if blocks has children)
+ * (child block)
+ *
+ *
+ * (child block 2)
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+export function prosemirrorSliceToSlicedBlocks<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(
+ slice: Slice,
+ schema: Schema,
+ blockSchema: BSchema = getBlockSchema(schema) as BSchema,
+ inlineContentSchema: I = getInlineContentSchema(schema) as I,
+ styleSchema: S = getStyleSchema(schema) as S,
+ blockCache: WeakMap> = getBlockCache(schema),
+): {
+ /**
+ * The blocks that are included in the selection.
+ */
+ blocks: Block[];
+ /**
+ * If a block was "cut" at the start of the selection, this will be the id of the block that was cut.
+ */
+ blockCutAtStart: string | undefined;
+ /**
+ * If a block was "cut" at the end of the selection, this will be the id of the block that was cut.
+ */
+ blockCutAtEnd: string | undefined;
+} {
+ // console.log(JSON.stringify(slice.toJSON()));
+ function processNode(
+ node: Node,
+ openStart: number,
+ openEnd: number,
+ ): {
+ blocks: Block[];
+ blockCutAtStart: string | undefined;
+ blockCutAtEnd: string | undefined;
+ } {
+ if (node.type.name !== "blockGroup") {
+ throw new Error("unexpected");
+ }
+ const blocks: Block[] = [];
+ let blockCutAtStart: string | undefined;
+ let blockCutAtEnd: string | undefined;
+
+ node.forEach((blockContainer, _offset, index) => {
+ if (blockContainer.type.name !== "blockContainer") {
+ throw new Error("unexpected");
+ }
+ if (blockContainer.childCount === 0) {
+ return;
+ }
+ if (blockContainer.childCount === 0 || blockContainer.childCount > 2) {
+ throw new Error(
+ "unexpected, blockContainer.childCount: " + blockContainer.childCount,
+ );
+ }
+
+ const isFirstBlock = index === 0;
+ const isLastBlock = index === node.childCount - 1;
+
+ if (blockContainer.firstChild!.type.name === "blockGroup") {
+ // this is the parent where a selection starts within one of its children,
+ // e.g.:
+ // A
+ // ├── B
+ // selection starts within B, then this blockContainer is A, but we don't care about A
+ // so let's descend into B and continue processing
+ if (!isFirstBlock) {
+ throw new Error("unexpected");
+ }
+ const ret = processNode(
+ blockContainer.firstChild!,
+ Math.max(0, openStart - 1),
+ isLastBlock ? Math.max(0, openEnd - 1) : 0,
+ );
+ blockCutAtStart = ret.blockCutAtStart;
+ if (isLastBlock) {
+ blockCutAtEnd = ret.blockCutAtEnd;
+ }
+ blocks.push(...ret.blocks);
+ return;
+ }
+
+ const block = nodeToBlock(
+ blockContainer,
+ schema,
+ blockSchema,
+ inlineContentSchema,
+ styleSchema,
+ blockCache,
+ );
+ const childGroup =
+ blockContainer.childCount > 1 ? blockContainer.child(1) : undefined;
+
+ let childBlocks: Block[] = [];
+ if (childGroup) {
+ const ret = processNode(
+ childGroup,
+ 0, // TODO: can this be anything other than 0?
+ isLastBlock ? Math.max(0, openEnd - 1) : 0,
+ );
+ childBlocks = ret.blocks;
+ if (isLastBlock) {
+ blockCutAtEnd = ret.blockCutAtEnd;
+ }
+ }
+
+ if (isLastBlock && !childGroup && openEnd > 1) {
+ blockCutAtEnd = block.id;
+ }
+
+ if (isFirstBlock && openStart > 1) {
+ blockCutAtStart = block.id;
+ }
+
+ blocks.push({
+ ...(block as any),
+ children: childBlocks,
+ });
+ });
+
+ return { blocks, blockCutAtStart, blockCutAtEnd };
+ }
+
+ if (slice.content.childCount === 0) {
+ return {
+ blocks: [],
+ blockCutAtStart: undefined,
+ blockCutAtEnd: undefined,
+ };
+ }
+
+ if (slice.content.childCount !== 1) {
+ throw new Error(
+ "slice must be a single block, did you forget includeParents=true?",
+ );
+ }
+
+ return processNode(
+ slice.content.firstChild!,
+ Math.max(slice.openStart - 1, 0),
+ Math.max(slice.openEnd - 1, 0),
+ );
+}
diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts
deleted file mode 100644
index 9fa46b4315..0000000000
--- a/packages/core/src/api/nodeUtil.test.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-import { describe, expect, it, beforeEach } from "vitest";
-
-import { setupTestEnv } from "./blockManipulation/setupTestEnv.js";
-import { getBlocksChangedByTransaction } from "./nodeUtil.js";
-import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
-
-const getEditor = setupTestEnv();
-
-describe("Test getBlocksChangedByTransaction", () => {
- let editor: BlockNoteEditor;
-
- beforeEach(() => {
- editor = getEditor();
- });
-
- it("should return the correct blocks changed by a transaction", () => {
- const blocksChanged = editor.transact((tr) => {
- return getBlocksChangedByTransaction(tr);
- });
- expect(blocksChanged).toEqual([]);
- });
-
- it("should return blocks inserted by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.insertBlocks([{ type: "paragraph" }], "paragraph-0", "after");
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-inserted.json",
- );
- });
-
- it("should return nested blocks inserted by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.insertBlocks(
- [
- {
- type: "paragraph",
- children: [{ type: "paragraph", content: "Nested" }],
- },
- ],
- "paragraph-0",
- "after",
- );
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-inserted-nested.json",
- );
- });
-
- it("should return blocks deleted by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.removeBlocks(["paragraph-0"]);
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-deleted.json",
- );
- });
-
- it("should return deeply nested blocks deleted by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.removeBlocks(["double-nested-paragraph-0"]);
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-deleted-nested-deep.json",
- );
- });
-
- it("should return nested blocks deleted by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.removeBlocks(["nested-paragraph-0"]);
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-deleted-nested.json",
- );
- });
-
- it("should return blocks updated by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.updateBlock("paragraph-0", {
- props: {
- backgroundColor: "red",
- },
- });
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated.json",
- );
- });
-
- it("should return nested blocks updated by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.updateBlock("nested-paragraph-0", {
- props: {
- backgroundColor: "red",
- },
- });
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated-nested.json",
- );
- });
-
- it("should return deeply nested blocks updated by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.updateBlock("double-nested-paragraph-0", {
- content: "Example Text",
- });
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated-nested-deep.json",
- );
- });
-
- it("should return multiple nested blocks updated by a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.updateBlock("nested-paragraph-0", {
- props: {
- backgroundColor: "red",
- },
- });
- editor.updateBlock("double-nested-paragraph-0", {
- content: "Example Text",
- });
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated-nested-multiple.json",
- );
- });
-
- it("should only return a single block, if multiple updates change a single block in a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.updateBlock("paragraph-0", {
- props: {
- backgroundColor: "red",
- },
- });
- editor.updateBlock("paragraph-0", {
- props: {
- backgroundColor: "blue",
- },
- });
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated-single.json",
- );
- });
-
- it("should return multiple blocks, if multiple updates change multiple blocks in a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.updateBlock("paragraph-0", {
- props: {
- backgroundColor: "red",
- },
- });
- editor.updateBlock("paragraph-1", {
- props: {
- backgroundColor: "blue",
- },
- });
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated-multiple.json",
- );
- });
-
- it("should return multiple blocks, if multiple inserts add new blocks in a transaction", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.insertBlocks(
- [{ type: "paragraph", content: "ABC" }],
- "paragraph-0",
- "after",
- );
- editor.insertBlocks(
- [{ type: "paragraph", content: "DEF" }],
- "paragraph-1",
- "after",
- );
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated-multiple-insert.json",
- );
- });
-
- it("should return blocks which have had content inserted into them", async () => {
- const blocksChanged = editor.transact((tr) => {
- editor.setTextCursorPosition("paragraph-2", "start");
- editor.insertInlineContent("Hello");
-
- return getBlocksChangedByTransaction(tr);
- });
-
- await expect(blocksChanged).toMatchFileSnapshot(
- "__snapshots__/blocks-updated-content-inserted.json",
- );
- });
-});
diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts
index 45e3556fe3..3388c95413 100644
--- a/packages/core/src/api/nodeUtil.ts
+++ b/packages/core/src/api/nodeUtil.ts
@@ -1,21 +1,4 @@
-import {
- combineTransactionSteps,
- findChildrenInRange,
- getChangedRanges,
-} from "@tiptap/core";
import type { Node } from "prosemirror-model";
-import type { Transaction } from "prosemirror-state";
-import {
- Block,
- DefaultBlockSchema,
- DefaultInlineContentSchema,
- DefaultStyleSchema,
-} from "../blocks/defaultBlocks.js";
-import type { BlockSchema } from "../schema/index.js";
-import type { InlineContentSchema } from "../schema/inlineContent/types.js";
-import type { StyleSchema } from "../schema/styles/types.js";
-import { nodeToBlock } from "./nodeConversions/nodeToBlock.js";
-import { getPmSchema } from "./pmUtil.js";
/**
* Get a TipTap node by id
@@ -57,215 +40,3 @@ export function getNodeById(
export function isNodeBlock(node: Node): boolean {
return node.type.isInGroup("bnBlock");
}
-
-/**
- * This attributes the changes to a specific source.
- */
-export type BlockChangeSource =
- | {
- /**
- * When an event is triggered by the local user, the source is "local".
- * This is the default source.
- */
- type: "local";
- }
- | {
- /**
- * When an event is triggered by a paste operation, the source is "paste".
- */
- type: "paste";
- }
- | {
- /**
- * When an event is triggered by a drop operation, the source is "drop".
- */
- type: "drop";
- }
- | {
- /**
- * When an event is triggered by an undo or redo operation, the source is "undo" or "redo".
- * @note Y.js undo/redo are not differentiated.
- */
- type: "undo" | "redo" | "undo-redo";
- }
- | {
- /**
- * When an event is triggered by a remote user, the source is "remote".
- */
- type: "yjs-remote";
- };
-
-export type BlocksChanged<
- BSchema extends BlockSchema = DefaultBlockSchema,
- ISchema extends InlineContentSchema = DefaultInlineContentSchema,
- SSchema extends StyleSchema = DefaultStyleSchema,
-> = Array<
- {
- /**
- * The affected block.
- */
- block: Block;
- /**
- * The source of the change.
- */
- source: BlockChangeSource;
- } & (
- | {
- type: "insert" | "delete";
- /**
- * Insert and delete changes don't have a previous block.
- */
- prevBlock: undefined;
- }
- | {
- type: "update";
- /**
- * The block before the update.
- */
- prevBlock: Block;
- }
- )
->;
-
-/**
- * Compares two blocks, ignoring their children.
- * Returns true if the blocks are different (excluding children).
- */
-function areBlocksDifferentExcludingChildren<
- BSchema extends BlockSchema,
- ISchema extends InlineContentSchema,
- SSchema extends StyleSchema,
->(
- block1: Block,
- block2: Block,
-): boolean {
- // TODO use an actual diff algorithm
- // Compare all properties except children
- return (
- block1.id !== block2.id ||
- block1.type !== block2.type ||
- JSON.stringify(block1.props) !== JSON.stringify(block2.props) ||
- JSON.stringify(block1.content) !== JSON.stringify(block2.content)
- );
-}
-
-/**
- * Get the blocks that were changed by a transaction.
- * @param transaction The transaction to get the changes from.
- * @param editor The editor to get the changes from.
- * @returns The blocks that were changed by the transaction.
- */
-export function getBlocksChangedByTransaction<
- BSchema extends BlockSchema = DefaultBlockSchema,
- ISchema extends InlineContentSchema = DefaultInlineContentSchema,
- SSchema extends StyleSchema = DefaultStyleSchema,
->(
- transaction: Transaction,
- appendedTransactions: Transaction[] = [],
-): BlocksChanged {
- let source: BlockChangeSource = { type: "local" };
-
- if (transaction.getMeta("paste")) {
- source = { type: "paste" };
- } else if (transaction.getMeta("uiEvent") === "drop") {
- source = { type: "drop" };
- } else if (transaction.getMeta("history$")) {
- source = {
- type: transaction.getMeta("history$").redo ? "redo" : "undo",
- };
- } else if (transaction.getMeta("y-sync$")) {
- if (transaction.getMeta("y-sync$").isUndoRedoOperation) {
- source = {
- type: "undo-redo",
- };
- } else {
- source = {
- type: "yjs-remote",
- };
- }
- }
-
- // Get affected blocks before and after the change
- const pmSchema = getPmSchema(transaction);
- const combinedTransaction = combineTransactionSteps(transaction.before, [
- transaction,
- ...appendedTransactions,
- ]);
-
- const changedRanges = getChangedRanges(combinedTransaction);
- const prevAffectedBlocks = changedRanges
- .flatMap((range) => {
- return findChildrenInRange(
- combinedTransaction.before,
- range.oldRange,
- isNodeBlock,
- );
- })
- .map(({ node }) => nodeToBlock(node, pmSchema));
-
- const nextAffectedBlocks = changedRanges
- .flatMap((range) => {
- return findChildrenInRange(
- combinedTransaction.doc,
- range.newRange,
- isNodeBlock,
- );
- })
- .map(({ node }) => nodeToBlock(node, pmSchema));
-
- const nextBlocks = new Map(
- nextAffectedBlocks.map((block) => {
- return [block.id, block];
- }),
- );
- const prevBlocks = new Map(
- prevAffectedBlocks.map((block) => {
- return [block.id, block];
- }),
- );
-
- const changes: BlocksChanged = [];
-
- // Inserted blocks are blocks that were not in the previous state and are in the next state
- for (const [id, block] of nextBlocks) {
- if (!prevBlocks.has(id)) {
- changes.push({
- type: "insert",
- block,
- source,
- prevBlock: undefined,
- });
- }
- }
-
- // Deleted blocks are blocks that were in the previous state but not in the next state
- for (const [id, block] of prevBlocks) {
- if (!nextBlocks.has(id)) {
- changes.push({
- type: "delete",
- block,
- source,
- prevBlock: undefined,
- });
- }
- }
-
- // Updated blocks are blocks that were in the previous state and are in the next state
- for (const [id, block] of nextBlocks) {
- if (prevBlocks.has(id)) {
- const prevBlock = prevBlocks.get(id)!;
-
- // Only include the update if the block itself changed (excluding children)
- if (areBlocksDifferentExcludingChildren(prevBlock, block)) {
- changes.push({
- type: "update",
- block,
- prevBlock,
- source,
- });
- }
- }
- }
-
- return changes;
-}
diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts
index 726805b81d..16e03f883a 100644
--- a/packages/core/src/api/parsers/html/parseHTML.ts
+++ b/packages/core/src/api/parsers/html/parseHTML.ts
@@ -8,12 +8,15 @@ import {
import { Block } from "../../../blocks/defaultBlocks.js";
import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js";
import { nestedListsToBlockNoteStructure } from "./util/nestedLists.js";
-export async function HTMLToBlocks<
+import { preprocessHTMLWhitespace } from "./util/normalizeWhitespace.js";
+
+export function HTMLToBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
->(html: string, pmSchema: Schema): Promise[]> {
+>(html: string, pmSchema: Schema): Block[] {
const htmlNode = nestedListsToBlockNoteStructure(html);
+ preprocessHTMLWhitespace(htmlNode);
const parser = DOMParser.fromSchema(pmSchema);
// Other approach might be to use
diff --git a/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap
index 68c0a1c817..1db488255b 100644
--- a/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap
+++ b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap
@@ -1,129 +1,144 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Lift nested lists > Lifts multiple bullet lists 1`] = `
-"
-
-
-
Bullet List Item 1
-
-
- Nested Bullet List Item 1
- Nested Bullet List Item 2
-
-
- Nested Bullet List Item 3
- Nested Bullet List Item 4
-
-
-
- Bullet List Item 2
-
-"
+"
+
+ Bullet List Item 1
+
+
+ Nested Bullet List Item 1
+
+
+ Nested Bullet List Item 2
+
+
+
+ Nested Bullet List Item 3
+
+
+ Nested Bullet List Item 4
+
+
+
+ Bullet List Item 2
+
+ "
`;
exports[`Lift nested lists > Lifts multiple bullet lists with content in between 1`] = `
-"
-
-
-
Bullet List Item 1
-
-
- Nested Bullet List Item 1
- Nested Bullet List Item 2
-
-
-
-
-
In between content
-
-
- Nested Bullet List Item 3
- Nested Bullet List Item 4
-
-
-
- Bullet List Item 2
-
-"
+"
+
+ Bullet List Item 1
+
+
+ Nested Bullet List Item 1
+
+
+ Nested Bullet List Item 2
+
+
+ In between content
+
+
+ Nested Bullet List Item 3
+
+
+ Nested Bullet List Item 4
+
+
+
+ Bullet List Item 2
+
+ "
`;
exports[`Lift nested lists > Lifts nested bullet lists 1`] = `
-"
-
-
-
Bullet List Item 1
-
-
- Nested Bullet List Item 1
- Nested Bullet List Item 2
-
-
-
- Bullet List Item 2
-
-"
+"
+
+ Bullet List Item 1
+
+
+ Nested Bullet List Item 1
+
+
+ Nested Bullet List Item 2
+
+
+
+ Bullet List Item 2
+
+ "
`;
exports[`Lift nested lists > Lifts nested bullet lists with content after nested list 1`] = `
-"
-
-
-
Bullet List Item 1
-
-
- Nested Bullet List Item 1
- Nested Bullet List Item 2
-
-
-
- More content in list item 1
- Bullet List Item 2
-
-"
+"
+
+ Bullet List Item 1
+
+
+ Nested Bullet List Item 1
+
+
+ Nested Bullet List Item 2
+
+
+ More content in list item 1
+
+
+ Bullet List Item 2
+
+ "
`;
exports[`Lift nested lists > Lifts nested bullet lists without li 1`] = `
-"
-Bullet List Item 1
-
- Nested Bullet List Item 1
- Nested Bullet List Item 2
-
- Bullet List Item 2
-
-"
+"
+ Bullet List Item 1
+
+
+ Nested Bullet List Item 1
+
+
+ Nested Bullet List Item 2
+
+
+
+ Bullet List Item 2
+
+ "
`;
exports[`Lift nested lists > Lifts nested mixed lists 1`] = `
-"
-
-
-
Numbered List Item 1
-
-
- Bullet List Item 1
- Bullet List Item 2
-
-
-
- Numbered List Item 2
-
-"
+"
+
+ Numbered List Item 1
+
+
+ Bullet List Item 1
+
+
+ Bullet List Item 2
+
+
+
+ Numbered List Item 2
+
+ "
`;
exports[`Lift nested lists > Lifts nested numbered lists 1`] = `
-"
-
-
-
Numbered List Item 1
-
-
- Nested Numbered List Item 1
- Nested Numbered List Item 2
-
-
-
- Numbered List Item 2
-
-"
+"
+
+ Numbered List Item 1
+
+
+ Nested Numbered List Item 1
+
+
+ Nested Numbered List Item 2
+
+
+
+ Numbered List Item 2
+
+ "
`;
diff --git a/packages/core/src/api/parsers/html/util/nestedLists.test.ts b/packages/core/src/api/parsers/html/util/nestedLists.test.ts
index 70c96eda65..e695efa9c4 100644
--- a/packages/core/src/api/parsers/html/util/nestedLists.test.ts
+++ b/packages/core/src/api/parsers/html/util/nestedLists.test.ts
@@ -1,20 +1,9 @@
import { describe, expect, it } from "vitest";
-import { initializeESMDependencies } from "../../../../util/esmDependencies.js";
import { nestedListsToBlockNoteStructure } from "./nestedLists.js";
async function testHTML(html: string) {
- const deps = await initializeESMDependencies();
-
const htmlNode = nestedListsToBlockNoteStructure(html);
-
- const pretty = await deps.unified
- .unified()
- .use(deps.rehypeParse.default, { fragment: true })
- .use(deps.rehypeFormat.default)
- .use(deps.rehypeStringify.default)
- .process(htmlNode.innerHTML);
-
- expect(pretty.value).toMatchSnapshot();
+ expect(htmlNode.innerHTML).toMatchSnapshot();
}
describe("Lift nested lists", () => {
diff --git a/packages/core/src/api/parsers/html/util/normalizeWhitespace.ts b/packages/core/src/api/parsers/html/util/normalizeWhitespace.ts
new file mode 100644
index 0000000000..9cacd86e15
--- /dev/null
+++ b/packages/core/src/api/parsers/html/util/normalizeWhitespace.ts
@@ -0,0 +1,87 @@
+/**
+ * Checks if the given HTML element contains markers indicating it was
+ * generated by Notion. Notion uses `\n` in text nodes to represent hard
+ * breaks, which is non-standard but intentional.
+ *
+ * Detected by the `` comment that Notion places
+ * on the clipboard.
+ */
+function isNotionHTML(element: HTMLElement): boolean {
+ const walker = element.ownerDocument.createTreeWalker(
+ element,
+ // NodeFilter.SHOW_COMMENT
+ 128,
+ );
+
+ let node: Node | null;
+ while ((node = walker.nextNode())) {
+ if (/^\s*notionvc:/.test(node.nodeValue || "")) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Normalizes whitespace in text nodes by collapsing runs of whitespace
+ * (including newlines) to single spaces, matching CSS white-space:normal
+ * behavior.
+ *
+ * This is needed because ProseMirror's DOMParser, when `linebreakReplacement`
+ * is set in the schema (as BlockNote does for hard breaks), converts `\n`
+ * characters in text nodes to hard break nodes instead of collapsing them.
+ * This causes HTML source line wrapping (e.g. from MS Word) to create
+ * visible line breaks in the editor.
+ *
+ * Skipped for sources like Notion that intentionally use `\n` in text nodes
+ * to represent hard breaks instead of ` ` tags.
+ *
+ * Skips `` and `` elements where whitespace should be preserved.
+ */
+function normalizeTextNodeWhitespace(element: HTMLElement) {
+ const preserveWSTags = new Set(["PRE", "CODE"]);
+ const walker = element.ownerDocument.createTreeWalker(
+ element,
+ // NodeFilter.SHOW_TEXT
+ 4,
+ {
+ acceptNode(node) {
+ // Skip text nodes inside pre/code elements
+ let parent = node.parentElement;
+ while (parent && parent !== element) {
+ if (preserveWSTags.has(parent.tagName)) {
+ // NodeFilter.FILTER_REJECT
+ return 2;
+ }
+ parent = parent.parentElement;
+ }
+ // NodeFilter.FILTER_ACCEPT
+ return 1;
+ },
+ },
+ );
+
+ const textNodes: Text[] = [];
+ let node: Node | null;
+ while ((node = walker.nextNode())) {
+ textNodes.push(node as Text);
+ }
+
+ for (const textNode of textNodes) {
+ if (textNode.nodeValue && /[\r\n]/.test(textNode.nodeValue)) {
+ textNode.nodeValue = textNode.nodeValue.replace(/[ \t\r\n\f]+/g, " ");
+ }
+ }
+}
+
+/**
+ * Normalizes whitespace in HTML text nodes to match standard CSS
+ * white-space:normal behavior. Skipped for Notion HTML which intentionally
+ * uses `\n` for hard breaks.
+ */
+export function preprocessHTMLWhitespace(element: HTMLElement) {
+ if (!isNotionHTML(element)) {
+ normalizeTextNodeWhitespace(element);
+ }
+}
diff --git a/packages/core/src/api/parsers/markdown/detectMarkdown.test.ts b/packages/core/src/api/parsers/markdown/detectMarkdown.test.ts
new file mode 100644
index 0000000000..45c416bacb
--- /dev/null
+++ b/packages/core/src/api/parsers/markdown/detectMarkdown.test.ts
@@ -0,0 +1,211 @@
+import { describe, expect, it } from "vitest";
+import { isMarkdown } from "./detectMarkdown.js";
+
+describe("isMarkdown", () => {
+ describe("Headings (H1-H6)", () => {
+ it("should detect H1 headings", () => {
+ expect(isMarkdown("# Heading 1\n\nContent")).toBe(true);
+ expect(isMarkdown(" # Heading 1\n\nContent")).toBe(true);
+ expect(isMarkdown(" # Heading 1\n\nContent")).toBe(true);
+ });
+
+ it("should detect H2-H6 headings", () => {
+ expect(isMarkdown("## Heading 2\n\nContent")).toBe(true);
+ expect(isMarkdown("### Heading 3\n\nContent")).toBe(true);
+ expect(isMarkdown("#### Heading 4\n\nContent")).toBe(true);
+ expect(isMarkdown("##### Heading 5\n\nContent")).toBe(true);
+ expect(isMarkdown("###### Heading 6\n\nContent")).toBe(true);
+ });
+
+ it("should not detect invalid headings", () => {
+ expect(isMarkdown("####### Heading 7\n\nContent")).toBe(false);
+ expect(isMarkdown("#Heading without space\n\nContent")).toBe(false);
+ expect(isMarkdown("# \n\nContent")).toBe(false);
+ expect(
+ isMarkdown(
+ "# Very long heading that exceeds the character limit and should not be detected as a valid markdown heading\n\nContent",
+ ),
+ ).toBe(false);
+ });
+ });
+
+ describe("Bold, italic, underline, strikethrough, highlight", () => {
+ it("should detect bold text", () => {
+ expect(isMarkdown("**bold text**")).toBe(true);
+ expect(isMarkdown("__bold text__")).toBe(true);
+ expect(isMarkdown(" *bold text* ")).toBe(true);
+ expect(isMarkdown(" _bold text_ ")).toBe(true);
+ });
+
+ it("should detect italic text", () => {
+ expect(isMarkdown("*italic text*")).toBe(true);
+ expect(isMarkdown("_italic text_")).toBe(true);
+ });
+
+ it("should detect strikethrough text", () => {
+ expect(isMarkdown("~~strikethrough text~~")).toBe(true);
+ });
+
+ it("should detect highlighted text", () => {
+ expect(isMarkdown("==highlighted text==")).toBe(true);
+ expect(isMarkdown("++highlighted text++")).toBe(true);
+ });
+ });
+
+ describe("Links", () => {
+ it("should detect basic links", () => {
+ expect(isMarkdown("[Link text](https://example.com)")).toBe(true);
+ expect(isMarkdown("[Link text](http://example.com)")).toBe(true);
+ expect(isMarkdown("[Short](https://ex.com)")).toBe(true);
+ });
+
+ it("should detect image links", () => {
+ expect(isMarkdown("")).toBe(
+ true,
+ );
+ });
+ });
+
+ describe("Inline code", () => {
+ it("should detect inline code", () => {
+ expect(isMarkdown("`code`")).toBe(true);
+ expect(isMarkdown(" `code` ")).toBe(true);
+ expect(isMarkdown("`const x = 1;`")).toBe(true);
+ });
+
+ it("should not detect invalid inline code", () => {
+ expect(isMarkdown("` code `")).toBe(false); // spaces around content
+ expect(isMarkdown("``")).toBe(false); // empty
+ expect(isMarkdown("` `")).toBe(false); // only space
+ });
+ });
+
+ describe("Unordered lists", () => {
+ it("should detect unordered lists", () => {
+ expect(isMarkdown("- Item 1\n- Item 2")).toBe(true);
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
+ expect(isMarkdown(" - Item 1\n - Item 2")).toBe(true);
+ });
+
+ it("should not detect invalid unordered lists", () => {
+ expect(isMarkdown("- Item 1")).toBe(false); // single item
+ expect(isMarkdown("-- Item 1\n-- Item 2")).toBe(false); // wrong marker
+ expect(isMarkdown("-Item 1\n-Item 2")).toBe(false); // no space after marker
+ });
+ });
+
+ describe("Ordered lists", () => {
+ it("should detect ordered lists", () => {
+ expect(isMarkdown("1. Item 1\n2. Item 2")).toBe(true);
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
+ expect(isMarkdown(" 1. Item 1\n 2. Item 2")).toBe(true);
+ });
+
+ it("should not detect invalid ordered lists", () => {
+ expect(isMarkdown("1. Item 1")).toBe(false); // single item
+ expect(isMarkdown("1 Item 1\n2 Item 2")).toBe(false); // no dot
+ expect(isMarkdown("1.Item 1\n2.Item 2")).toBe(false); // no space after dot
+ });
+ });
+
+ describe("Horizontal rules", () => {
+ it("should detect horizontal rules", () => {
+ expect(isMarkdown("\n\n ---\n\n")).toBe(true);
+ expect(isMarkdown("\n\n ----\n\n")).toBe(true);
+ expect(isMarkdown("\n\n ---\n\n")).toBe(true);
+ expect(isMarkdown("\n\n ---\n\n")).toBe(true);
+ });
+ });
+
+ describe("Fenced code blocks", () => {
+ it("should detect fenced code blocks", () => {
+ expect(isMarkdown("```\ncode block\n```")).toBe(true);
+ expect(isMarkdown("~~~\ncode block\n~~~")).toBe(true);
+ expect(isMarkdown("```javascript\nconst x = 1;\n```")).toBe(true);
+ expect(isMarkdown("```js\nconst x = 1;\n```")).toBe(true);
+ });
+ });
+
+ describe("Classical underlined headings", () => {
+ it("should detect H1 with equals", () => {
+ expect(isMarkdown("Heading\n===\n\nContent")).toBe(true);
+ expect(isMarkdown("Heading\n====\n\nContent")).toBe(true);
+ });
+
+ it("should detect H2 with dashes", () => {
+ expect(isMarkdown("Heading\n---\n\nContent")).toBe(true);
+ expect(isMarkdown("Heading\n----\n\nContent")).toBe(true);
+ });
+ });
+
+ describe("Blockquotes", () => {
+ it("should detect blockquotes", () => {
+ expect(isMarkdown("> This is a blockquote\n\nContent")).toBe(true);
+ expect(isMarkdown(" > This is a blockquote\n\nContent")).toBe(true);
+ expect(isMarkdown(" > This is a blockquote\n\nContent")).toBe(true);
+ expect(isMarkdown(" > This is a blockquote\n\nContent")).toBe(true);
+ });
+
+ it("should detect multi-line blockquotes", () => {
+ expect(isMarkdown("> Line 1\n> Line 2\n\nContent")).toBe(true);
+ expect(isMarkdown("> Line 1\n> Line 2\n> Line 3\n\nContent")).toBe(true);
+ });
+ });
+
+ describe("Tables", () => {
+ it("should detect table headers", () => {
+ expect(isMarkdown("| Header 1 | Header 2 |\n")).toBe(true);
+ expect(isMarkdown("| Header 1 | Header 2 | Header 3 |\n")).toBe(true);
+ });
+
+ it("should detect table dividers", () => {
+ expect(isMarkdown("| --- | --- |\n")).toBe(true);
+ expect(isMarkdown("| :--- | ---: |\n")).toBe(true);
+ expect(isMarkdown("| :---: | --- |\n")).toBe(true);
+ });
+
+ it("should detect table rows", () => {
+ expect(isMarkdown("| Cell 1 | Cell 2 |\n")).toBe(true);
+ expect(isMarkdown("| Cell 1 | Cell 2 | Cell 3 |\n")).toBe(true);
+ });
+
+ it("should detect complete tables", () => {
+ const table =
+ "| Header 1 | Header 2 |\n| --- | --- |\n| Cell 1 | Cell 2 |\n";
+ expect(isMarkdown(table)).toBe(true);
+ });
+
+ it("should not detect invalid tables", () => {
+ expect(isMarkdown("| Header 1 | Header 2\n")).toBe(false); // missing closing pipe
+ expect(isMarkdown("Header 1 | Header 2 |\n")).toBe(false); // missing opening pipe
+ });
+ });
+
+ describe("Edge cases and combinations", () => {
+ it("should detect mixed markdown content", () => {
+ const mixedContent =
+ "# Heading\n\nThis is **bold** and *italic* text with a [link](https://example.com).\n\n- List item 1\n- List item 2\n\n> Blockquote\n\n```\ncode block\n```";
+ expect(isMarkdown(mixedContent)).toBe(true);
+ });
+
+ it("should not detect plain text", () => {
+ expect(
+ isMarkdown("This is just plain text without any markdown formatting."),
+ ).toBe(false);
+ expect(isMarkdown("")).toBe(false);
+ expect(isMarkdown(" \n \n ")).toBe(false); // only whitespace
+ });
+
+ it("should handle special characters", () => {
+ expect(isMarkdown("**text with `backticks`**")).toBe(true);
+ expect(isMarkdown("**text with [brackets]**")).toBe(true);
+ expect(isMarkdown("**text with (parentheses)**")).toBe(true);
+ });
+ });
+});
diff --git a/packages/core/src/api/parsers/markdown/detectMarkdown.ts b/packages/core/src/api/parsers/markdown/detectMarkdown.ts
index 92734face4..be69f24c80 100644
--- a/packages/core/src/api/parsers/markdown/detectMarkdown.ts
+++ b/packages/core/src/api/parsers/markdown/detectMarkdown.ts
@@ -2,13 +2,14 @@
const h1 = /(^|\n) {0,3}#{1,6} {1,8}[^\n]{1,64}\r?\n\r?\n\s{0,32}\S/;
// Bold, italic, underline, strikethrough, highlight.
-const bold = /(?:\s|^)(_|__|\*|\*\*|~~|==|\+\+)(?!\s).{1,64}(?/g, ">")
+ .replace(/"/g, """);
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function isAlphanumeric(char: string | undefined): boolean {
+ if (!char) {
+ return false;
+ }
+ return /\w/.test(char);
+}
+
+/**
+ * Returns true when an underscore delimiter at position `i` is "intraword",
+ * meaning the characters on both sides are alphanumeric (e.g. `snake_case`).
+ * In that case the underscore should NOT be treated as emphasis per CommonMark.
+ */
+function isIntraword(text: string, i: number, delimLen: number): boolean {
+ const before = i > 0 ? text[i - 1] : undefined;
+ const after =
+ i + delimLen < text.length ? text[i + delimLen] : undefined;
+ return isAlphanumeric(before) && isAlphanumeric(after);
+}
+
+// ─── Inline Parser ───────────────────────────────────────────────────────────
+
+type InlineTokenizer = (
+ text: string,
+ i: number
+) => { html: string; end: number } | null;
+
+function tryBackslashEscape(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] !== "\\" || i + 1 >= text.length) {return null;}
+ const next = text[i + 1];
+ // Hard line break: backslash at end of line
+ if (next === "\n") {
+ return { html: " \n", end: i + 2 };
+ }
+ // Escapable characters
+ if ("\\`*_{}[]()#+-.!~|>".includes(next)) {
+ return { html: escapeHtml(next), end: i + 2 };
+ }
+ return null;
+}
+
+function tryInlineCode(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] !== "`") {return null;}
+ return parseInlineCode(text, i);
+}
+
+function tryImage(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] !== "!" || text[i + 1] !== "[") {return null;}
+ return parseImage(text, i);
+}
+
+function tryLink(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] !== "[") {return null;}
+ return parseLink(text, i);
+}
+
+function tryStrikethrough(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] !== "~" || text[i + 1] !== "~") {return null;}
+ return parseDelimited(text, i, "~~", "", "");
+}
+
+function tryBoldItalic(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (
+ (text[i] === "*" && text[i + 1] === "*" && text[i + 2] === "*") ||
+ (text[i] === "_" &&
+ text[i + 1] === "_" &&
+ text[i + 2] === "_" &&
+ !isIntraword(text, i, 3))
+ ) {
+ const delimiter = text.substring(i, i + 3);
+ return parseDelimited(text, i, delimiter, "", " ");
+ }
+ return null;
+}
+
+function tryBold(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (
+ (text[i] === "*" && text[i + 1] === "*") ||
+ (text[i] === "_" && text[i + 1] === "_" && !isIntraword(text, i, 2))
+ ) {
+ const delimiter = text.substring(i, i + 2);
+ return parseDelimited(text, i, delimiter, "", " ");
+ }
+ return null;
+}
+
+function tryItalic(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] === "*" || (text[i] === "_" && !isIntraword(text, i, 1))) {
+ return parseDelimited(text, i, text[i], "", " ");
+ }
+ return null;
+}
+
+function trySoftBreak(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] === "\n") {
+ return { html: " \n", end: i + 1 };
+ }
+ return null;
+}
+
+// Inline raw HTML: pass through tags, comments, CDATA, processing
+// instructions, and declarations verbatim so authors can mix HTML into
+// markdown (e.g. `text foo more`). Anything that doesn't match
+// these shapes falls through and gets HTML-escaped as plain text.
+const INLINE_HTML_TAG_RE =
+ /^<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][a-zA-Z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/;
+const HTML_COMMENT_RE = /^/;
+const HTML_CDATA_RE = /^/;
+const HTML_PI_RE = /^<\?[\s\S]*?\?>/;
+const HTML_DECL_RE = /^/;
+
+function tryInlineHtml(
+ text: string,
+ i: number
+): { html: string; end: number } | null {
+ if (text[i] !== "<") {return null;}
+ const rest = text.substring(i);
+ for (const re of [
+ HTML_COMMENT_RE,
+ HTML_CDATA_RE,
+ HTML_PI_RE,
+ HTML_DECL_RE,
+ INLINE_HTML_TAG_RE,
+ ]) {
+ const m = rest.match(re);
+ if (m) {
+ return { html: m[0], end: i + m[0].length };
+ }
+ }
+ return null;
+}
+
+/** Characters that can start an inline syntax token. */
+const SPECIAL_CHARS = new Set("\\`![~*_\n<");
+
+/**
+ * Ordered array of inline tokenizers, tried in priority order.
+ * The first match wins.
+ */
+const inlineTokenizers: InlineTokenizer[] = [
+ tryBackslashEscape,
+ tryInlineCode,
+ tryImage,
+ tryLink,
+ tryStrikethrough,
+ tryBoldItalic, // *** / ___
+ tryBold, // ** / __
+ tryItalic, // * / _
+ tryInlineHtml,
+ trySoftBreak,
+];
+
+/**
+ * Parse inline markdown syntax and return HTML.
+ * Handles: bold, italic, bold+italic, strikethrough, inline code,
+ * links, images (with video detection), hard line breaks, backslash escapes.
+ */
+function parseInline(text: string): string {
+ let result = "";
+ let i = 0;
+
+ while (i < text.length) {
+ // Hard line break: 2+ trailing spaces immediately before a newline.
+ // (The other hard-break form, backslash + newline, is handled by
+ // tryBackslashEscape.) Strip the trailing spaces from the accumulated
+ // result before emitting the .
+ if (
+ text[i] === "\n" &&
+ i >= 2 &&
+ text[i - 1] === " " &&
+ text[i - 2] === " "
+ ) {
+ result = result.replace(/ +$/, "");
+ result += " \n";
+ i++;
+ continue;
+ }
+
+ // Try each tokenizer in priority order
+ let matched = false;
+ if (SPECIAL_CHARS.has(text[i])) {
+ for (const tokenizer of inlineTokenizers) {
+ const r = tokenizer(text, i);
+ if (r) {
+ result += r.html;
+ i = r.end;
+ matched = true;
+ break;
+ }
+ }
+ }
+
+ if (!matched) {
+ // Batch consecutive plain-text characters and escape once
+ const runStart = i;
+ i++;
+ while (i < text.length && !SPECIAL_CHARS.has(text[i])) {
+ i++;
+ }
+ result += escapeHtml(text.substring(runStart, i));
+ }
+ }
+
+ return result;
+}
+
+function parseInlineCode(
+ text: string,
+ start: number
+): { html: string; end: number } | null {
+ // Count opening backticks
+ let openCount = 0;
+ let i = start;
+ while (i < text.length && text[i] === "`") {
+ openCount++;
+ i++;
+ }
+
+ // Find matching closing backticks
+ let j = i;
+ while (j < text.length) {
+ if (text[j] === "`") {
+ let closeCount = 0;
+ const closeStart = j;
+ while (j < text.length && text[j] === "`") {
+ closeCount++;
+ j++;
+ }
+ if (closeCount === openCount) {
+ let code = text.substring(i, closeStart);
+ // Per CommonMark: line endings inside a code span are converted to
+ // single spaces, then if the result starts AND ends with a space and
+ // is not all-spaces, one leading + trailing space is stripped (so
+ // `` ` `foo` ` `` is ``foo``).
+ code = code.replace(/\n/g, " ");
+ if (
+ code.length >= 2 &&
+ code[0] === " " &&
+ code[code.length - 1] === " " &&
+ /[^ ]/.test(code)
+ ) {
+ code = code.substring(1, code.length - 1);
+ }
+ return {
+ html: `${escapeHtml(code)}`,
+ end: j,
+ };
+ }
+ } else {
+ j++;
+ }
+ }
+ return null;
+}
+
+function parseImage(
+ text: string,
+ start: number
+): { html: string; end: number } | null {
+ //  or 
+ // Use balanced bracket matching to handle nested/escaped brackets in alt text
+ const altEnd = findClosingBracket(text, start + 1);
+ if (altEnd === -1) {return null;}
+ const altStart = start + 2; // after ![
+
+ if (text[altEnd + 1] !== "(") {return null;}
+
+ const urlStart = altEnd + 2;
+ const parenEnd = findClosingParen(text, urlStart - 1);
+ if (parenEnd === -1) {return null;}
+
+ const alt = text.substring(altStart, altEnd);
+ const { url, title } = parseDestinationAndTitle(
+ text.substring(urlStart, parenEnd),
+ );
+
+ if (isVideoUrl(url)) {
+ // Use the alt text as the video's display name (falling back to the
+ // title) so a video link written with the standard `` form
+ // round-trips into BlockNote's video block. Captioned videos go through
+ // raw `` HTML instead, see htmlToMarkdown.serializeMediaFigure.
+ const name = alt || title;
+ return {
+ html: ` `,
+ end: parenEnd + 1,
+ };
+ }
+
+ const titleAttr =
+ title !== undefined ? ` title="${escapeHtml(title)}"` : "";
+ return {
+ html: ` `,
+ end: parenEnd + 1,
+ };
+}
+
+function parseLink(
+ text: string,
+ start: number
+): { html: string; end: number } | null {
+ // [text](url)
+ const textStart = start + 1;
+ const textEnd = findClosingBracket(text, start);
+ if (textEnd === -1) {return null;}
+
+ if (text[textEnd + 1] !== "(") {return null;}
+
+ const urlStart = textEnd + 2;
+ const parenEnd = findClosingParen(text, textEnd + 1);
+ if (parenEnd === -1) {return null;}
+
+ const linkText = text.substring(textStart, textEnd);
+ const { url, title } = parseDestinationAndTitle(
+ text.substring(urlStart, parenEnd),
+ );
+
+ const titleAttr =
+ title !== undefined ? ` title="${escapeHtml(title)}"` : "";
+ return {
+ html: `${parseInline(linkText)} `,
+ end: parenEnd + 1,
+ };
+}
+
+function findClosingBracket(text: string, openPos: number): number {
+ let depth = 0;
+ for (let i = openPos; i < text.length; i++) {
+ if (text[i] === "\\" && i + 1 < text.length) {
+ i++; // skip escaped
+ continue;
+ }
+ if (text[i] === "[") {depth++;}
+ if (text[i] === "]") {
+ depth--;
+ if (depth === 0) {return i;}
+ }
+ }
+ return -1;
+}
+
+function findClosingParen(text: string, openPos: number): number {
+ let depth = 0;
+ for (let i = openPos; i < text.length; i++) {
+ if (text[i] === "\\" && i + 1 < text.length) {
+ i++;
+ continue;
+ }
+ if (text[i] === "(") {depth++;}
+ if (text[i] === ")") {
+ depth--;
+ if (depth === 0) {return i;}
+ }
+ }
+ return -1;
+}
+
+/**
+ * Parse the inside of `(...)` from a link/image (the URL and optional title).
+ * Handles three URL forms:
+ * - bare: `/uri` or `/uri "title"`
+ * - angle-bracket: `` or ` "title"` (brackets are stripped)
+ * And three title-quote forms: `"..."`, `'...'`, `(...)`.
+ */
+function parseDestinationAndTitle(raw: string): {
+ url: string;
+ title?: string;
+} {
+ raw = raw.trim();
+ let url: string;
+ let rest: string;
+
+ if (raw.startsWith("<")) {
+ const close = raw.indexOf(">");
+ if (close === -1) {
+ // Unmatched `<` — treat the whole thing as the URL minus the `<`.
+ url = raw.substring(1);
+ rest = "";
+ } else {
+ url = raw.substring(1, close);
+ rest = raw.substring(close + 1).trim();
+ }
+ } else {
+ // Split at first unescaped whitespace.
+ let split = raw.length;
+ for (let i = 0; i < raw.length; i++) {
+ if (raw[i] === "\\" && i + 1 < raw.length) {
+ i++;
+ continue;
+ }
+ if (raw[i] === " " || raw[i] === "\t" || raw[i] === "\n") {
+ split = i;
+ break;
+ }
+ }
+ url = raw.substring(0, split);
+ rest = raw.substring(split).trim();
+ }
+
+ let title: string | undefined;
+ if (rest.length > 0) {
+ const titleMatch = rest.match(/^"([^"]*)"$|^'([^']*)'$|^\(([^)]*)\)$/);
+ if (titleMatch) {
+ title = titleMatch[1] ?? titleMatch[2] ?? titleMatch[3];
+ }
+ }
+
+ return { url, title };
+}
+
+function parseDelimited(
+ text: string,
+ start: number,
+ delimiter: string,
+ openTag: string,
+ closeTag: string
+): { html: string; end: number } | null {
+ const len = delimiter.length;
+ const afterOpen = start + len;
+
+ if (afterOpen >= text.length) {return null;}
+
+ // Opening delimiter must not be followed by whitespace
+ if (text[afterOpen] === " " || text[afterOpen] === "\t") {return null;}
+
+ // Find closing delimiter
+ let j = afterOpen;
+ while (j < text.length) {
+ // Skip escaped characters
+ if (text[j] === "\\" && j + 1 < text.length) {
+ j += 2;
+ continue;
+ }
+
+ if (text.substring(j, j + len) === delimiter) {
+ // Closing delimiter must not be preceded by whitespace
+ if (text[j - 1] === " " || text[j - 1] === "\t") {
+ j++;
+ continue;
+ }
+
+ // For single-char delimiters, don't accept closer if it's part of a
+ // multi-char run (e.g., don't treat the * in ** as italic closer)
+ if (
+ len === 1 &&
+ ((j > 0 && text[j - 1] === delimiter[0] && !(j >= 2 && text[j - 2] === "\\")) ||
+ (j + len < text.length && text[j + len] === delimiter[0]))
+ ) {
+ j++;
+ continue;
+ }
+
+ const inner = text.substring(afterOpen, j);
+ if (inner.length === 0) {
+ j++;
+ continue;
+ }
+
+ return {
+ html: openTag + parseInline(inner) + closeTag,
+ end: j + len,
+ };
+ }
+ j++;
+ }
+
+ return null;
+}
+
+// ─── Block-Level Types ───────────────────────────────────────────────────────
+
+interface BlockToken {
+ type: string;
+}
+
+interface HeadingToken extends BlockToken {
+ type: "heading";
+ level: number;
+ content: string;
+}
+
+interface ParagraphToken extends BlockToken {
+ type: "paragraph";
+ content: string;
+}
+
+interface CodeBlockToken extends BlockToken {
+ type: "codeBlock";
+ language: string;
+ code: string;
+}
+
+interface BlockquoteToken extends BlockToken {
+ type: "blockquote";
+ content: string;
+}
+
+interface HorizontalRuleToken extends BlockToken {
+ type: "hr";
+}
+
+interface ListItemToken extends BlockToken {
+ type: "listItem";
+ listType: "bullet" | "ordered" | "task";
+ indent: number;
+ content: string;
+ start?: number; // for ordered lists
+ checked?: boolean; // for task lists
+ childContent?: string; // recursively parsed content within this item
+}
+
+interface TableToken extends BlockToken {
+ type: "table";
+ headers: string[];
+ rows: string[][];
+ alignments: ("left" | "center" | "right" | null)[];
+}
+
+interface RawHtmlToken extends BlockToken {
+ type: "rawHtml";
+ content: string;
+}
+
+type Token =
+ | HeadingToken
+ | ParagraphToken
+ | CodeBlockToken
+ | BlockquoteToken
+ | HorizontalRuleToken
+ | ListItemToken
+ | TableToken
+ | RawHtmlToken;
+
+/**
+ * HTML block-level tag names (from the CommonMark type-6 list, plus `audio`
+ * which BlockNote serializes as raw HTML since markdown has no shorthand
+ * for it). When a line starts with `<` followed by one of these tag names,
+ * the run of non-blank lines is emitted verbatim as raw HTML rather than
+ * wrapped in a paragraph.
+ */
+const HTML_BLOCK_TAGS = new Set([
+ "address", "article", "aside", "audio", "base", "basefont", "blockquote",
+ "body", "caption", "center", "col", "colgroup", "dd", "details", "dialog",
+ "dir", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer",
+ "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head",
+ "header", "hr", "html", "iframe", "legend", "li", "link", "main", "menu",
+ "menuitem", "nav", "noframes", "ol", "optgroup", "option", "p", "param",
+ "section", "source", "summary", "table", "tbody", "td", "tfoot", "th",
+ "thead", "title", "tr", "track", "ul",
+]);
+
+function isHtmlBlockStart(line: string): boolean {
+ // `, `...?>`, ``, or ``.
+ // Lines are emitted verbatim until the next blank line.
+ if (isHtmlBlockStart(line)) {
+ const htmlLines: string[] = [];
+ while (i < lines.length && lines[i].trim() !== "") {
+ htmlLines.push(lines[i]);
+ i++;
+ }
+ tokens.push({
+ type: "rawHtml",
+ content: htmlLines.join("\n"),
+ });
+ prevLineWasBlank = false;
+ continue;
+ }
+
+ // Paragraph (default)
+ const paraLines: string[] = [line];
+ i++;
+ while (i < lines.length) {
+ const nextLine = lines[i];
+ // Stop paragraph on blank line
+ if (nextLine.trim() === "") {break;}
+ // Stop on block-level element
+ if (/^(#{1,6})\s/.test(nextLine)) {break;}
+ if (/^(`{3,}|~{3,})/.test(nextLine)) {break;}
+ if (/^\s{0,3}>/.test(nextLine)) {break;}
+ if (/^(\s{0,3})([-*_])\s*(\2\s*){2,}$/.test(nextLine)) {break;}
+ if (/^\s*([-*+]|\d+[.)])\s+/.test(nextLine)) {break;}
+ if (/^\s*\|(.+\|)+\s*$/.test(nextLine)) {break;}
+ if (isHtmlBlockStart(nextLine)) {break;}
+ // Check if next-next line is setext marker
+ if (
+ i + 1 < lines.length &&
+ /^[=-]+\s*$/.test(lines[i + 1]) &&
+ nextLine.trim().length > 0
+ ) {
+ break;
+ }
+ paraLines.push(nextLine);
+ i++;
+ }
+ // CommonMark allows up to 3 leading spaces of indent on paragraph lines.
+ // Also strip trailing whitespace from the final line so a trailing
+ // hard-break sequence (` \n` at end of paragraph) doesn't leak as
+ // literal trailing spaces in the rendered output.
+ tokens.push({
+ type: "paragraph",
+ content: paraLines
+ .map((l) => l.replace(/^ {1,3}/, ""))
+ .join("\n")
+ .replace(/[ \t]+$/, ""),
+ });
+ prevLineWasBlank = false;
+ }
+
+ return tokens;
+}
+
+function tryParseTable(
+ lines: string[],
+ start: number
+): { token: TableToken; nextLine: number } | null {
+ // A table needs at least a header row and a separator row
+ if (start + 1 >= lines.length) {return null;}
+
+ const headerLine = lines[start];
+ const separatorLine = lines[start + 1];
+
+ // Check separator line format: | --- | --- | or --- | --- (outer pipes optional)
+ // Must contain at least one pipe and only dashes, colons, pipes, and whitespace
+ if (
+ !separatorLine.includes("|") ||
+ !/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(separatorLine)
+ ) {return null;}
+
+ // Check header line has at least one pipe (required to distinguish from plain text)
+ if (!headerLine.includes("|")) {return null;}
+
+ const headers = parsePipeCells(headerLine);
+ const alignments = parseAlignments(separatorLine);
+
+ const rows: string[][] = [];
+ let i = start + 2;
+ while (i < lines.length) {
+ const line = lines[i];
+ if (!line.includes("|")) {break;}
+ rows.push(parsePipeCells(line));
+ i++;
+ }
+
+ return {
+ token: {
+ type: "table",
+ headers,
+ rows,
+ alignments,
+ },
+ nextLine: i,
+ };
+}
+
+function parsePipeCells(line: string): string[] {
+ // Trim leading/trailing pipes and split
+ const trimmed = line.trim();
+ const withoutOuterPipes = trimmed.startsWith("|")
+ ? trimmed.substring(1)
+ : trimmed;
+ const content = withoutOuterPipes.endsWith("|")
+ ? withoutOuterPipes.substring(0, withoutOuterPipes.length - 1)
+ : withoutOuterPipes;
+
+ // Split by pipes, handling escaped pipes
+ const cells: string[] = [];
+ let current = "";
+ for (let i = 0; i < content.length; i++) {
+ if (content[i] === "\\" && i + 1 < content.length && content[i + 1] === "|") {
+ current += "|";
+ i++;
+ } else if (content[i] === "|") {
+ cells.push(current.trim());
+ current = "";
+ } else {
+ current += content[i];
+ }
+ }
+ cells.push(current.trim());
+
+ return cells;
+}
+
+function parseAlignments(
+ separatorLine: string
+): ("left" | "center" | "right" | null)[] {
+ const cells = parsePipeCells(separatorLine);
+ return cells.map((cell) => {
+ const trimmed = cell.trim();
+ const left = trimmed.startsWith(":");
+ const right = trimmed.endsWith(":");
+ if (left && right) {return "center";}
+ if (right) {return "right";}
+ if (left) {return "left";}
+ return null;
+ });
+}
+
+// ─── HTML Emitter ────────────────────────────────────────────────────────────
+
+function tokensToHtml(tokens: Token[]): string {
+ let html = "";
+ let i = 0;
+
+ while (i < tokens.length) {
+ const token = tokens[i];
+
+ switch (token.type) {
+ case "heading": {
+ const t = token as HeadingToken;
+ html += `${parseInline(t.content)} `;
+ i++;
+ break;
+ }
+
+ case "paragraph": {
+ const t = token as ParagraphToken;
+ html += `${parseInline(t.content)}
`;
+ i++;
+ break;
+ }
+
+ case "codeBlock": {
+ const t = token as CodeBlockToken;
+ const langAttr = t.language
+ ? ` data-language="${escapeHtml(t.language)}"`
+ : "";
+ html += `${escapeHtml(t.code)} `;
+ i++;
+ break;
+ }
+
+ case "blockquote": {
+ const t = token as BlockquoteToken;
+ // Recursively parse blockquote content as markdown
+ const innerTokens = tokenize(t.content);
+ const innerHtml = tokensToHtml(innerTokens);
+ html += `${innerHtml} `;
+ i++;
+ break;
+ }
+
+ case "hr":
+ html += ` `;
+ i++;
+ break;
+
+ case "listItem": {
+ // Collect consecutive list items and build nested list structure
+ const listHtml = emitListItems(tokens, i);
+ html += listHtml.html;
+ i = listHtml.nextIndex;
+ break;
+ }
+
+ case "table": {
+ const t = token as TableToken;
+ html += emitTable(t);
+ i++;
+ break;
+ }
+
+ case "rawHtml": {
+ const t = token as RawHtmlToken;
+ html += t.content;
+ i++;
+ break;
+ }
+
+ default:
+ i++;
+ }
+ }
+
+ return html;
+}
+
+function emitListItems(
+ tokens: Token[],
+ startIdx: number
+): { html: string; nextIndex: number } {
+ let html = "";
+ let i = startIdx;
+ let currentListType: "bullet" | "ordered" | null = null;
+
+ while (i < tokens.length && tokens[i].type === "listItem") {
+ const item = tokens[i] as ListItemToken;
+ const effectiveType = getEffectiveListType(item.listType);
+
+ // Check if we need to switch list type
+ if (currentListType !== null && currentListType !== effectiveType) {
+ // Close current list, open new one
+ html += `${currentListType === "ordered" ? "ol" : "ul"}>`;
+ currentListType = null;
+ }
+
+ // Open list if needed
+ if (currentListType === null) {
+ if (effectiveType === "ordered") {
+ const startAttr =
+ item.start !== undefined && item.start !== 1
+ ? ` start="${item.start}"`
+ : "";
+ html += ``;
+ } else {
+ html += ``;
+ }
+ currentListType = effectiveType;
+ }
+
+ // Emit list item
+ if (item.listType === "task") {
+ const checkedAttr = item.checked ? " checked" : "";
+ html += `${parseInline(item.content)}
`;
+ } else {
+ html += `${parseInline(item.content)}
`;
+ }
+
+ // Render child content (nested items, continuation paragraphs, etc.)
+ if (item.childContent) {
+ const childTokens = tokenize(item.childContent);
+ html += tokensToHtml(childTokens);
+ }
+
+ html += ` `;
+ i++;
+ }
+
+ // Close the list
+ if (currentListType !== null) {
+ html += `${currentListType === "ordered" ? "ol" : "ul"}>`;
+ }
+
+ return { html, nextIndex: i };
+}
+
+function getEffectiveListType(
+ listType: "bullet" | "ordered" | "task"
+): "bullet" | "ordered" {
+ return listType === "ordered" ? "ordered" : "bullet";
+}
+
+function emitTable(table: TableToken): string {
+ let html = "";
+
+ // BlockNote tables have no required header row, but the markdown table
+ // syntax does. When we serialize a headerless BlockNote table to markdown
+ // we emit an empty header row; on re-parse, treat that empty header as
+ // "no header" so the round-trip is stable (issue #739).
+ const headerIsEmpty = table.headers.every((h) => h.trim() === "");
+ const colCount = table.headers.length;
+
+ if (!headerIsEmpty) {
+ html += "";
+ for (let c = 0; c < colCount; c++) {
+ const align = table.alignments[c];
+ const alignAttr = align ? ` align="${align}"` : "";
+ html += `${parseInline(table.headers[c])} `;
+ }
+ html += " ";
+ }
+
+ if (table.rows.length > 0) {
+ html += "";
+ for (const row of table.rows) {
+ html += "";
+ for (let c = 0; c < colCount; c++) {
+ const cell = c < row.length ? row[c] : "";
+ const align = table.alignments[c];
+ const alignAttr = align ? ` align="${align}"` : "";
+ html += `${parseInline(cell)} `;
+ }
+ html += " ";
+ }
+ html += " ";
+ }
+
+ html += "
";
+ return html;
+}
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Convert a markdown string to an HTML string.
+ * This is a direct replacement for the unified/remark/rehype pipeline.
+ */
+export function markdownToHtml(markdown: string): string {
+ const tokens = tokenize(markdown);
+ return tokensToHtml(tokens);
+}
diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts
index d48a0fae19..e1741e214e 100644
--- a/packages/core/src/api/parsers/markdown/parseMarkdown.ts
+++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts
@@ -6,73 +6,19 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
-import { initializeESMDependencies } from "../../../util/esmDependencies.js";
import { HTMLToBlocks } from "../html/parseHTML.js";
+import { markdownToHtml } from "./markdownToHtml.js";
-// modified version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js
-// that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript)
-function code(state: any, node: any) {
- const value = node.value ? node.value : "";
- /** @type {Properties} */
- const properties: any = {};
-
- if (node.lang) {
- // changed line
- properties["data-language"] = node.lang;
- }
-
- // Create ``.
- /** @type {Element} */
- let result: any = {
- type: "element",
- tagName: "code",
- properties,
- children: [{ type: "text", value }],
- };
-
- if (node.meta) {
- result.data = { meta: node.meta };
- }
-
- state.patch(node, result);
- result = state.applyData(node, result);
-
- // Create ``.
- result = {
- type: "element",
- tagName: "pre",
- properties: {},
- children: [result],
- };
- state.patch(node, result);
- return result;
-}
-
-export async function markdownToHTML(markdown: string): Promise {
- const deps = await initializeESMDependencies();
-
- const htmlString = deps.unified
- .unified()
- .use(deps.remarkParse.default)
- .use(deps.remarkGfm.default)
- .use(deps.remarkRehype.default, {
- handlers: {
- ...(deps.remarkRehype.defaultHandlers as any),
- code,
- },
- })
- .use(deps.rehypeStringify.default)
- .processSync(markdown);
-
- return htmlString.value as string;
+export function markdownToHTML(markdown: string): string {
+ return markdownToHtml(markdown);
}
-export async function markdownToBlocks<
+export function markdownToBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
->(markdown: string, pmSchema: Schema): Promise[]> {
- const htmlString = await markdownToHTML(markdown);
+>(markdown: string, pmSchema: Schema): Block[] {
+ const htmlString = markdownToHTML(markdown);
return HTMLToBlocks(htmlString, pmSchema);
}
diff --git a/packages/core/src/api/pmUtil.ts b/packages/core/src/api/pmUtil.ts
index 0d4be4ee08..e47a3f2a75 100644
--- a/packages/core/src/api/pmUtil.ts
+++ b/packages/core/src/api/pmUtil.ts
@@ -1,12 +1,12 @@
import type { Node, Schema } from "prosemirror-model";
-import type { Transaction } from "prosemirror-state";
+import { Transform } from "prosemirror-transform";
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
+import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js";
import type { BlockSchema } from "../schema/blocks/types.js";
import type { InlineContentSchema } from "../schema/inlineContent/types.js";
import type { StyleSchema } from "../schema/styles/types.js";
-import { BlockNoteSchema } from "../editor/BlockNoteSchema.js";
-export function getPmSchema(trOrNode: Transaction | Node) {
+export function getPmSchema(trOrNode: Transform | Node) {
if ("doc" in trOrNode) {
return trOrNode.doc.type.schema;
}
diff --git a/packages/core/src/api/positionMapping.test.ts b/packages/core/src/api/positionMapping.test.ts
index aad3021257..a0019932ee 100644
--- a/packages/core/src/api/positionMapping.test.ts
+++ b/packages/core/src/api/positionMapping.test.ts
@@ -1,23 +1,29 @@
-import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import * as Y from "yjs";
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { trackPosition } from "./positionMapping.js";
describe("PositionStorage with local editor", () => {
- let editor: BlockNoteEditor;
+ describe("mount and unmount", () => {
+ it("should register transaction handler on creation", () => {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
- beforeEach(() => {
- editor = BlockNoteEditor.create();
- editor.mount(document.createElement("div"));
- });
+ editor._tiptapEditor.on = vi.fn();
+ trackPosition(editor, 0);
- afterEach(() => {
- editor.mount(undefined);
- editor._tiptapEditor.destroy();
- });
+ expect(editor._tiptapEditor.on).toHaveBeenCalledWith(
+ "transaction",
+ expect.any(Function),
+ );
+
+ editor._tiptapEditor.destroy();
+ });
+
+ it("should register transaction handler on creation & mount", () => {
+ const editor = BlockNoteEditor.create();
+ // editor.mount(document.createElement("div"));
- describe("mount and unmount", () => {
- it("should register transaction handler on creation", () => {
editor._tiptapEditor.on = vi.fn();
trackPosition(editor, 0);
@@ -25,24 +31,82 @@ describe("PositionStorage with local editor", () => {
"transaction",
expect.any(Function),
);
+
+ editor._tiptapEditor.destroy();
});
});
describe("set and get positions", () => {
it("should store and retrieve positions without Y.js", () => {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+
const getPos = trackPosition(editor, 10);
expect(getPos()).toBe(10);
+
+ editor._tiptapEditor.destroy();
});
it("should handle right side positions", () => {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+
const getPos = trackPosition(editor, 10, "right");
expect(getPos()).toBe(10);
+
+ editor._tiptapEditor.destroy();
});
});
it("should update mapping for local transactions before the position", () => {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+
+ // Set initial content
+ editor.insertBlocks(
+ [
+ {
+ id: "1",
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "Hello World",
+ styles: {},
+ },
+ ],
+ },
+ ],
+ editor.document[0],
+ "before",
+ );
+
+ // Start tracking
+ const getPos = trackPosition(editor, 10);
+
+ // Move the cursor to the start of the document
+ editor.setTextCursorPosition(editor.document[0], "start");
+
+ // Insert text at the start of the document
+ editor.insertInlineContent([
+ {
+ type: "text",
+ text: "Test",
+ styles: {},
+ },
+ ]);
+
+ // Position should be updated according to mapping
+ expect(getPos()).toBe(14);
+
+ editor._tiptapEditor.destroy();
+ });
+
+ it("should update mapping for local transactions before the position (unmounted)", () => {
+ const editor = BlockNoteEditor.create();
+
// Set initial content
editor.insertBlocks(
[
@@ -79,9 +143,14 @@ describe("PositionStorage with local editor", () => {
// Position should be updated according to mapping
expect(getPos()).toBe(14);
+
+ editor._tiptapEditor.destroy();
});
it("should not update mapping for local transactions after the position", () => {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+
// Set initial content
editor.insertBlocks(
[
@@ -117,9 +186,14 @@ describe("PositionStorage with local editor", () => {
// Position should not be updated
expect(getPos()).toBe(10);
+
+ editor._tiptapEditor.destroy();
});
it("should track positions on each side", () => {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+
editor.replaceBlocks(editor.document, [
{
type: "paragraph",
@@ -142,9 +216,14 @@ describe("PositionStorage with local editor", () => {
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ editor._tiptapEditor.destroy();
});
it("should handle multiple transactions", () => {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+
editor.replaceBlocks(editor.document, [
{
type: "paragraph",
@@ -172,6 +251,8 @@ describe("PositionStorage with local editor", () => {
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ editor._tiptapEditor.destroy();
});
});
@@ -202,16 +283,12 @@ describe("PositionStorage with remote editor", () => {
}
describe("remote editor", () => {
- let localEditor: BlockNoteEditor;
- let remoteEditor: BlockNoteEditor;
- let ydoc: Y.Doc;
- let remoteYdoc: Y.Doc;
-
- beforeEach(() => {
- ydoc = new Y.Doc();
- remoteYdoc = new Y.Doc();
+ it("should update the local position when collaborating", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
// Create a mock editor
- localEditor = BlockNoteEditor.create({
+ const localEditor = BlockNoteEditor.create({
collaboration: {
fragment: ydoc.getXmlFragment("doc"),
user: { color: "#ff0000", name: "Local User" },
@@ -221,7 +298,7 @@ describe("PositionStorage with remote editor", () => {
const div = document.createElement("div");
localEditor.mount(div);
- remoteEditor = BlockNoteEditor.create({
+ const remoteEditor = BlockNoteEditor.create({
collaboration: {
fragment: remoteYdoc.getXmlFragment("doc"),
user: { color: "#ff0000", name: "Remote User" },
@@ -232,18 +309,7 @@ describe("PositionStorage with remote editor", () => {
const remoteDiv = document.createElement("div");
remoteEditor.mount(remoteDiv);
setupTwoWaySync(ydoc, remoteYdoc);
- });
-
- afterEach(() => {
- ydoc.destroy();
- remoteYdoc.destroy();
- localEditor.mount(undefined);
- localEditor._tiptapEditor.destroy();
- remoteEditor.mount(undefined);
- remoteEditor._tiptapEditor.destroy();
- });
- it("should update the local position when collaborating", () => {
localEditor.replaceBlocks(localEditor.document, [
{
type: "paragraph",
@@ -271,9 +337,40 @@ describe("PositionStorage with remote editor", () => {
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor._tiptapEditor.destroy();
+ remoteEditor._tiptapEditor.destroy();
});
it("should handle multiple transactions when collaborating", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ // Create a mock editor
+ const localEditor = BlockNoteEditor.create({
+ collaboration: {
+ fragment: ydoc.getXmlFragment("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ });
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create({
+ collaboration: {
+ fragment: remoteYdoc.getXmlFragment("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ });
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
localEditor.replaceBlocks(localEditor.document, [
{
type: "paragraph",
@@ -305,9 +402,40 @@ describe("PositionStorage with remote editor", () => {
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor._tiptapEditor.destroy();
+ remoteEditor._tiptapEditor.destroy();
});
it("should update the local position from a remote transaction", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ // Create a mock editor
+ const localEditor = BlockNoteEditor.create({
+ collaboration: {
+ fragment: ydoc.getXmlFragment("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ });
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create({
+ collaboration: {
+ fragment: remoteYdoc.getXmlFragment("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ });
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
remoteEditor.replaceBlocks(remoteEditor.document, [
{
type: "paragraph",
@@ -335,9 +463,40 @@ describe("PositionStorage with remote editor", () => {
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor._tiptapEditor.destroy();
+ remoteEditor._tiptapEditor.destroy();
});
it("should update the remote position from a remote transaction", () => {
+ const ydoc = new Y.Doc();
+ const remoteYdoc = new Y.Doc();
+
+ // Create a mock editor
+ const localEditor = BlockNoteEditor.create({
+ collaboration: {
+ fragment: ydoc.getXmlFragment("doc"),
+ user: { color: "#ff0000", name: "Local User" },
+ provider: undefined,
+ },
+ });
+ const div = document.createElement("div");
+ localEditor.mount(div);
+
+ const remoteEditor = BlockNoteEditor.create({
+ collaboration: {
+ fragment: remoteYdoc.getXmlFragment("doc"),
+ user: { color: "#ff0000", name: "Remote User" },
+ provider: undefined,
+ },
+ });
+
+ const remoteDiv = document.createElement("div");
+ remoteEditor.mount(remoteDiv);
+ setupTwoWaySync(ydoc, remoteYdoc);
+
remoteEditor.replaceBlocks(remoteEditor.document, [
{
type: "paragraph",
@@ -365,6 +524,11 @@ describe("PositionStorage with remote editor", () => {
expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length)
expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length)
expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length)
+
+ ydoc.destroy();
+ remoteYdoc.destroy();
+ localEditor._tiptapEditor.destroy();
+ remoteEditor._tiptapEditor.destroy();
});
});
});
diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts
index d5bfab8bbf..11d8ef0fa9 100644
--- a/packages/core/src/api/positionMapping.ts
+++ b/packages/core/src/api/positionMapping.ts
@@ -61,9 +61,7 @@ export function trackPosition(
*/
side: "left" | "right" = "left",
): () => number {
- const ySyncPluginState = ySyncPluginKey.getState(
- editor._tiptapEditor.state,
- ) as {
+ const ySyncPluginState = ySyncPluginKey.getState(editor.prosemirrorState) as {
doc: Y.Doc;
binding: ProsemirrorBinding;
};
@@ -88,14 +86,14 @@ export function trackPosition(
const relativePosition = absolutePositionToRelativePosition(
// Track the position after the position if we are on the right side
- position + (side === "right" ? 1 : 0),
+ position + (side === "right" ? 1 : -1),
ySyncPluginState.binding.type,
ySyncPluginState.binding.mapping,
);
return () => {
const curYSyncPluginState = ySyncPluginKey.getState(
- editor._tiptapEditor.state,
+ editor.prosemirrorState,
) as typeof ySyncPluginState;
const pos = relativePositionToAbsolutePosition(
curYSyncPluginState.doc,
@@ -109,6 +107,6 @@ export function trackPosition(
throw new Error("Position not found, cannot track positions");
}
- return pos + (side === "right" ? -1 : 0);
+ return pos + (side === "right" ? -1 : 1);
};
}
diff --git a/packages/core/src/blocks/Audio/block.ts b/packages/core/src/blocks/Audio/block.ts
new file mode 100644
index 0000000000..78722cf988
--- /dev/null
+++ b/packages/core/src/blocks/Audio/block.ts
@@ -0,0 +1,171 @@
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import {
+ BlockFromConfig,
+ createBlockConfig,
+ createBlockSpec,
+} from "../../schema/index.js";
+import { defaultProps, parseDefaultProps } from "../defaultProps.js";
+import { parseFigureElement } from "../File/helpers/parse/parseFigureElement.js";
+import { createFileBlockWrapper } from "../File/helpers/render/createFileBlockWrapper.js";
+import { createFigureWithCaption } from "../File/helpers/toExternalHTML/createFigureWithCaption.js";
+import { createLinkWithCaption } from "../File/helpers/toExternalHTML/createLinkWithCaption.js";
+import { parseAudioElement } from "./parseAudioElement.js";
+
+export const FILE_AUDIO_ICON_SVG =
+ ' ';
+
+export interface AudioOptions {
+ icon?: string;
+}
+
+export type AudioBlockConfig = ReturnType;
+
+export const createAudioBlockConfig = createBlockConfig(
+ (_ctx: AudioOptions) =>
+ ({
+ type: "audio" as const,
+ propSchema: {
+ backgroundColor: defaultProps.backgroundColor,
+ // File name.
+ name: {
+ default: "" as const,
+ },
+ // File url.
+ url: {
+ default: "" as const,
+ },
+ // File caption.
+ caption: {
+ default: "" as const,
+ },
+
+ showPreview: {
+ default: true,
+ },
+ },
+ content: "none",
+ }) as const,
+);
+
+export const audioParse =
+ (_config: AudioOptions = {}) =>
+ (element: HTMLElement) => {
+ if (element.tagName === "AUDIO") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseAudioElement(element as HTMLAudioElement),
+ backgroundColor,
+ };
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "audio");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseAudioElement(targetElement as HTMLAudioElement),
+ backgroundColor,
+ caption,
+ };
+ }
+
+ return undefined;
+ };
+
+export const audioRender =
+ (config: AudioOptions = {}) =>
+ (
+ block: BlockFromConfig, any, any>,
+ editor: BlockNoteEditor<
+ Record<"audio", ReturnType>,
+ any,
+ any
+ >,
+ ) => {
+ const icon = document.createElement("div");
+ icon.innerHTML = config.icon ?? FILE_AUDIO_ICON_SVG;
+
+ const audio = document.createElement("audio");
+ audio.className = "bn-audio";
+ if (editor.resolveFileUrl) {
+ editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
+ audio.src = downloadUrl;
+ });
+ } else {
+ audio.src = block.props.url;
+ }
+ audio.controls = true;
+ audio.contentEditable = "false";
+ audio.draggable = false;
+
+ return createFileBlockWrapper(
+ block,
+ editor,
+ { dom: audio },
+ icon.firstElementChild as HTMLElement,
+ );
+ };
+
+export const audioToExternalHTML =
+ (_config: AudioOptions = {}) =>
+ (
+ block: BlockFromConfig, any, any>,
+ _editor: BlockNoteEditor<
+ Record<"audio", ReturnType>,
+ any,
+ any
+ >,
+ ) => {
+ if (!block.props.url) {
+ return {
+ dom: document.createElement("audio"),
+ };
+ }
+
+ let audio;
+ if (block.props.showPreview) {
+ audio = document.createElement("audio");
+ audio.src = block.props.url;
+ } else {
+ audio = document.createElement("a");
+ audio.href = block.props.url;
+ audio.textContent = block.props.name || block.props.url;
+ }
+
+ if (block.props.caption) {
+ if (block.props.showPreview) {
+ return createFigureWithCaption(audio, block.props.caption);
+ } else {
+ return createLinkWithCaption(audio, block.props.caption);
+ }
+ }
+
+ return {
+ dom: audio,
+ };
+ };
+
+export const createAudioBlockSpec = createBlockSpec(
+ createAudioBlockConfig,
+ (config) => ({
+ meta: {
+ fileBlockAccept: ["audio/*"],
+ },
+ parse: audioParse(config),
+ render: audioRender(config),
+ toExternalHTML: audioToExternalHTML(config),
+ runsBefore: ["file"],
+ }),
+);
diff --git a/packages/core/src/blocks/AudioBlockContent/parseAudioElement.ts b/packages/core/src/blocks/Audio/parseAudioElement.ts
similarity index 100%
rename from packages/core/src/blocks/AudioBlockContent/parseAudioElement.ts
rename to packages/core/src/blocks/Audio/parseAudioElement.ts
diff --git a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts
deleted file mode 100644
index 7a3e0101fe..0000000000
--- a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import {
- BlockFromConfig,
- createBlockSpec,
- FileBlockConfig,
- Props,
- PropSchema,
-} from "../../schema/index.js";
-import { defaultProps } from "../defaultProps.js";
-
-import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js";
-import { createFileBlockWrapper } from "../FileBlockContent/helpers/render/createFileBlockWrapper.js";
-import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
-import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
-import { parseAudioElement } from "./parseAudioElement.js";
-
-export const FILE_AUDIO_ICON_SVG =
- ' ';
-
-export const audioPropSchema = {
- backgroundColor: defaultProps.backgroundColor,
- // File name.
- name: {
- default: "" as const,
- },
- // File url.
- url: {
- default: "" as const,
- },
- // File caption.
- caption: {
- default: "" as const,
- },
-
- showPreview: {
- default: true,
- },
-} satisfies PropSchema;
-
-export const audioBlockConfig = {
- type: "audio" as const,
- propSchema: audioPropSchema,
- content: "none",
- isFileBlock: true,
- fileBlockAccept: ["audio/*"],
-} satisfies FileBlockConfig;
-
-export const audioRender = (
- block: BlockFromConfig,
- editor: BlockNoteEditor,
-) => {
- const icon = document.createElement("div");
- icon.innerHTML = FILE_AUDIO_ICON_SVG;
-
- const audio = document.createElement("audio");
- audio.className = "bn-audio";
- if (editor.resolveFileUrl) {
- editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
- audio.src = downloadUrl;
- });
- } else {
- audio.src = block.props.url;
- }
- audio.controls = true;
- audio.contentEditable = "false";
- audio.draggable = false;
-
- return createFileBlockWrapper(
- block,
- editor,
- { dom: audio },
- editor.dictionary.file_blocks.audio.add_button_text,
- icon.firstElementChild as HTMLElement,
- );
-};
-
-export const audioParse = (
- element: HTMLElement,
-): Partial> | undefined => {
- if (element.tagName === "AUDIO") {
- // Ignore if parent figure has already been parsed.
- if (element.closest("figure")) {
- return undefined;
- }
-
- return parseAudioElement(element as HTMLAudioElement);
- }
-
- if (element.tagName === "FIGURE") {
- const parsedFigure = parseFigureElement(element, "audio");
- if (!parsedFigure) {
- return undefined;
- }
-
- const { targetElement, caption } = parsedFigure;
-
- return {
- ...parseAudioElement(targetElement as HTMLAudioElement),
- caption,
- };
- }
-
- return undefined;
-};
-
-export const audioToExternalHTML = (
- block: BlockFromConfig,
-) => {
- if (!block.props.url) {
- const div = document.createElement("p");
- div.textContent = "Add audio";
-
- return {
- dom: div,
- };
- }
-
- let audio;
- if (block.props.showPreview) {
- audio = document.createElement("audio");
- audio.src = block.props.url;
- } else {
- audio = document.createElement("a");
- audio.href = block.props.url;
- audio.textContent = block.props.name || block.props.url;
- }
-
- if (block.props.caption) {
- if (block.props.showPreview) {
- return createFigureWithCaption(audio, block.props.caption);
- } else {
- return createLinkWithCaption(audio, block.props.caption);
- }
- }
-
- return {
- dom: audio,
- };
-};
-
-export const AudioBlock = createBlockSpec(audioBlockConfig, {
- render: audioRender,
- parse: audioParse,
- toExternalHTML: audioToExternalHTML,
-});
diff --git a/packages/core/src/blocks/BlockNoteSchema.ts b/packages/core/src/blocks/BlockNoteSchema.ts
new file mode 100644
index 0000000000..37b60220da
--- /dev/null
+++ b/packages/core/src/blocks/BlockNoteSchema.ts
@@ -0,0 +1,59 @@
+import {
+ BlockSchema,
+ BlockSchemaFromSpecs,
+ BlockSpecs,
+ CustomBlockNoteSchema,
+ InlineContentSchema,
+ InlineContentSchemaFromSpecs,
+ InlineContentSpecs,
+ StyleSchema,
+ StyleSchemaFromSpecs,
+ StyleSpecs,
+} from "../schema/index.js";
+import {
+ defaultBlockSpecs,
+ defaultInlineContentSpecs,
+ defaultStyleSpecs,
+} from "./defaultBlocks.js";
+
+export class BlockNoteSchema<
+ BSchema extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+> extends CustomBlockNoteSchema {
+ public static create<
+ BSpecs extends BlockSpecs | undefined = undefined,
+ ISpecs extends InlineContentSpecs | undefined = undefined,
+ SSpecs extends StyleSpecs | undefined = undefined,
+ >(options?: {
+ /**
+ * A list of custom block types that should be available in the editor.
+ */
+ blockSpecs?: BSpecs;
+ /**
+ * A list of custom InlineContent types that should be available in the editor.
+ */
+ inlineContentSpecs?: ISpecs;
+ /**
+ * A list of custom Styles that should be available in the editor.
+ */
+ styleSpecs?: SSpecs;
+ }): BlockNoteSchema<
+ BSpecs extends undefined
+ ? BlockSchemaFromSpecs
+ : BlockSchemaFromSpecs>,
+ ISpecs extends undefined
+ ? InlineContentSchemaFromSpecs
+ : InlineContentSchemaFromSpecs>,
+ SSpecs extends undefined
+ ? StyleSchemaFromSpecs
+ : StyleSchemaFromSpecs>
+ > {
+ return new BlockNoteSchema({
+ blockSpecs: options?.blockSpecs ?? defaultBlockSpecs,
+ inlineContentSpecs:
+ options?.inlineContentSpecs ?? defaultInlineContentSpecs,
+ styleSpecs: options?.styleSpecs ?? defaultStyleSpecs,
+ });
+ }
+}
diff --git a/packages/core/src/blocks/Code/block.test.ts b/packages/core/src/blocks/Code/block.test.ts
new file mode 100644
index 0000000000..b687c03b22
--- /dev/null
+++ b/packages/core/src/blocks/Code/block.test.ts
@@ -0,0 +1,258 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import type { PartialBlock } from "../defaultBlocks.js";
+import { getLanguageId, type CodeBlockOptions } from "./block.js";
+
+/**
+ * @vitest-environment jsdom
+ */
+
+/**
+ * Simulate typing text into the editor at the current cursor position.
+ * This triggers input rules by calling the view's handleTextInput prop,
+ * which is how ProseMirror processes keyboard text input.
+ */
+function simulateTextInput(editor: BlockNoteEditor, text: string) {
+ const view = editor.prosemirrorView;
+ const { from, to } = view.state.selection;
+ const deflt = () => view.state.tr.insertText(text, from, to);
+ const handled = view.someProp("handleTextInput", (f) =>
+ f(view, from, to, text, deflt),
+ );
+ if (!handled) {
+ view.dispatch(deflt());
+ }
+}
+
+function typeString(editor: BlockNoteEditor, str: string) {
+ for (const char of str) {
+ simulateTextInput(editor, char);
+ }
+}
+
+/**
+ * Simulate a keyboard shortcut by invoking the view's handleKeyDown prop,
+ * which is how ProseMirror routes keymap-based handlers like Enter.
+ */
+function pressKey(editor: BlockNoteEditor, key: string) {
+ const view = editor.prosemirrorView;
+ const event = new KeyboardEvent("keydown", { key });
+ view.someProp("handleKeyDown", (f) => f(view, event));
+}
+
+describe("Code block input rule", () => {
+ let editor: BlockNoteEditor;
+ const div = document.createElement("div");
+
+ beforeAll(() => {
+ editor = BlockNoteEditor.create();
+ editor.mount(div);
+ });
+
+ afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+ });
+
+ beforeEach(() => {
+ const testDoc: PartialBlock[] = [
+ {
+ id: "test-paragraph",
+ type: "paragraph",
+ content: "",
+ },
+ ];
+ editor.replaceBlocks(editor.document, testDoc);
+ editor.setTextCursorPosition("test-paragraph", "start");
+ });
+
+ it("converts ```ts + space into a codeBlock", () => {
+ typeString(editor, "```ts ");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+ // Without supportedLanguages configured, the raw alias is used
+ expect((block.props as any).language).toBe("ts");
+ });
+
+ it("converts ``` + space into a codeBlock with empty language", () => {
+ typeString(editor, "``` ");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+ expect((block.props as any).language).toBe("");
+ });
+
+ it("converts ```javascript + space into a codeBlock", () => {
+ typeString(editor, "```javascript ");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+ expect((block.props as any).language).toBe("javascript");
+ });
+
+ it("does not trigger input rule without trailing space", () => {
+ typeString(editor, "```ts");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("paragraph");
+ });
+
+ it("does not trigger with only two backticks", () => {
+ typeString(editor, "``ts ");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("paragraph");
+ });
+
+ it("does not trigger in non-empty paragraph with preceding text", () => {
+ typeString(editor, "some text ```ts ");
+
+ const block = editor.document[0];
+ // The ^ anchor in the regex means it only triggers at the start of a block
+ expect(block.type).toBe("paragraph");
+ });
+
+ it("code block content is empty after conversion", () => {
+ typeString(editor, "```ts ");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+ expect(block.content).toEqual([]);
+ });
+
+ it("converts ```ts + Enter into a codeBlock", () => {
+ typeString(editor, "```ts");
+ pressKey(editor, "Enter");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+ expect((block.props as any).language).toBe("ts");
+ expect(block.content).toEqual([]);
+ });
+
+ it("converts ``` + Enter into a codeBlock with empty language", () => {
+ typeString(editor, "```");
+ pressKey(editor, "Enter");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+ expect((block.props as any).language).toBe("");
+ });
+
+ it("converts ```javascript + Enter into a codeBlock", () => {
+ typeString(editor, "```javascript");
+ pressKey(editor, "Enter");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+ expect((block.props as any).language).toBe("javascript");
+ });
+
+ it("does not trigger Enter conversion in non-empty paragraph with preceding text", () => {
+ typeString(editor, "some text ```ts");
+ pressKey(editor, "Enter");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("paragraph");
+ });
+
+ it("does not trigger Enter conversion with only two backticks", () => {
+ typeString(editor, "``ts");
+ pressKey(editor, "Enter");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("paragraph");
+ });
+
+ it("places cursor inside the new code block after space conversion", () => {
+ typeString(editor, "```ts ");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+
+ const { block: cursorBlock } = editor.getTextCursorPosition();
+ expect(cursorBlock.id).toBe(block.id);
+
+ // Typing should now go into the code block, not after it.
+ typeString(editor, "hello");
+ const after = editor.document[0];
+ expect(after.type).toBe("codeBlock");
+ expect(after.id).toBe(block.id);
+ expect((after.content as Array<{ type: string; text: string }>)[0].text).toBe(
+ "hello",
+ );
+ });
+
+ it("places cursor inside the new code block after Enter conversion", () => {
+ typeString(editor, "```ts");
+ pressKey(editor, "Enter");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+
+ const { block: cursorBlock } = editor.getTextCursorPosition();
+ expect(cursorBlock.id).toBe(block.id);
+
+ typeString(editor, "world");
+ const after = editor.document[0];
+ expect(after.type).toBe("codeBlock");
+ expect(after.id).toBe(block.id);
+ expect((after.content as Array<{ type: string; text: string }>)[0].text).toBe(
+ "world",
+ );
+ });
+
+ it("Enter inside an existing code block does not retrigger conversion", () => {
+ typeString(editor, "```ts ");
+
+ const block = editor.document[0];
+ expect(block.type).toBe("codeBlock");
+
+ typeString(editor, "```js");
+ pressKey(editor, "Enter");
+
+ // Enter inside a code block should insert a newline, not convert again.
+ const after = editor.document[0];
+ expect(after.type).toBe("codeBlock");
+ expect((after.props as any).language).toBe("ts");
+ });
+});
+
+describe("getLanguageId", () => {
+ const options: CodeBlockOptions = {
+ supportedLanguages: {
+ typescript: {
+ name: "TypeScript",
+ aliases: ["ts", "typescript"],
+ },
+ javascript: {
+ name: "JavaScript",
+ aliases: ["js", "javascript"],
+ },
+ python: {
+ name: "Python",
+ aliases: ["py", "python"],
+ },
+ },
+ };
+
+ it("resolves alias to language id", () => {
+ expect(getLanguageId(options, "ts")).toBe("typescript");
+ expect(getLanguageId(options, "js")).toBe("javascript");
+ expect(getLanguageId(options, "py")).toBe("python");
+ });
+
+ it("resolves language id directly", () => {
+ expect(getLanguageId(options, "typescript")).toBe("typescript");
+ expect(getLanguageId(options, "javascript")).toBe("javascript");
+ });
+
+ it("returns undefined for unknown language", () => {
+ expect(getLanguageId(options, "unknown")).toBeUndefined();
+ });
+
+ it("returns undefined with no supportedLanguages", () => {
+ expect(getLanguageId({}, "ts")).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts
new file mode 100644
index 0000000000..dbb7fc33a9
--- /dev/null
+++ b/packages/core/src/blocks/Code/block.ts
@@ -0,0 +1,308 @@
+import type { HighlighterGeneric } from "@shikijs/types";
+import { createExtension } from "../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
+import { lazyShikiPlugin } from "./shiki.js";
+import { DOMParser } from "@tiptap/pm/model";
+
+export type CodeBlockOptions = {
+ /**
+ * Whether to indent lines with a tab when the user presses `Tab` in a code block.
+ *
+ * @default true
+ */
+ indentLineWithTab?: boolean;
+ /**
+ * The default language to use for code blocks.
+ *
+ * @default "text"
+ */
+ defaultLanguage?: string;
+ /**
+ * The languages that are supported in the editor.
+ *
+ * @example
+ * {
+ * javascript: {
+ * name: "JavaScript",
+ * aliases: ["js"],
+ * },
+ * typescript: {
+ * name: "TypeScript",
+ * aliases: ["ts"],
+ * },
+ * }
+ */
+ supportedLanguages?: Record<
+ string,
+ {
+ /**
+ * The display name of the language.
+ */
+ name: string;
+ /**
+ * Aliases for this language.
+ */
+ aliases?: string[];
+ }
+ >;
+ /**
+ * The highlighter to use for code blocks.
+ */
+ createHighlighter?: () => Promise>;
+};
+
+export type CodeBlockConfig = ReturnType;
+
+export const createCodeBlockConfig = createBlockConfig(
+ ({ defaultLanguage = "text" }: CodeBlockOptions) =>
+ ({
+ type: "codeBlock" as const,
+ propSchema: {
+ language: {
+ default: defaultLanguage,
+ },
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const createCodeBlockSpec = createBlockSpec(
+ createCodeBlockConfig,
+ (options) => ({
+ meta: {
+ code: true,
+ defining: true,
+ isolating: false,
+ },
+ parse: (e) => {
+ if (e.tagName !== "PRE") {
+ return undefined;
+ }
+
+ if (
+ e.childElementCount !== 1 ||
+ e.firstElementChild?.tagName !== "CODE"
+ ) {
+ return undefined;
+ }
+
+ const code = e.firstElementChild!;
+ const language =
+ code.getAttribute("data-language") ||
+ code.className
+ .split(" ")
+ .find((name) => name.includes("language-"))
+ ?.replace("language-", "");
+
+ return { language };
+ },
+
+ parseContent: ({ el, schema }) => {
+ const parser = DOMParser.fromSchema(schema);
+ const code = el.firstElementChild!;
+
+ return parser.parse(code, {
+ preserveWhitespace: "full",
+ topNode: schema.nodes["codeBlock"].create(),
+ }).content;
+ },
+
+ render(block, editor) {
+ const wrapper = document.createDocumentFragment();
+ const pre = document.createElement("pre");
+ const code = document.createElement("code");
+ pre.appendChild(code);
+
+ let removeSelectChangeListener = undefined;
+
+ if (options.supportedLanguages) {
+ const select = document.createElement("select");
+
+ Object.entries(options.supportedLanguages ?? {}).forEach(
+ ([id, { name }]) => {
+ const option = document.createElement("option");
+
+ option.value = id;
+ option.text = name;
+ select.appendChild(option);
+ },
+ );
+ select.value =
+ block.props.language || options.defaultLanguage || "text";
+
+ if (editor.isEditable) {
+ const handleLanguageChange = (event: Event) => {
+ const language = (event.target as HTMLSelectElement).value;
+
+ editor.updateBlock(block.id, { props: { language } });
+ };
+ select.addEventListener("change", handleLanguageChange);
+ removeSelectChangeListener = () =>
+ select.removeEventListener("change", handleLanguageChange);
+ } else {
+ select.disabled = true;
+ }
+
+ const selectWrapper = document.createElement("div");
+ selectWrapper.contentEditable = "false";
+
+ selectWrapper.appendChild(select);
+ wrapper.appendChild(selectWrapper);
+ }
+ wrapper.appendChild(pre);
+
+ return {
+ dom: wrapper,
+ contentDOM: code,
+ destroy: () => {
+ removeSelectChangeListener?.();
+ },
+ };
+ },
+ toExternalHTML(block) {
+ const pre = document.createElement("pre");
+ const code = document.createElement("code");
+ code.className = `language-${block.props.language}`;
+ code.dataset.language = block.props.language;
+ pre.appendChild(code);
+ return {
+ dom: pre,
+ contentDOM: code,
+ };
+ },
+ }),
+ (options) => {
+ return [
+ createExtension({
+ key: "code-block-highlighter",
+ prosemirrorPlugins: [lazyShikiPlugin(options)],
+ }),
+ createExtension({
+ key: "code-block-keyboard-shortcuts",
+ keyboardShortcuts: {
+ Delete: ({ editor }) => {
+ return editor.transact((tr) => {
+ const { block } = editor.getTextCursorPosition();
+ if (block.type !== "codeBlock") {
+ return false;
+ }
+ const { $from } = tr.selection;
+
+ // When inside empty codeblock, on `DELETE` key press, delete the codeblock
+ if (!$from.parent.textContent) {
+ editor.removeBlocks([block]);
+
+ return true;
+ }
+
+ return false;
+ });
+ },
+ Tab: ({ editor }) => {
+ if (options.indentLineWithTab === false) {
+ return false;
+ }
+
+ return editor.transact((tr) => {
+ const { block } = editor.getTextCursorPosition();
+ if (block.type === "codeBlock") {
+ // TODO should probably only tab when at a line start or already tabbed in
+ tr.insertText(" ");
+ return true;
+ }
+
+ return false;
+ });
+ },
+ Enter: ({ editor }) => {
+ return editor.transact((tr) => {
+ const { block, nextBlock } = editor.getTextCursorPosition();
+ if (block.type !== "codeBlock") {
+ return false;
+ }
+ const { $from } = tr.selection;
+
+ const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
+ const endsWithDoubleNewline =
+ $from.parent.textContent.endsWith("\n\n");
+
+ // The user is trying to exit the code block by pressing enter at the end of the code block
+ if (isAtEnd && endsWithDoubleNewline) {
+ // Remove the double newline
+ tr.delete($from.pos - 2, $from.pos);
+
+ // If there is a next block, move the cursor to it
+ if (nextBlock) {
+ editor.setTextCursorPosition(nextBlock, "start");
+ return true;
+ }
+
+ // If there is no next block, insert a new paragraph
+ const [newBlock] = editor.insertBlocks(
+ [{ type: "paragraph" }],
+ block,
+ "after",
+ );
+ // Move the cursor to the new block
+ editor.setTextCursorPosition(newBlock, "start");
+
+ return true;
+ }
+
+ tr.insertText("\n");
+ return true;
+ });
+ },
+ "Shift-Enter": ({ editor }) => {
+ return editor.transact(() => {
+ const { block } = editor.getTextCursorPosition();
+ if (block.type !== "codeBlock") {
+ return false;
+ }
+
+ const [newBlock] = editor.insertBlocks(
+ // insert a new paragraph
+ [{ type: "paragraph" }],
+ block,
+ "after",
+ );
+ // move the cursor to the new block
+ editor.setTextCursorPosition(newBlock, "start");
+ return true;
+ });
+ },
+ },
+ inputRules: [
+ {
+ find: /^```(.*?)\s$/,
+ replace: ({ match }) => {
+ const languageName = match[1].trim();
+ const attributes = {
+ language: getLanguageId(options, languageName) ?? languageName,
+ };
+
+ return {
+ type: "codeBlock",
+ props: {
+ language: attributes.language,
+ },
+ content: [],
+ };
+ },
+ },
+ ],
+ }),
+ ];
+ },
+);
+
+export function getLanguageId(
+ options: CodeBlockOptions,
+ languageName: string,
+): string | undefined {
+ return Object.entries(options.supportedLanguages ?? {}).find(
+ ([id, { aliases }]) => {
+ return aliases?.includes(languageName) || id === languageName;
+ },
+ )?.[0];
+}
diff --git a/packages/core/src/blocks/Code/shiki.ts b/packages/core/src/blocks/Code/shiki.ts
new file mode 100644
index 0000000000..1298007a58
--- /dev/null
+++ b/packages/core/src/blocks/Code/shiki.ts
@@ -0,0 +1,73 @@
+import type { HighlighterGeneric } from "@shikijs/types";
+import { Parser, createHighlightPlugin } from "prosemirror-highlight";
+import { createParser } from "prosemirror-highlight/shiki";
+import { CodeBlockOptions, getLanguageId } from "./block.js";
+
+export const shikiParserSymbol = Symbol.for("blocknote.shikiParser");
+export const shikiHighlighterPromiseSymbol = Symbol.for(
+ "blocknote.shikiHighlighterPromise",
+);
+
+export function lazyShikiPlugin(options: CodeBlockOptions) {
+ const globalThisForShiki = globalThis as {
+ [shikiHighlighterPromiseSymbol]?: Promise>;
+ [shikiParserSymbol]?: Parser;
+ };
+
+ let highlighter: HighlighterGeneric | undefined;
+ let parser: Parser | undefined;
+ let hasWarned = false;
+ const lazyParser: Parser = (parserOptions) => {
+ if (!options.createHighlighter) {
+ if (process.env.NODE_ENV === "development" && !hasWarned) {
+ // eslint-disable-next-line no-console
+ console.log(
+ "For syntax highlighting of code blocks, you must provide a `createCodeBlockSpec({ createHighlighter: () => ... })` function",
+ );
+ hasWarned = true;
+ }
+ return [];
+ }
+ if (!highlighter) {
+ globalThisForShiki[shikiHighlighterPromiseSymbol] =
+ globalThisForShiki[shikiHighlighterPromiseSymbol] ||
+ options.createHighlighter();
+
+ return globalThisForShiki[shikiHighlighterPromiseSymbol].then(
+ (createdHighlighter) => {
+ highlighter = createdHighlighter;
+ },
+ );
+ }
+ const language = getLanguageId(options, parserOptions.language!);
+
+ if (
+ !language ||
+ language === "text" ||
+ language === "none" ||
+ language === "plaintext" ||
+ language === "txt"
+ ) {
+ return [];
+ }
+
+ if (!highlighter.getLoadedLanguages().includes(language)) {
+ return highlighter.loadLanguage(language);
+ }
+
+ if (!parser) {
+ parser =
+ globalThisForShiki[shikiParserSymbol] ||
+ createParser(highlighter as any);
+ globalThisForShiki[shikiParserSymbol] = parser;
+ }
+
+ return parser(parserOptions);
+ };
+
+ return createHighlightPlugin({
+ parser: lazyParser,
+ languageExtractor: (node) => node.attrs.language,
+ nodeTypes: ["codeBlock"],
+ });
+}
diff --git a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts
deleted file mode 100644
index 239add82b4..0000000000
--- a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts
+++ /dev/null
@@ -1,445 +0,0 @@
-import { InputRule, isTextSelection } from "@tiptap/core";
-import { TextSelection } from "@tiptap/pm/state";
-import { createHighlightPlugin, Parser } from "prosemirror-highlight";
-import { createParser } from "prosemirror-highlight/shiki";
-import {
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
- PropSchema,
-} from "../../schema/index.js";
-import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
-import type { HighlighterGeneric } from "@shikijs/types";
-import { BlockNoteEditor } from "../../index.js";
-
-export type CodeBlockOptions = {
- /**
- * Whether to indent lines with a tab when the user presses `Tab` in a code block.
- *
- * @default true
- */
- indentLineWithTab?: boolean;
- /**
- * The default language to use for code blocks.
- *
- * @default "text"
- */
- defaultLanguage?: string;
- /**
- * The languages that are supported in the editor.
- *
- * @example
- * {
- * javascript: {
- * name: "JavaScript",
- * aliases: ["js"],
- * },
- * typescript: {
- * name: "TypeScript",
- * aliases: ["ts"],
- * },
- * }
- */
- supportedLanguages: Record<
- string,
- {
- /**
- * The display name of the language.
- */
- name: string;
- /**
- * Aliases for this language.
- */
- aliases?: string[];
- }
- >;
- /**
- * The highlighter to use for code blocks.
- */
- createHighlighter?: () => Promise>;
-};
-
-type CodeBlockConfigOptions = {
- editor: BlockNoteEditor;
-};
-
-export const shikiParserSymbol = Symbol.for("blocknote.shikiParser");
-export const shikiHighlighterPromiseSymbol = Symbol.for(
- "blocknote.shikiHighlighterPromise",
-);
-export const defaultCodeBlockPropSchema = {
- language: {
- default: "text",
- },
-} satisfies PropSchema;
-
-const CodeBlockContent = createStronglyTypedTiptapNode({
- name: "codeBlock",
- content: "inline*",
- group: "blockContent",
- marks: "",
- code: true,
- defining: true,
- addOptions() {
- return {
- defaultLanguage: "text",
- indentLineWithTab: true,
- supportedLanguages: {},
- };
- },
- addAttributes() {
- const options = this.options as CodeBlockConfigOptions;
-
- return {
- language: {
- default: options.editor.settings.codeBlock.defaultLanguage,
- parseHTML: (inputElement) => {
- let element = inputElement as HTMLElement | null;
- let language: string | null = null;
-
- if (
- element?.tagName === "DIV" &&
- element?.dataset.contentType === "codeBlock"
- ) {
- element = element.children[0] as HTMLElement | null;
- }
-
- if (element?.tagName === "PRE") {
- element = element?.children[0] as HTMLElement | null;
- }
-
- const dataLanguage = element?.getAttribute("data-language");
-
- if (dataLanguage) {
- language = dataLanguage.toLowerCase();
- } else {
- const classNames = [...(element?.className.split(" ") || [])];
- const languages = classNames
- .filter((className) => className.startsWith("language-"))
- .map((className) => className.replace("language-", ""));
-
- if (languages.length > 0) {
- language = languages[0].toLowerCase();
- }
- }
-
- if (!language) {
- return null;
- }
-
- return (
- getLanguageId(options.editor.settings.codeBlock, language) ??
- language
- );
- },
- renderHTML: (attributes) => {
- return attributes.language
- ? {
- class: `language-${attributes.language}`,
- "data-language": attributes.language,
- }
- : {};
- },
- },
- };
- },
- parseHTML() {
- return [
- // Parse from internal HTML.
- {
- tag: "div[data-content-type=" + this.name + "]",
- contentElement: ".bn-inline-content",
- },
- // Parse from external HTML.
- {
- tag: "pre",
- contentElement: "code",
- preserveWhitespace: "full",
- },
- ];
- },
- renderHTML({ HTMLAttributes }) {
- const pre = document.createElement("pre");
- const { dom, contentDOM } = createDefaultBlockDOMOutputSpec(
- this.name,
- "code",
- this.options.domAttributes?.blockContent || {},
- {
- ...(this.options.domAttributes?.inlineContent || {}),
- ...HTMLAttributes,
- },
- );
-
- dom.removeChild(contentDOM);
- dom.appendChild(pre);
- pre.appendChild(contentDOM);
-
- return {
- dom,
- contentDOM,
- };
- },
- addNodeView() {
- const options = this.options as CodeBlockConfigOptions;
-
- return ({ editor, node, getPos, HTMLAttributes }) => {
- const pre = document.createElement("pre");
- const select = document.createElement("select");
- const selectWrapper = document.createElement("div");
- const { dom, contentDOM } = createDefaultBlockDOMOutputSpec(
- this.name,
- "code",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
- const handleLanguageChange = (event: Event) => {
- const language = (event.target as HTMLSelectElement).value;
-
- editor.commands.command(({ tr }) => {
- tr.setNodeAttribute(getPos(), "language", language);
-
- return true;
- });
- };
-
- Object.entries(
- options.editor.settings.codeBlock.supportedLanguages,
- ).forEach(([id, { name }]) => {
- const option = document.createElement("option");
-
- option.value = id;
- option.text = name;
- select.appendChild(option);
- });
-
- selectWrapper.contentEditable = "false";
- select.value =
- node.attrs.language ||
- options.editor.settings.codeBlock.defaultLanguage;
- dom.removeChild(contentDOM);
- dom.appendChild(selectWrapper);
- dom.appendChild(pre);
- pre.appendChild(contentDOM);
- selectWrapper.appendChild(select);
- select.addEventListener("change", handleLanguageChange);
-
- return {
- dom,
- contentDOM,
- update: (newNode) => {
- if (newNode.type !== this.type) {
- return false;
- }
-
- return true;
- },
- destroy: () => {
- select.removeEventListener("change", handleLanguageChange);
- },
- };
- };
- },
- addProseMirrorPlugins() {
- const options = this.options as CodeBlockConfigOptions;
- const globalThisForShiki = globalThis as {
- [shikiHighlighterPromiseSymbol]?: Promise>;
- [shikiParserSymbol]?: Parser;
- };
-
- let highlighter: HighlighterGeneric | undefined;
- let parser: Parser | undefined;
- let hasWarned = false;
- const lazyParser: Parser = (parserOptions) => {
- if (!options.editor.settings.codeBlock.createHighlighter) {
- if (process.env.NODE_ENV === "development" && !hasWarned) {
- // eslint-disable-next-line no-console
- console.log(
- "For syntax highlighting of code blocks, you must provide a `codeBlock.createHighlighter` function",
- );
- hasWarned = true;
- }
- return [];
- }
- if (!highlighter) {
- globalThisForShiki[shikiHighlighterPromiseSymbol] =
- globalThisForShiki[shikiHighlighterPromiseSymbol] ||
- options.editor.settings.codeBlock.createHighlighter();
-
- return globalThisForShiki[shikiHighlighterPromiseSymbol].then(
- (createdHighlighter) => {
- highlighter = createdHighlighter;
- },
- );
- }
- const language = getLanguageId(
- options.editor.settings.codeBlock,
- parserOptions.language!,
- );
-
- if (
- !language ||
- language === "text" ||
- language === "none" ||
- language === "plaintext" ||
- language === "txt"
- ) {
- return [];
- }
-
- if (!highlighter.getLoadedLanguages().includes(language)) {
- return highlighter.loadLanguage(language);
- }
-
- if (!parser) {
- parser =
- globalThisForShiki[shikiParserSymbol] ||
- createParser(highlighter as any);
- globalThisForShiki[shikiParserSymbol] = parser;
- }
-
- return parser(parserOptions);
- };
-
- const shikiLazyPlugin = createHighlightPlugin({
- parser: lazyParser,
- languageExtractor: (node) => node.attrs.language,
- nodeTypes: [this.name],
- });
-
- return [shikiLazyPlugin];
- },
- addInputRules() {
- const options = this.options as CodeBlockConfigOptions;
-
- return [
- new InputRule({
- find: /^```(.*?)\s$/,
- handler: ({ state, range, match }) => {
- const $start = state.doc.resolve(range.from);
- const languageName = match[1].trim();
- const attributes = {
- language:
- getLanguageId(options.editor.settings.codeBlock, languageName) ??
- languageName,
- };
-
- if (
- !$start
- .node(-1)
- .canReplaceWith(
- $start.index(-1),
- $start.indexAfter(-1),
- this.type,
- )
- ) {
- return null;
- }
-
- state.tr
- .delete(range.from, range.to)
- .setBlockType(range.from, range.from, this.type, attributes)
- .setSelection(TextSelection.create(state.tr.doc, range.from));
-
- return;
- },
- }),
- ];
- },
- addKeyboardShortcuts() {
- return {
- Delete: ({ editor }) => {
- const { selection } = editor.state;
- const { $from } = selection;
-
- // When inside empty codeblock, on `DELETE` key press, delete the codeblock
- if (
- editor.isActive(this.name) &&
- !$from.parent.textContent &&
- isTextSelection(selection)
- ) {
- // Get the start position of the codeblock for node selection
- const from = $from.pos - $from.parentOffset - 2;
-
- editor.chain().setNodeSelection(from).deleteSelection().run();
-
- return true;
- }
-
- return false;
- },
- Tab: ({ editor }) => {
- if (!this.options.indentLineWithTab) {
- return false;
- }
- if (editor.isActive(this.name)) {
- editor.commands.insertContent(" ");
- return true;
- }
-
- return false;
- },
- Enter: ({ editor }) => {
- const { $from } = editor.state.selection;
-
- if (!editor.isActive(this.name)) {
- return false;
- }
-
- const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
- const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
-
- if (!isAtEnd || !endsWithDoubleNewline) {
- editor.commands.insertContent("\n");
- return true;
- }
-
- return editor
- .chain()
- .command(({ tr }) => {
- tr.delete($from.pos - 2, $from.pos);
-
- return true;
- })
- .exitCode()
- .run();
- },
- "Shift-Enter": ({ editor }) => {
- const { $from } = editor.state.selection;
-
- if (!editor.isActive(this.name)) {
- return false;
- }
-
- editor
- .chain()
- .insertContentAt(
- $from.pos - $from.parentOffset + $from.parent.nodeSize,
- {
- type: "paragraph",
- },
- )
- .run();
-
- return true;
- },
- };
- },
-});
-
-export const CodeBlock = createBlockSpecFromStronglyTypedTiptapNode(
- CodeBlockContent,
- defaultCodeBlockPropSchema,
-);
-
-function getLanguageId(
- options: CodeBlockOptions,
- languageName: string,
-): string | undefined {
- return Object.entries(options.supportedLanguages).find(
- ([id, { aliases }]) => {
- return aliases?.includes(languageName) || id === languageName;
- },
- )?.[0];
-}
diff --git a/packages/core/src/blocks/Divider/block.ts b/packages/core/src/blocks/Divider/block.ts
new file mode 100644
index 0000000000..ad847ddd0c
--- /dev/null
+++ b/packages/core/src/blocks/Divider/block.ts
@@ -0,0 +1,49 @@
+import { createExtension } from "../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
+
+export type DividerBlockConfig = ReturnType;
+
+export const createDividerBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "divider" as const,
+ propSchema: {},
+ content: "none",
+ }) as const,
+);
+
+export const createDividerBlockSpec = createBlockSpec(
+ createDividerBlockConfig,
+ {
+ meta: {
+ isolating: false,
+ },
+ parse(element) {
+ if (element.tagName === "HR") {
+ return {};
+ }
+
+ return undefined;
+ },
+ render() {
+ const dom = document.createElement("hr");
+
+ return {
+ dom,
+ };
+ },
+ },
+ [
+ createExtension({
+ key: "divider-block-shortcuts",
+ inputRules: [
+ {
+ find: new RegExp(`^---$`),
+ replace() {
+ return { type: "divider", props: {}, content: [] };
+ },
+ },
+ ],
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/File/block.ts b/packages/core/src/blocks/File/block.ts
new file mode 100644
index 0000000000..8e1ddf622f
--- /dev/null
+++ b/packages/core/src/blocks/File/block.ts
@@ -0,0 +1,95 @@
+import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
+import { defaultProps, parseDefaultProps } from "../defaultProps.js";
+import { parseEmbedElement } from "./helpers/parse/parseEmbedElement.js";
+import { parseFigureElement } from "./helpers/parse/parseFigureElement.js";
+import { createFileBlockWrapper } from "./helpers/render/createFileBlockWrapper.js";
+import { createLinkWithCaption } from "./helpers/toExternalHTML/createLinkWithCaption.js";
+
+export type FileBlockConfig = ReturnType;
+
+export const createFileBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "file" as const,
+ propSchema: {
+ backgroundColor: defaultProps.backgroundColor,
+ // File name.
+ name: {
+ default: "" as const,
+ },
+ // File url.
+ url: {
+ default: "" as const,
+ },
+ // File caption.
+ caption: {
+ default: "" as const,
+ },
+ },
+ content: "none" as const,
+ }) as const,
+);
+
+export const fileParse = () => (element: HTMLElement) => {
+ if (element.tagName === "EMBED") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseEmbedElement(element as HTMLEmbedElement),
+ backgroundColor,
+ };
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "embed");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseEmbedElement(targetElement as HTMLEmbedElement),
+ backgroundColor,
+ caption,
+ };
+ }
+
+ return undefined;
+};
+
+export const createFileBlockSpec = createBlockSpec(createFileBlockConfig, {
+ meta: {
+ fileBlockAccept: ["*/*"],
+ },
+ parse: fileParse(),
+ render(block, editor) {
+ return createFileBlockWrapper(block, editor);
+ },
+ toExternalHTML(block) {
+ if (!block.props.url) {
+ return {
+ dom: document.createElement("embed"),
+ };
+ }
+
+ const fileSrcLink = document.createElement("a");
+ fileSrcLink.href = block.props.url;
+ fileSrcLink.textContent = block.props.name || block.props.url;
+
+ if (block.props.caption) {
+ return createLinkWithCaption(fileSrcLink, block.props.caption);
+ }
+
+ return {
+ dom: fileSrcLink,
+ };
+ },
+});
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/parse/parseEmbedElement.ts b/packages/core/src/blocks/File/helpers/parse/parseEmbedElement.ts
similarity index 100%
rename from packages/core/src/blocks/FileBlockContent/helpers/parse/parseEmbedElement.ts
rename to packages/core/src/blocks/File/helpers/parse/parseEmbedElement.ts
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/parse/parseFigureElement.ts b/packages/core/src/blocks/File/helpers/parse/parseFigureElement.ts
similarity index 100%
rename from packages/core/src/blocks/FileBlockContent/helpers/parse/parseFigureElement.ts
rename to packages/core/src/blocks/File/helpers/parse/parseFigureElement.ts
diff --git a/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts b/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts
new file mode 100644
index 0000000000..93440e13a6
--- /dev/null
+++ b/packages/core/src/blocks/File/helpers/render/createAddFileButton.ts
@@ -0,0 +1,69 @@
+import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import { FilePanelExtension } from "../../../../extensions/FilePanel/FilePanel.js";
+import {
+ BlockConfig,
+ BlockFromConfigNoChildren,
+} from "../../../../schema/index.js";
+
+export const createAddFileButton = (
+ block: BlockFromConfigNoChildren, any, any>,
+ editor: BlockNoteEditor,
+ buttonIcon?: HTMLElement,
+) => {
+ const addFileButton = document.createElement("div");
+ addFileButton.className = "bn-add-file-button";
+
+ const addFileButtonIcon = document.createElement("div");
+ addFileButtonIcon.className = "bn-add-file-button-icon";
+ if (buttonIcon) {
+ addFileButtonIcon.appendChild(buttonIcon);
+ } else {
+ addFileButtonIcon.innerHTML =
+ ' ';
+ }
+ addFileButton.appendChild(addFileButtonIcon);
+
+ const addFileButtonText = document.createElement("p");
+ addFileButtonText.className = "bn-add-file-button-text";
+ addFileButtonText.innerHTML =
+ block.type in editor.dictionary.file_blocks.add_button_text
+ ? editor.dictionary.file_blocks.add_button_text[block.type]
+ : editor.dictionary.file_blocks.add_button_text["file"];
+ addFileButton.appendChild(addFileButtonText);
+
+ // Prevents focus from moving to the button.
+ const addFileButtonMouseDownHandler = (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+ // Opens the file toolbar.
+ const addFileButtonClickHandler = () => {
+ if (!editor.isEditable) {
+ return;
+ }
+
+ editor.getExtension(FilePanelExtension)?.showMenu(block.id);
+ };
+ addFileButton.addEventListener(
+ "mousedown",
+ addFileButtonMouseDownHandler,
+ true,
+ );
+ addFileButton.addEventListener("click", addFileButtonClickHandler, true);
+
+ return {
+ dom: addFileButton,
+ destroy: () => {
+ addFileButton.removeEventListener(
+ "mousedown",
+ addFileButtonMouseDownHandler,
+ true,
+ );
+ addFileButton.removeEventListener(
+ "click",
+ addFileButtonClickHandler,
+ true,
+ );
+ },
+ };
+};
diff --git a/packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts b/packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts
new file mode 100644
index 0000000000..51de658d2c
--- /dev/null
+++ b/packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts
@@ -0,0 +1,88 @@
+import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import {
+ BlockConfig,
+ BlockFromConfigNoChildren,
+} from "../../../../schema/index.js";
+import { createAddFileButton } from "./createAddFileButton.js";
+import { createFileNameWithIcon } from "./createFileNameWithIcon.js";
+
+export const createFileBlockWrapper = (
+ block: BlockFromConfigNoChildren<
+ BlockConfig<
+ string,
+ {
+ backgroundColor: { default: "default" };
+ name: { default: "" };
+ url: { default: "" };
+ caption: { default: "" };
+ showPreview?: { default: true };
+ },
+ "none"
+ >,
+ any,
+ any
+ >,
+ editor: BlockNoteEditor,
+ element?: { dom: HTMLElement; destroy?: () => void },
+ buttonIcon?: HTMLElement,
+) => {
+ // Use a / when the block has a caption, so the caption
+ // is semantically associated with its content for assistive tech. Falls back
+ // to a plain when there is no caption (or the file has not been
+ // uploaded yet, since the upload UI never shows the caption).
+ const useFigure = block.props.url !== "" && !!block.props.caption;
+ const wrapper = document.createElement(useFigure ? "figure" : "div");
+ wrapper.className = "bn-file-block-content-wrapper";
+
+ // Show the add file button if the file has not been uploaded yet. Change to
+ // show a loader if a file upload for the block begins.
+ if (block.props.url === "") {
+ const addFileButton = createAddFileButton(block, editor, buttonIcon);
+ wrapper.appendChild(addFileButton.dom);
+
+ const destroyUploadStartHandler = editor.onUploadStart((blockId) => {
+ if (blockId === block.id) {
+ wrapper.removeChild(addFileButton.dom);
+
+ const loading = document.createElement("div");
+ loading.className = "bn-file-loading-preview";
+ loading.textContent = "Loading...";
+ wrapper.appendChild(loading);
+ }
+ });
+
+ return {
+ dom: wrapper,
+ destroy: () => {
+ destroyUploadStartHandler();
+ addFileButton.destroy();
+ },
+ };
+ }
+
+ const ret: { dom: HTMLElement; destroy?: () => void } = { dom: wrapper };
+
+ // Show the file preview, or the file name and icon.
+ if (block.props.showPreview === false || !element) {
+ // Show file name and icon.
+ const fileNameWithIcon = createFileNameWithIcon(block);
+ wrapper.appendChild(fileNameWithIcon.dom);
+
+ ret.destroy = () => {
+ fileNameWithIcon.destroy?.();
+ };
+ } else {
+ // Show file preview.
+ wrapper.appendChild(element.dom);
+ }
+
+ // Show the caption if there is one.
+ if (block.props.caption) {
+ const caption = document.createElement("figcaption");
+ caption.className = "bn-file-caption";
+ caption.textContent = block.props.caption;
+ wrapper.appendChild(caption);
+ }
+
+ return ret;
+};
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts b/packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts
similarity index 76%
rename from packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts
rename to packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts
index dded8dbde6..05ba0d6281 100644
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts
+++ b/packages/core/src/blocks/File/helpers/render/createFileNameWithIcon.ts
@@ -1,9 +1,22 @@
-import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js";
+import {
+ BlockConfig,
+ BlockFromConfigNoChildren,
+} from "../../../../schema/index.js";
export const FILE_ICON_SVG = `
`;
export const createFileNameWithIcon = (
- block: BlockFromConfig
,
+ block: BlockFromConfigNoChildren<
+ BlockConfig<
+ string,
+ {
+ name: { default: "" };
+ },
+ "none"
+ >,
+ any,
+ any
+ >,
): { dom: HTMLElement; destroy?: () => void } => {
const file = document.createElement("div");
file.className = "bn-file-name-with-icon";
diff --git a/packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts b/packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts
new file mode 100644
index 0000000000..4b78b8029f
--- /dev/null
+++ b/packages/core/src/blocks/File/helpers/render/createResizableFileBlockWrapper.ts
@@ -0,0 +1,271 @@
+import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import {
+ BlockConfig,
+ BlockFromConfigNoChildren,
+} from "../../../../schema/index.js";
+import { createFileBlockWrapper } from "./createFileBlockWrapper.js";
+
+export const createResizableFileBlockWrapper = (
+ block: BlockFromConfigNoChildren<
+ BlockConfig<
+ string,
+ {
+ backgroundColor: { default: "default" };
+ name: { default: "" };
+ url: { default: "" };
+ caption: { default: "" };
+ showPreview?: { default: true };
+ previewWidth?: { default: number };
+ textAlignment?: { default: "left" };
+ },
+ "none"
+ >,
+ any,
+ any
+ >,
+ editor: BlockNoteEditor,
+ element: { dom: HTMLElement; destroy?: () => void },
+ resizeHandlesContainerElement: HTMLElement,
+ buttonIcon?: HTMLElement,
+): { dom: HTMLElement; destroy: () => void } => {
+ const { dom, destroy } = createFileBlockWrapper(
+ block,
+ editor,
+ element,
+ buttonIcon,
+ );
+ const wrapper = dom;
+ wrapper.style.position = "relative";
+ if (block.props.url && block.props.showPreview) {
+ if (block.props.previewWidth) {
+ wrapper.style.width = `${block.props.previewWidth}px`;
+ } else {
+ wrapper.style.width = "fit-content";
+ }
+ }
+
+ const leftResizeHandle = document.createElement("div");
+ leftResizeHandle.className = "bn-resize-handle";
+ leftResizeHandle.style.left = "4px";
+ leftResizeHandle.style.display = "none";
+ resizeHandlesContainerElement.appendChild(leftResizeHandle);
+ const rightResizeHandle = document.createElement("div");
+ rightResizeHandle.className = "bn-resize-handle";
+ rightResizeHandle.style.right = "4px";
+ rightResizeHandle.style.display = "none";
+ resizeHandlesContainerElement.appendChild(rightResizeHandle);
+
+ // This element ensures `mousemove` and `mouseup` events are captured while
+ // resizing when the cursor is over the wrapper content. This is because
+ // embeds are treated as separate HTML documents, so if the content is an
+ // embed, the events will only fire within that document.
+ const eventCaptureElement = document.createElement("div");
+ eventCaptureElement.style.position = "absolute";
+ eventCaptureElement.style.height = "100%";
+ eventCaptureElement.style.width = "100%";
+
+ // Temporary parameters set when the user begins resizing the element, used to
+ // calculate the new width of the element.
+ let resizeParams:
+ | {
+ handleUsed: "left" | "right";
+ initialWidth: number;
+ initialClientX: number;
+ }
+ | undefined;
+ let width = block.props.previewWidth! as number;
+
+ // Updates the element width with an updated width depending on the cursor X
+ // offset from when the resize began, and which resize handle is being used.
+ const windowMouseMoveHandler = (event: MouseEvent | TouchEvent) => {
+ if (!resizeParams) {
+ if (!editor.isEditable) {
+ leftResizeHandle.style.display = "none";
+ rightResizeHandle.style.display = "none";
+ }
+
+ return;
+ }
+
+ let newWidth: number;
+
+ const clientX =
+ "touches" in event ? event.touches[0].clientX : event.clientX;
+
+ if (block.props.textAlignment === "center") {
+ if (resizeParams.handleUsed === "left") {
+ newWidth =
+ resizeParams.initialWidth +
+ (resizeParams.initialClientX - clientX) * 2;
+ } else {
+ newWidth =
+ resizeParams.initialWidth +
+ (clientX - resizeParams.initialClientX) * 2;
+ }
+ } else {
+ if (resizeParams.handleUsed === "left") {
+ newWidth =
+ resizeParams.initialWidth + resizeParams.initialClientX - clientX;
+ } else {
+ newWidth =
+ resizeParams.initialWidth + clientX - resizeParams.initialClientX;
+ }
+ }
+
+ // Min element width in px.
+ const minWidth = 64;
+
+ // Ensures the element is not wider than the editor and not narrower than a
+ // predetermined minimum width.
+ width = Math.min(
+ Math.max(newWidth, minWidth),
+ editor.domElement?.firstElementChild?.clientWidth || Number.MAX_VALUE,
+ );
+ wrapper.style.width = `${width}px`;
+ };
+ // Stops mouse movements from resizing the element and updates the block's
+ // `width` prop to the new value.
+ const windowMouseUpHandler = (event: MouseEvent | TouchEvent) => {
+ // Hides the drag handles if the cursor is no longer over the element.
+ if (
+ !event.target ||
+ !wrapper.contains(event.target as Node) ||
+ !editor.isEditable
+ ) {
+ leftResizeHandle.style.display = "none";
+ rightResizeHandle.style.display = "none";
+ }
+
+ if (!resizeParams) {
+ return;
+ }
+
+ resizeParams = undefined;
+
+ if (wrapper.contains(eventCaptureElement)) {
+ wrapper.removeChild(eventCaptureElement);
+ }
+
+ editor.updateBlock(block, {
+ props: {
+ previewWidth: width,
+ },
+ });
+ };
+
+ // Shows the resize handles when hovering over the wrapper with the cursor.
+ const wrapperMouseEnterHandler = () => {
+ if (editor.isEditable) {
+ leftResizeHandle.style.display = "";
+ rightResizeHandle.style.display = "";
+ }
+ };
+ // Hides the resize handles when the cursor leaves the wrapper, unless the
+ // cursor moves to one of the resize handles.
+ const wrapperMouseLeaveHandler = (event: MouseEvent) => {
+ if (
+ event.relatedTarget === leftResizeHandle ||
+ event.relatedTarget === rightResizeHandle
+ ) {
+ return;
+ }
+
+ if (resizeParams) {
+ return;
+ }
+
+ if (editor.isEditable) {
+ leftResizeHandle.style.display = "none";
+ rightResizeHandle.style.display = "none";
+ }
+ };
+
+ // Sets the resize params, allowing the user to begin resizing the element by
+ // moving the cursor left or right.
+ const leftResizeHandleMouseDownHandler = (event: MouseEvent | TouchEvent) => {
+ event.preventDefault();
+
+ if (!wrapper.contains(eventCaptureElement)) {
+ wrapper.appendChild(eventCaptureElement);
+ }
+
+ const clientX =
+ "touches" in event ? event.touches[0].clientX : event.clientX;
+
+ resizeParams = {
+ handleUsed: "left",
+ initialWidth: wrapper.clientWidth,
+ initialClientX: clientX,
+ };
+ };
+ const rightResizeHandleMouseDownHandler = (
+ event: MouseEvent | TouchEvent,
+ ) => {
+ event.preventDefault();
+
+ if (!wrapper.contains(eventCaptureElement)) {
+ wrapper.appendChild(eventCaptureElement);
+ }
+
+ const clientX =
+ "touches" in event ? event.touches[0].clientX : event.clientX;
+
+ resizeParams = {
+ handleUsed: "right",
+ initialWidth: wrapper.clientWidth,
+ initialClientX: clientX,
+ };
+ };
+
+ window.addEventListener("mousemove", windowMouseMoveHandler);
+ window.addEventListener("touchmove", windowMouseMoveHandler);
+ window.addEventListener("mouseup", windowMouseUpHandler);
+ window.addEventListener("touchend", windowMouseUpHandler);
+ wrapper.addEventListener("mouseenter", wrapperMouseEnterHandler);
+ wrapper.addEventListener("mouseleave", wrapperMouseLeaveHandler);
+ leftResizeHandle.addEventListener(
+ "mousedown",
+ leftResizeHandleMouseDownHandler,
+ );
+ leftResizeHandle.addEventListener(
+ "touchstart",
+ leftResizeHandleMouseDownHandler,
+ );
+ rightResizeHandle.addEventListener(
+ "mousedown",
+ rightResizeHandleMouseDownHandler,
+ );
+ rightResizeHandle.addEventListener(
+ "touchstart",
+ rightResizeHandleMouseDownHandler,
+ );
+
+ return {
+ dom: wrapper,
+ destroy: () => {
+ destroy?.();
+ window.removeEventListener("mousemove", windowMouseMoveHandler);
+ window.removeEventListener("touchmove", windowMouseMoveHandler);
+ window.removeEventListener("mouseup", windowMouseUpHandler);
+ window.removeEventListener("touchend", windowMouseUpHandler);
+ wrapper.removeEventListener("mouseenter", wrapperMouseEnterHandler);
+ wrapper.removeEventListener("mouseleave", wrapperMouseLeaveHandler);
+ leftResizeHandle.removeEventListener(
+ "mousedown",
+ leftResizeHandleMouseDownHandler,
+ );
+ leftResizeHandle.removeEventListener(
+ "touchstart",
+ leftResizeHandleMouseDownHandler,
+ );
+ rightResizeHandle.removeEventListener(
+ "mousedown",
+ rightResizeHandleMouseDownHandler,
+ );
+ rightResizeHandle.removeEventListener(
+ "touchstart",
+ rightResizeHandleMouseDownHandler,
+ );
+ },
+ };
+};
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.ts b/packages/core/src/blocks/File/helpers/toExternalHTML/createFigureWithCaption.ts
similarity index 100%
rename from packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.ts
rename to packages/core/src/blocks/File/helpers/toExternalHTML/createFigureWithCaption.ts
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.ts b/packages/core/src/blocks/File/helpers/toExternalHTML/createLinkWithCaption.ts
similarity index 100%
rename from packages/core/src/blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.ts
rename to packages/core/src/blocks/File/helpers/toExternalHTML/createLinkWithCaption.ts
diff --git a/packages/core/src/blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts b/packages/core/src/blocks/File/helpers/uploadToTmpFilesDotOrg_DEV_ONLY.ts
similarity index 100%
rename from packages/core/src/blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts
rename to packages/core/src/blocks/File/helpers/uploadToTmpFilesDotOrg_DEV_ONLY.ts
diff --git a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts
deleted file mode 100644
index 433487d8e0..0000000000
--- a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import {
- BlockFromConfig,
- FileBlockConfig,
- PropSchema,
- createBlockSpec,
-} from "../../schema/index.js";
-import { defaultProps } from "../defaultProps.js";
-import { parseEmbedElement } from "./helpers/parse/parseEmbedElement.js";
-import { parseFigureElement } from "./helpers/parse/parseFigureElement.js";
-import { createFileBlockWrapper } from "./helpers/render/createFileBlockWrapper.js";
-import { createLinkWithCaption } from "./helpers/toExternalHTML/createLinkWithCaption.js";
-
-export const filePropSchema = {
- backgroundColor: defaultProps.backgroundColor,
- // File name.
- name: {
- default: "" as const,
- },
- // File url.
- url: {
- default: "" as const,
- },
- // File caption.
- caption: {
- default: "" as const,
- },
-} satisfies PropSchema;
-
-export const fileBlockConfig = {
- type: "file" as const,
- propSchema: filePropSchema,
- content: "none",
- isFileBlock: true,
-} satisfies FileBlockConfig;
-
-export const fileRender = (
- block: BlockFromConfig,
- editor: BlockNoteEditor,
-) => {
- return createFileBlockWrapper(block, editor);
-};
-
-export const fileParse = (element: HTMLElement) => {
- if (element.tagName === "EMBED") {
- // Ignore if parent figure has already been parsed.
- if (element.closest("figure")) {
- return undefined;
- }
-
- return parseEmbedElement(element as HTMLEmbedElement);
- }
-
- if (element.tagName === "FIGURE") {
- const parsedFigure = parseFigureElement(element, "embed");
- if (!parsedFigure) {
- return undefined;
- }
-
- const { targetElement, caption } = parsedFigure;
-
- return {
- ...parseEmbedElement(targetElement as HTMLEmbedElement),
- caption,
- };
- }
-
- return undefined;
-};
-
-export const fileToExternalHTML = (
- block: BlockFromConfig,
-) => {
- if (!block.props.url) {
- const div = document.createElement("p");
- div.textContent = "Add file";
-
- return {
- dom: div,
- };
- }
-
- const fileSrcLink = document.createElement("a");
- fileSrcLink.href = block.props.url;
- fileSrcLink.textContent = block.props.name || block.props.url;
-
- if (block.props.caption) {
- return createLinkWithCaption(fileSrcLink, block.props.caption);
- }
-
- return {
- dom: fileSrcLink,
- };
-};
-
-export const FileBlock = createBlockSpec(fileBlockConfig, {
- render: fileRender,
- parse: fileParse,
- toExternalHTML: fileToExternalHTML,
-});
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts
deleted file mode 100644
index 686b1acf0d..0000000000
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js";
-
-export const createAddFileButton = (
- block: BlockFromConfig,
- editor: BlockNoteEditor,
- buttonText?: string,
- buttonIcon?: HTMLElement,
-) => {
- const addFileButton = document.createElement("div");
- addFileButton.className = "bn-add-file-button";
-
- const addFileButtonIcon = document.createElement("div");
- addFileButtonIcon.className = "bn-add-file-button-icon";
- if (buttonIcon) {
- addFileButtonIcon.appendChild(buttonIcon);
- } else {
- addFileButtonIcon.innerHTML =
- ' ';
- }
- addFileButton.appendChild(addFileButtonIcon);
-
- const addFileButtonText = document.createElement("p");
- addFileButtonText.className = "bn-add-file-button-text";
- addFileButtonText.innerHTML =
- buttonText || editor.dictionary.file_blocks.file.add_button_text;
- addFileButton.appendChild(addFileButtonText);
-
- // Prevents focus from moving to the button.
- const addFileButtonMouseDownHandler = (event: MouseEvent) => {
- event.preventDefault();
- };
- // Opens the file toolbar.
- const addFileButtonClickHandler = () => {
- editor.transact((tr) =>
- tr.setMeta(editor.filePanel!.plugin, {
- block: block,
- }),
- );
- };
- addFileButton.addEventListener(
- "mousedown",
- addFileButtonMouseDownHandler,
- true,
- );
- addFileButton.addEventListener("click", addFileButtonClickHandler, true);
-
- return {
- dom: addFileButton,
- destroy: () => {
- addFileButton.removeEventListener(
- "mousedown",
- addFileButtonMouseDownHandler,
- true,
- );
- addFileButton.removeEventListener(
- "click",
- addFileButtonClickHandler,
- true,
- );
- },
- };
-};
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts
deleted file mode 100644
index 02af916f2d..0000000000
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import {
- BlockFromConfig,
- BlockSchemaWithBlock,
- FileBlockConfig,
-} from "../../../../schema/index.js";
-import { createAddFileButton } from "./createAddFileButton.js";
-import { createFileNameWithIcon } from "./createFileNameWithIcon.js";
-
-export const createFileBlockWrapper = (
- block: BlockFromConfig,
- editor: BlockNoteEditor<
- BlockSchemaWithBlock,
- any,
- any
- >,
- element?: { dom: HTMLElement; destroy?: () => void },
- buttonText?: string,
- buttonIcon?: HTMLElement,
-) => {
- const wrapper = document.createElement("div");
- wrapper.className = "bn-file-block-content-wrapper";
-
- // Show the add file button if the file has not been uploaded yet. Change to
- // show a loader if a file upload for the block begins.
- if (block.props.url === "") {
- const addFileButton = createAddFileButton(
- block,
- editor,
- buttonText,
- buttonIcon,
- );
- wrapper.appendChild(addFileButton.dom);
-
- const destroyUploadStartHandler = editor.onUploadStart((blockId) => {
- if (blockId === block.id) {
- wrapper.removeChild(addFileButton.dom);
-
- const loading = document.createElement("div");
- loading.className = "bn-file-loading-preview";
- loading.textContent = "Loading...";
- wrapper.appendChild(loading);
- }
- });
-
- return {
- dom: wrapper,
- destroy: () => {
- destroyUploadStartHandler();
- addFileButton.destroy();
- },
- };
- }
-
- const ret: { dom: HTMLElement; destroy?: () => void } = { dom: wrapper };
-
- // Show the file preview, or the file name and icon.
- if (block.props.showPreview === false || !element) {
- // Show file name and icon.
- const fileNameWithIcon = createFileNameWithIcon(block);
- wrapper.appendChild(fileNameWithIcon.dom);
-
- ret.destroy = () => {
- fileNameWithIcon.destroy?.();
- };
- } else {
- // Show file preview.
- wrapper.appendChild(element.dom);
- }
-
- // Show the caption if there is one.
- if (block.props.caption) {
- const caption = document.createElement("p");
- caption.className = "bn-file-caption";
- caption.textContent = block.props.caption;
- wrapper.appendChild(caption);
- }
-
- return ret;
-};
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts
deleted file mode 100644
index b4c4ae95d7..0000000000
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js";
-import { createFileBlockWrapper } from "./createFileBlockWrapper.js";
-
-export const createResizableFileBlockWrapper = (
- block: BlockFromConfig,
- editor: BlockNoteEditor,
- element: { dom: HTMLElement; destroy?: () => void },
- resizeHandlesContainerElement: HTMLElement,
- buttonText: string,
- buttonIcon: HTMLElement,
-): { dom: HTMLElement; destroy: () => void } => {
- const { dom, destroy } = createFileBlockWrapper(
- block,
- editor,
- element,
- buttonText,
- buttonIcon,
- );
- const wrapper = dom;
- if (block.props.url && block.props.showPreview) {
- if (block.props.previewWidth) {
- wrapper.style.width = `${block.props.previewWidth}px`;
- } else {
- wrapper.style.width = "fit-content";
- }
- }
-
- const leftResizeHandle = document.createElement("div");
- leftResizeHandle.className = "bn-resize-handle";
- leftResizeHandle.style.left = "4px";
- const rightResizeHandle = document.createElement("div");
- rightResizeHandle.className = "bn-resize-handle";
- rightResizeHandle.style.right = "4px";
-
- // Temporary parameters set when the user begins resizing the element, used to
- // calculate the new width of the element.
- let resizeParams:
- | {
- handleUsed: "left" | "right";
- initialWidth: number;
- initialClientX: number;
- }
- | undefined;
- let width = block.props.previewWidth! as number;
-
- // Updates the element width with an updated width depending on the cursor X
- // offset from when the resize began, and which resize handle is being used.
- const windowMouseMoveHandler = (event: MouseEvent) => {
- if (!resizeParams) {
- if (
- !editor.isEditable &&
- resizeHandlesContainerElement.contains(leftResizeHandle) &&
- resizeHandlesContainerElement.contains(rightResizeHandle)
- ) {
- resizeHandlesContainerElement.removeChild(leftResizeHandle);
- resizeHandlesContainerElement.removeChild(rightResizeHandle);
- }
-
- return;
- }
-
- let newWidth: number;
-
- if (block.props.textAlignment === "center") {
- if (resizeParams.handleUsed === "left") {
- newWidth =
- resizeParams.initialWidth +
- (resizeParams.initialClientX - event.clientX) * 2;
- } else {
- newWidth =
- resizeParams.initialWidth +
- (event.clientX - resizeParams.initialClientX) * 2;
- }
- } else {
- if (resizeParams.handleUsed === "left") {
- newWidth =
- resizeParams.initialWidth +
- resizeParams.initialClientX -
- event.clientX;
- } else {
- newWidth =
- resizeParams.initialWidth +
- event.clientX -
- resizeParams.initialClientX;
- }
- }
-
- // Min element width in px.
- const minWidth = 64;
-
- // Ensures the element is not wider than the editor and not narrower than a
- // predetermined minimum width.
- width = Math.min(
- Math.max(newWidth, minWidth),
- editor.domElement?.firstElementChild?.clientWidth || Number.MAX_VALUE,
- );
- wrapper.style.width = `${width}px`;
- };
- // Stops mouse movements from resizing the element and updates the block's
- // `width` prop to the new value.
- const windowMouseUpHandler = (event: MouseEvent) => {
- // Hides the drag handles if the cursor is no longer over the element.
- if (
- (!event.target ||
- !wrapper.contains(event.target as Node) ||
- !editor.isEditable) &&
- resizeHandlesContainerElement.contains(leftResizeHandle) &&
- resizeHandlesContainerElement.contains(rightResizeHandle)
- ) {
- resizeHandlesContainerElement.removeChild(leftResizeHandle);
- resizeHandlesContainerElement.removeChild(rightResizeHandle);
- }
-
- if (!resizeParams) {
- return;
- }
-
- resizeParams = undefined;
-
- editor.updateBlock(block, {
- props: {
- previewWidth: width,
- },
- });
- };
-
- // Shows the resize handles when hovering over the wrapper with the cursor.
- const wrapperMouseEnterHandler = () => {
- if (editor.isEditable) {
- resizeHandlesContainerElement.appendChild(leftResizeHandle);
- resizeHandlesContainerElement.appendChild(rightResizeHandle);
- }
- };
- // Hides the resize handles when the cursor leaves the wrapper, unless the
- // cursor moves to one of the resize handles.
- const wrapperMouseLeaveHandler = (event: MouseEvent) => {
- if (
- event.relatedTarget === leftResizeHandle ||
- event.relatedTarget === rightResizeHandle
- ) {
- return;
- }
-
- if (resizeParams) {
- return;
- }
-
- if (
- editor.isEditable &&
- resizeHandlesContainerElement.contains(leftResizeHandle) &&
- resizeHandlesContainerElement.contains(rightResizeHandle)
- ) {
- resizeHandlesContainerElement.removeChild(leftResizeHandle);
- resizeHandlesContainerElement.removeChild(rightResizeHandle);
- }
- };
-
- // Sets the resize params, allowing the user to begin resizing the element by
- // moving the cursor left or right.
- const leftResizeHandleMouseDownHandler = (event: MouseEvent) => {
- event.preventDefault();
-
- resizeParams = {
- handleUsed: "left",
- initialWidth: wrapper.clientWidth,
- initialClientX: event.clientX,
- };
- };
- const rightResizeHandleMouseDownHandler = (event: MouseEvent) => {
- event.preventDefault();
-
- resizeParams = {
- handleUsed: "right",
- initialWidth: wrapper.clientWidth,
- initialClientX: event.clientX,
- };
- };
-
- window.addEventListener("mousemove", windowMouseMoveHandler);
- window.addEventListener("mouseup", windowMouseUpHandler);
- wrapper.addEventListener("mouseenter", wrapperMouseEnterHandler);
- wrapper.addEventListener("mouseleave", wrapperMouseLeaveHandler);
- leftResizeHandle.addEventListener(
- "mousedown",
- leftResizeHandleMouseDownHandler,
- );
- rightResizeHandle.addEventListener(
- "mousedown",
- rightResizeHandleMouseDownHandler,
- );
-
- return {
- dom: wrapper,
- destroy: () => {
- destroy?.();
- window.removeEventListener("mousemove", windowMouseMoveHandler);
- window.removeEventListener("mouseup", windowMouseUpHandler);
- wrapper.removeEventListener("mouseenter", wrapperMouseEnterHandler);
- wrapper.removeEventListener("mouseleave", wrapperMouseLeaveHandler);
- leftResizeHandle.removeEventListener(
- "mousedown",
- leftResizeHandleMouseDownHandler,
- );
- rightResizeHandle.removeEventListener(
- "mousedown",
- rightResizeHandleMouseDownHandler,
- );
- },
- };
-};
diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts
new file mode 100644
index 0000000000..6b14204cc8
--- /dev/null
+++ b/packages/core/src/blocks/Heading/block.ts
@@ -0,0 +1,188 @@
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { createExtension } from "../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
+import {
+ addDefaultPropsExternalHTML,
+ defaultProps,
+ parseDefaultProps,
+} from "../defaultProps.js";
+import { getDetailsContent } from "../getDetailsContent.js";
+import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js";
+
+const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;
+
+export interface HeadingOptions {
+ defaultLevel?: (typeof HEADING_LEVELS)[number];
+ levels?: readonly number[];
+ // TODO should probably use composition instead of this
+ allowToggleHeadings?: boolean;
+}
+
+const createHeadingKeyboardShortcut =
+ (level: number) =>
+ ({ editor }: { editor: BlockNoteEditor }) => {
+ const cursorPosition = editor.getTextCursorPosition();
+
+ if (
+ editor.schema.blockSchema[cursorPosition.block.type].content !== "inline"
+ ) {
+ return false;
+ }
+
+ editor.updateBlock(cursorPosition.block, {
+ type: "heading",
+ props: { level },
+ });
+
+ return true;
+ };
+
+export type HeadingBlockConfig = ReturnType;
+
+export const createHeadingBlockConfig = createBlockConfig(
+ ({
+ defaultLevel = 1,
+ levels = HEADING_LEVELS,
+ allowToggleHeadings = true,
+ }: HeadingOptions = {}) =>
+ ({
+ type: "heading" as const,
+ propSchema: {
+ ...defaultProps,
+ level: { default: defaultLevel, values: levels },
+ ...(allowToggleHeadings
+ ? { isToggleable: { default: false, optional: true } as const }
+ : {}),
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const createHeadingBlockSpec = createBlockSpec(
+ createHeadingBlockConfig,
+ ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({
+ meta: {
+ isolating: false,
+ },
+ parse(e) {
+ if (allowToggleHeadings && e.tagName === "DETAILS") {
+ const summary = e.querySelector(":scope > summary");
+ if (!summary) {
+ return undefined;
+ }
+
+ const heading = summary.querySelector("h1, h2, h3, h4, h5, h6");
+ if (!heading) {
+ return undefined;
+ }
+
+ return {
+ ...parseDefaultProps(heading as HTMLElement),
+ level: parseInt(heading.tagName[1]),
+ isToggleable: true,
+ };
+ }
+
+ let level: number;
+ switch (e.tagName) {
+ case "H1":
+ level = 1;
+ break;
+ case "H2":
+ level = 2;
+ break;
+ case "H3":
+ level = 3;
+ break;
+ case "H4":
+ level = 4;
+ break;
+ case "H5":
+ level = 5;
+ break;
+ case "H6":
+ level = 6;
+ break;
+ default:
+ return undefined;
+ }
+
+ return {
+ ...parseDefaultProps(e),
+ level,
+ };
+ },
+ ...(allowToggleHeadings
+ ? {
+ parseContent: ({ el, schema }: { el: HTMLElement; schema: any }) => {
+ if (el.tagName === "DETAILS") {
+ return getDetailsContent(el, schema, "heading");
+ }
+
+ // Regular heading (H1-H6): return undefined to fall through to
+ // the default inline content parsing in createSpec.
+ return undefined;
+ },
+ }
+ : {}),
+ runsBefore: ["toggleListItem"],
+ render(block, editor) {
+ const dom = document.createElement(`h${block.props.level}`);
+
+ if (allowToggleHeadings) {
+ const toggleWrapper = createToggleWrapper(block, editor, dom);
+ return { ...toggleWrapper, contentDOM: dom };
+ }
+
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ toExternalHTML(block) {
+ const dom = document.createElement(`h${block.props.level}`);
+ addDefaultPropsExternalHTML(block.props, dom);
+
+ if (allowToggleHeadings && block.props.isToggleable) {
+ const details = document.createElement("details");
+ details.setAttribute("open", "");
+ const summary = document.createElement("summary");
+ summary.appendChild(dom);
+ details.appendChild(summary);
+
+ return {
+ dom: details,
+ contentDOM: dom,
+ childrenDOM: details,
+ };
+ }
+
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ }),
+ ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [
+ createExtension({
+ key: "heading-shortcuts",
+ keyboardShortcuts: Object.fromEntries(
+ levels.map((level) => [
+ `Mod-Alt-${level}`,
+ createHeadingKeyboardShortcut(level),
+ ]) ?? [],
+ ),
+ inputRules: levels.map((level) => ({
+ find: new RegExp(`^(#{${level}})\\s$`),
+ replace({ match }: { match: RegExpMatchArray }) {
+ return {
+ type: "heading",
+ props: {
+ level: match[1].length,
+ },
+ };
+ },
+ })),
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts
deleted file mode 100644
index 8299892a03..0000000000
--- a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { InputRule } from "@tiptap/core";
-import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
-import {
- PropSchema,
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
- propsToAttributes,
-} from "../../schema/index.js";
-import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
-import { defaultProps } from "../defaultProps.js";
-
-export const headingPropSchema = {
- ...defaultProps,
- level: { default: 1, values: [1, 2, 3] as const },
-} satisfies PropSchema;
-
-const HeadingBlockContent = createStronglyTypedTiptapNode({
- name: "heading",
- content: "inline*",
- group: "blockContent",
-
- addAttributes() {
- return propsToAttributes(headingPropSchema);
- },
-
- addInputRules() {
- return [
- ...[1, 2, 3].map((level) => {
- // Creates a heading of appropriate level when starting with "#", "##", or "###".
- return new InputRule({
- find: new RegExp(`^(#{${level}})\\s$`),
- handler: ({ state, chain, range }) => {
- const blockInfo = getBlockInfoFromSelection(state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return;
- }
-
- chain()
- .command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "heading",
- props: {
- level: level as any,
- },
- }),
- )
- // Removes the "#" character(s) used to set the heading.
- .deleteRange({ from: range.from, to: range.to })
- .run();
- },
- });
- }),
- ];
- },
-
- addKeyboardShortcuts() {
- return {
- "Mod-Alt-1": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- // call updateBlockCommand
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "heading",
- props: {
- level: 1 as any,
- },
- }),
- );
- },
- "Mod-Alt-2": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "heading",
- props: {
- level: 2 as any,
- },
- }),
- );
- },
- "Mod-Alt-3": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "heading",
- props: {
- level: 3 as any,
- },
- }),
- );
- },
- };
- },
- parseHTML() {
- return [
- // Parse from internal HTML.
- {
- tag: "div[data-content-type=" + this.name + "]",
- contentElement: ".bn-inline-content",
- },
- // Parse from external HTML.
- {
- tag: "h1",
- attrs: { level: 1 },
- node: "heading",
- },
- {
- tag: "h2",
- attrs: { level: 2 },
- node: "heading",
- },
- {
- tag: "h3",
- attrs: { level: 3 },
- node: "heading",
- },
- ];
- },
-
- renderHTML({ node, HTMLAttributes }) {
- return createDefaultBlockDOMOutputSpec(
- this.name,
- `h${node.attrs.level}`,
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
- },
-});
-
-export const Heading = createBlockSpecFromStronglyTypedTiptapNode(
- HeadingBlockContent,
- headingPropSchema,
-);
diff --git a/packages/core/src/blocks/Image/block.ts b/packages/core/src/blocks/Image/block.ts
new file mode 100644
index 0000000000..e3ba986c6e
--- /dev/null
+++ b/packages/core/src/blocks/Image/block.ts
@@ -0,0 +1,193 @@
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import {
+ BlockFromConfig,
+ createBlockConfig,
+ createBlockSpec,
+} from "../../schema/index.js";
+import { defaultProps, parseDefaultProps } from "../defaultProps.js";
+import { parseFigureElement } from "../File/helpers/parse/parseFigureElement.js";
+import { createResizableFileBlockWrapper } from "../File/helpers/render/createResizableFileBlockWrapper.js";
+import { createFigureWithCaption } from "../File/helpers/toExternalHTML/createFigureWithCaption.js";
+import { createLinkWithCaption } from "../File/helpers/toExternalHTML/createLinkWithCaption.js";
+import { parseImageElement } from "./parseImageElement.js";
+
+export const FILE_IMAGE_ICON_SVG =
+ ' ';
+
+export interface ImageOptions {
+ icon?: string;
+}
+
+export type ImageBlockConfig = ReturnType;
+
+export const createImageBlockConfig = createBlockConfig(
+ (_ctx: ImageOptions = {}) =>
+ ({
+ type: "image" as const,
+ propSchema: {
+ textAlignment: defaultProps.textAlignment,
+ backgroundColor: defaultProps.backgroundColor,
+ // File name.
+ name: {
+ default: "" as const,
+ },
+ // File url.
+ url: {
+ default: "" as const,
+ },
+ // File caption.
+ caption: {
+ default: "" as const,
+ },
+
+ showPreview: {
+ default: true,
+ },
+ // File preview width in px.
+ previewWidth: {
+ default: undefined,
+ type: "number" as const,
+ },
+ },
+ content: "none" as const,
+ }) as const,
+);
+
+export const imageParse =
+ (_config: ImageOptions = {}) =>
+ (element: HTMLElement) => {
+ if (element.tagName === "IMG") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseImageElement(element as HTMLImageElement),
+ backgroundColor,
+ };
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "img");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseImageElement(targetElement as HTMLImageElement),
+ backgroundColor,
+ caption,
+ };
+ }
+
+ return undefined;
+ };
+
+export const imageRender =
+ (config: ImageOptions = {}) =>
+ (
+ block: BlockFromConfig, any, any>,
+ editor: BlockNoteEditor<
+ Record<"image", ReturnType>,
+ any,
+ any
+ >,
+ ) => {
+ const icon = document.createElement("div");
+ icon.innerHTML = config.icon ?? FILE_IMAGE_ICON_SVG;
+
+ const imageWrapper = document.createElement("div");
+ imageWrapper.className = "bn-visual-media-wrapper";
+
+ const image = document.createElement("img");
+ image.className = "bn-visual-media";
+ if (editor.resolveFileUrl) {
+ editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
+ image.src = downloadUrl;
+ });
+ } else {
+ image.src = block.props.url;
+ }
+
+ // alt describes image content (per WCAG H86); figcaption (when present)
+ // is the contextual caption. Fall back to "" so unlabelled images are
+ // marked decorative rather than getting a noisy generic fallback.
+ image.alt = block.props.name || "";
+ image.contentEditable = "false";
+ image.draggable = false;
+ if (block.props.previewWidth) {
+ image.width = block.props.previewWidth;
+ }
+ imageWrapper.appendChild(image);
+
+ return createResizableFileBlockWrapper(
+ block,
+ editor,
+ { dom: imageWrapper },
+ imageWrapper,
+ icon.firstElementChild as HTMLElement,
+ );
+ };
+
+export const imageToExternalHTML =
+ (_config: ImageOptions = {}) =>
+ (
+ block: BlockFromConfig, any, any>,
+ _editor: BlockNoteEditor<
+ Record<"image", ReturnType>,
+ any,
+ any
+ >,
+ ) => {
+ if (!block.props.url) {
+ return {
+ dom: document.createElement("img"),
+ };
+ }
+
+ let image;
+ if (block.props.showPreview) {
+ image = document.createElement("img");
+ image.src = block.props.url;
+ image.alt = block.props.name || "";
+ if (block.props.previewWidth) {
+ image.width = block.props.previewWidth;
+ }
+ } else {
+ image = document.createElement("a");
+ image.href = block.props.url;
+ image.textContent = block.props.name || block.props.url;
+ }
+
+ if (block.props.caption) {
+ if (block.props.showPreview) {
+ return createFigureWithCaption(image, block.props.caption);
+ } else {
+ return createLinkWithCaption(image, block.props.caption);
+ }
+ }
+
+ return {
+ dom: image,
+ };
+ };
+
+export const createImageBlockSpec = createBlockSpec(
+ createImageBlockConfig,
+ (config) => ({
+ meta: {
+ fileBlockAccept: ["image/*"],
+ },
+ parse: imageParse(config),
+ render: imageRender(config),
+ toExternalHTML: imageToExternalHTML(config),
+ runsBefore: ["file"],
+ }),
+);
diff --git a/packages/core/src/blocks/Image/parseImageElement.ts b/packages/core/src/blocks/Image/parseImageElement.ts
new file mode 100644
index 0000000000..21c0b31519
--- /dev/null
+++ b/packages/core/src/blocks/Image/parseImageElement.ts
@@ -0,0 +1,7 @@
+export const parseImageElement = (imageElement: HTMLImageElement) => {
+ const url = imageElement.src || undefined;
+ const previewWidth = imageElement.width || undefined;
+ const name = imageElement.alt || undefined;
+
+ return { url, previewWidth, name };
+};
diff --git a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
deleted file mode 100644
index 32b7338640..0000000000
--- a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import {
- BlockFromConfig,
- createBlockSpec,
- FileBlockConfig,
- Props,
- PropSchema,
-} from "../../schema/index.js";
-import { defaultProps } from "../defaultProps.js";
-import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js";
-import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
-import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
-import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js";
-import { parseImageElement } from "./parseImageElement.js";
-
-export const FILE_IMAGE_ICON_SVG =
- ' ';
-
-export const imagePropSchema = {
- textAlignment: defaultProps.textAlignment,
- backgroundColor: defaultProps.backgroundColor,
- // File name.
- name: {
- default: "" as const,
- },
- // File url.
- url: {
- default: "" as const,
- },
- // File caption.
- caption: {
- default: "" as const,
- },
-
- showPreview: {
- default: true,
- },
- // File preview width in px.
- previewWidth: {
- default: undefined,
- type: "number",
- },
-} satisfies PropSchema;
-
-export const imageBlockConfig = {
- type: "image" as const,
- propSchema: imagePropSchema,
- content: "none",
- isFileBlock: true,
- fileBlockAccept: ["image/*"],
-} satisfies FileBlockConfig;
-
-export const imageRender = (
- block: BlockFromConfig,
- editor: BlockNoteEditor,
-) => {
- const icon = document.createElement("div");
- icon.innerHTML = FILE_IMAGE_ICON_SVG;
-
- const imageWrapper = document.createElement("div");
- imageWrapper.className = "bn-visual-media-wrapper";
-
- const image = document.createElement("img");
- image.className = "bn-visual-media";
- if (editor.resolveFileUrl) {
- editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
- image.src = downloadUrl;
- });
- } else {
- image.src = block.props.url;
- }
-
- image.alt = block.props.name || block.props.caption || "BlockNote image";
- image.contentEditable = "false";
- image.draggable = false;
- imageWrapper.appendChild(image);
-
- return createResizableFileBlockWrapper(
- block,
- editor,
- { dom: imageWrapper },
- imageWrapper,
- editor.dictionary.file_blocks.image.add_button_text,
- icon.firstElementChild as HTMLElement,
- );
-};
-
-export const imageParse = (
- element: HTMLElement,
-): Partial> | undefined => {
- if (element.tagName === "IMG") {
- // Ignore if parent figure has already been parsed.
- if (element.closest("figure")) {
- return undefined;
- }
-
- return parseImageElement(element as HTMLImageElement);
- }
-
- if (element.tagName === "FIGURE") {
- const parsedFigure = parseFigureElement(element, "img");
- if (!parsedFigure) {
- return undefined;
- }
-
- const { targetElement, caption } = parsedFigure;
-
- return {
- ...parseImageElement(targetElement as HTMLImageElement),
- caption,
- };
- }
-
- return undefined;
-};
-
-export const imageToExternalHTML = (
- block: BlockFromConfig,
-) => {
- if (!block.props.url) {
- const div = document.createElement("p");
- div.textContent = "Add image";
-
- return {
- dom: div,
- };
- }
-
- let image;
- if (block.props.showPreview) {
- image = document.createElement("img");
- image.src = block.props.url;
- image.alt = block.props.name || block.props.caption || "BlockNote image";
- if (block.props.previewWidth) {
- image.width = block.props.previewWidth;
- }
- } else {
- image = document.createElement("a");
- image.href = block.props.url;
- image.textContent = block.props.name || block.props.url;
- }
-
- if (block.props.caption) {
- if (block.props.showPreview) {
- return createFigureWithCaption(image, block.props.caption);
- } else {
- return createLinkWithCaption(image, block.props.caption);
- }
- }
-
- return {
- dom: image,
- };
-};
-
-export const ImageBlock = createBlockSpec(imageBlockConfig, {
- render: imageRender,
- parse: imageParse,
- toExternalHTML: imageToExternalHTML,
-});
diff --git a/packages/core/src/blocks/ImageBlockContent/parseImageElement.ts b/packages/core/src/blocks/ImageBlockContent/parseImageElement.ts
deleted file mode 100644
index d225b9daa3..0000000000
--- a/packages/core/src/blocks/ImageBlockContent/parseImageElement.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export const parseImageElement = (imageElement: HTMLImageElement) => {
- const url = imageElement.src || undefined;
- const previewWidth = imageElement.width || undefined;
-
- return { url, previewWidth };
-};
diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts
new file mode 100644
index 0000000000..0a40bdc1ce
--- /dev/null
+++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts
@@ -0,0 +1,127 @@
+import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js";
+import { createExtension } from "../../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../../schema/index.js";
+import {
+ addDefaultPropsExternalHTML,
+ defaultProps,
+ parseDefaultProps,
+} from "../../defaultProps.js";
+import { handleEnter } from "../../utils/listItemEnterHandler.js";
+import { getListItemContent } from "../getListItemContent.js";
+
+export type BulletListItemBlockConfig = ReturnType<
+ typeof createBulletListItemBlockConfig
+>;
+
+export const createBulletListItemBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "bulletListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const createBulletListItemBlockSpec = createBlockSpec(
+ createBulletListItemBlockConfig,
+ {
+ meta: {
+ isolating: false,
+ },
+ parse(element) {
+ if (element.tagName !== "LI") {
+ return undefined;
+ }
+
+ const parent = element.parentElement;
+
+ if (
+ parent === null ||
+ parent.tagName === "UL" ||
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
+ ) {
+ return parseDefaultProps(element);
+ }
+
+ // Orphan `` (no / ancestor) — match as bulletListItem so
+ // pasting bare `` HTML doesn't fall back to a paragraph.
+ if (!element.closest("ul, ol")) {
+ return parseDefaultProps(element);
+ }
+
+ return undefined;
+ },
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ parseContent: ({ el, schema }) =>
+ getListItemContent(el, schema, "bulletListItem"),
+ render() {
+ // We use a tag, because for
tags we'd need a element to put
+ // them in to be semantically correct, which we can't have due to the
+ // schema.
+ const dom = document.createElement("p");
+
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ toExternalHTML(block) {
+ const li = document.createElement("li");
+ const p = document.createElement("p");
+ addDefaultPropsExternalHTML(block.props, li);
+ li.appendChild(p);
+
+ return {
+ dom: li,
+ contentDOM: p,
+ };
+ },
+ },
+ [
+ createExtension({
+ key: "bullet-list-item-shortcuts",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "bulletListItem");
+ },
+ "Mod-Shift-8": ({ editor }) => {
+ const cursorPosition = editor.getTextCursorPosition();
+
+ if (
+ editor.schema.blockSchema[cursorPosition.block.type].content !==
+ "inline"
+ ) {
+ return false;
+ }
+
+ editor.updateBlock(cursorPosition.block, {
+ type: "bulletListItem",
+ props: {},
+ });
+ return true;
+ },
+ },
+ inputRules: [
+ {
+ find: /^\s?[-+*]\s$/,
+ replace({ editor }) {
+ const blockInfo = getBlockInfoFromSelection(
+ editor.prosemirrorState,
+ );
+
+ if (blockInfo.blockNoteType === "heading") {
+ return;
+ }
+ return {
+ type: "bulletListItem",
+ props: {},
+ };
+ },
+ },
+ ],
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts
new file mode 100644
index 0000000000..c635aeda3a
--- /dev/null
+++ b/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts
@@ -0,0 +1,61 @@
+import { expect, it } from "vitest";
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import type { Block } from "../../defaultBlocks.js";
+
+/**
+ * Tests for CheckListItem block behaviour, especially that the checkbox
+ * respects the editor's editable state (disabled when not editable,
+ * and change handler no-ops when not editable).
+ *
+ * @vitest-environment jsdom
+ */
+function getCheckboxFromView(view: {
+ dom: HTMLElement | DocumentFragment;
+}): HTMLInputElement {
+ const el = view.dom.querySelector('input[type="checkbox"]');
+ if (!(el instanceof HTMLInputElement)) {
+ throw new Error("Checkbox input not found in rendered output");
+ }
+ return el;
+}
+
+it("renders checkbox as enabled when editor is editable", () => {
+ const editor = BlockNoteEditor.create();
+ const block: Block = {
+ id: "1",
+ type: "checkListItem",
+ props: {
+ backgroundColor: "default",
+ textAlignment: "left",
+ textColor: "default",
+ checked: false,
+ },
+ content: [],
+ children: [],
+ };
+ const spec = editor.schema.blockSpecs.checkListItem;
+ const view = spec.implementation.render(block, editor);
+ const checkbox = getCheckboxFromView(view);
+ expect(checkbox.disabled).toBe(false);
+});
+
+it("renders checkbox as disabled when editor is not editable", () => {
+ const editor = BlockNoteEditor.create();
+ editor.isEditable = false;
+ const block: Block = {
+ id: "1",
+ type: "checkListItem",
+ props: {
+ backgroundColor: "default",
+ textAlignment: "left",
+ textColor: "default",
+ checked: false,
+ },
+ content: [],
+ children: [],
+ };
+ const spec = editor.schema.blockSpecs.checkListItem;
+ const view = spec.implementation.render(block, editor);
+ const checkbox = getCheckboxFromView(view);
+ expect(checkbox.disabled).toBe(true);
+});
diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts
new file mode 100644
index 0000000000..6d514270bf
--- /dev/null
+++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts
@@ -0,0 +1,182 @@
+import { createExtension } from "../../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../../schema/index.js";
+import {
+ addDefaultPropsExternalHTML,
+ defaultProps,
+ parseDefaultProps,
+} from "../../defaultProps.js";
+import { handleEnter } from "../../utils/listItemEnterHandler.js";
+import { getListItemContent } from "../getListItemContent.js";
+
+export type CheckListItemBlockConfig = ReturnType<
+ typeof createCheckListItemConfig
+>;
+
+export const createCheckListItemConfig = createBlockConfig(
+ () =>
+ ({
+ type: "checkListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ checked: { default: false, type: "boolean" },
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const createCheckListItemBlockSpec = createBlockSpec(
+ createCheckListItemConfig,
+ {
+ meta: {
+ isolating: false,
+ },
+ parse(element) {
+ if (element.tagName === "input") {
+ // Ignore if we already parsed an ancestor list item to avoid double-parsing.
+ if (element.closest("[data-content-type]") || element.closest("li")) {
+ return undefined;
+ }
+
+ if ((element as HTMLInputElement).type === "checkbox") {
+ return { checked: (element as HTMLInputElement).checked };
+ }
+ return undefined;
+ }
+ if (element.tagName !== "LI") {
+ return undefined;
+ }
+
+ const parent = element.parentElement;
+
+ if (parent === null) {
+ return undefined;
+ }
+
+ if (
+ parent.tagName === "UL" ||
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
+ ) {
+ const checkbox =
+ (element.querySelector("input[type=checkbox]") as HTMLInputElement) ||
+ null;
+
+ if (checkbox === null) {
+ return undefined;
+ }
+
+ return { ...parseDefaultProps(element), checked: checkbox.checked };
+ }
+
+ return;
+ },
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ parseContent: ({ el, schema }) =>
+ getListItemContent(el, schema, "checkListItem"),
+ render(block, editor) {
+ const dom = document.createDocumentFragment();
+
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.checked = block.props.checked;
+ if (block.props.checked) {
+ checkbox.setAttribute("checked", "");
+ }
+ checkbox.disabled = !editor.isEditable;
+ checkbox.addEventListener("change", () => {
+ if (!editor.isEditable) {
+ return;
+ }
+ editor.updateBlock(block, { props: { checked: !block.props.checked } });
+ });
+ // We use a tag, because for
tags we'd need a element to put
+ // them in to be semantically correct, which we can't have due to the
+ // schema.
+ const paragraph = document.createElement("p");
+
+ const div = document.createElement("div");
+ div.contentEditable = "false";
+ div.appendChild(checkbox);
+ dom.appendChild(div);
+ dom.appendChild(paragraph);
+
+ return {
+ dom,
+ contentDOM: paragraph,
+ };
+ },
+ toExternalHTML(block) {
+ const dom = document.createElement("li");
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.checked = block.props.checked;
+ if (block.props.checked) {
+ checkbox.setAttribute("checked", "");
+ }
+ // We use a tag, because for
tags we'd need a element to put
+ // them in to be semantically correct, which we can't have due to the
+ // schema.
+ const paragraph = document.createElement("p");
+ addDefaultPropsExternalHTML(block.props, dom);
+
+ dom.appendChild(checkbox);
+ dom.appendChild(paragraph);
+
+ return {
+ dom,
+ contentDOM: paragraph,
+ };
+ },
+ runsBefore: ["bulletListItem"],
+ },
+ [
+ createExtension({
+ key: "check-list-item-shortcuts",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "checkListItem");
+ },
+ "Mod-Shift-9": ({ editor }) => {
+ const cursorPosition = editor.getTextCursorPosition();
+
+ if (
+ editor.schema.blockSchema[cursorPosition.block.type].content !==
+ "inline"
+ ) {
+ return false;
+ }
+
+ editor.updateBlock(cursorPosition.block, {
+ type: "checkListItem",
+ props: {},
+ });
+ return true;
+ },
+ },
+ inputRules: [
+ {
+ find: /^\s?\[\s*\]\s$/,
+ replace() {
+ return {
+ type: "checkListItem",
+ props: {
+ checked: false,
+ },
+ };
+ },
+ },
+ {
+ find: /^\s?\[[Xx]\]\s$/,
+ replace() {
+ return {
+ type: "checkListItem",
+ props: {
+ checked: true,
+ },
+ };
+ },
+ },
+ ],
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
similarity index 97%
rename from packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
rename to packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
index f901206c10..eb71c2f7ab 100644
--- a/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
+++ b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
@@ -18,6 +18,7 @@ export const handleEnter = (editor: BlockNoteEditor) => {
if (
!(
+ blockContent.node.type.name === "toggleListItem" ||
blockContent.node.type.name === "bulletListItem" ||
blockContent.node.type.name === "numberedListItem" ||
blockContent.node.type.name === "checkListItem"
diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts
new file mode 100644
index 0000000000..433fa47806
--- /dev/null
+++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.test.ts
@@ -0,0 +1,359 @@
+import { Selection } from "prosemirror-state";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+
+/**
+ * @vitest-environment jsdom
+ */
+
+const PLUGIN_KEY = "numbered-list-indexing-decorations$";
+
+// Track editors created in each test so we can unmount them in afterEach —
+// otherwise prosemirror-view's DOMObserver leaves a setTimeout alive that
+// fires after vitest tears down jsdom, throwing
+// `ReferenceError: document is not defined` and failing the run.
+const activeEditors: BlockNoteEditor[] = [];
+
+afterEach(() => {
+ while (activeEditors.length) {
+ activeEditors.pop()!.unmount();
+ }
+});
+
+function createEditor() {
+ const editor = BlockNoteEditor.create();
+ editor.mount(document.createElement("div"));
+ activeEditors.push(editor);
+ return editor;
+}
+
+function getDecorationSet(editor: BlockNoteEditor) {
+ const view = editor._tiptapEditor.view;
+ const plugin = view.state.plugins.find(
+ (p) => (p as any).key === PLUGIN_KEY,
+ );
+ if (!plugin) {
+ throw new Error("IndexingPlugin not found");
+ }
+ return plugin.getState(view.state)!.decorations;
+}
+
+/** Returns all decoration specs in document order. */
+function getDecoSpecs(editor: BlockNoteEditor) {
+ const decoSet = getDecorationSet(editor);
+ const doc = editor._tiptapEditor.view.state.doc;
+ const decos = decoSet.find(0, doc.nodeSize - 2);
+ return decos.map((d: any) => d.spec);
+}
+
+/** Returns the data-index values from decoration attrs in document order. */
+function getDataIndices(editor: BlockNoteEditor) {
+ const decoSet = getDecorationSet(editor);
+ const doc = editor._tiptapEditor.view.state.doc;
+ const decos = decoSet.find(0, doc.nodeSize - 2);
+ return decos.map((d: any) => {
+ // Decoration attrs are stored on the decoration object
+ const attrs =
+ (d as any).type?.attrs ?? (d as any).attrs ?? (d as any).type;
+ return parseInt(attrs["data-index"], 10);
+ });
+}
+
+function setBlocks(
+ editor: BlockNoteEditor,
+ blocks: Array<{ type: string; content?: string; props?: any }>,
+) {
+ editor.replaceBlocks(
+ editor.document,
+ blocks.map((b) => ({
+ type: b.type as any,
+ content: b.content ?? "text",
+ ...(b.props ? { props: b.props } : {}),
+ })) as any,
+ );
+}
+
+describe("IndexingPlugin: basic numbering", () => {
+ it("assigns sequential indices to a contiguous numbered list", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([1, 2, 3]);
+ });
+
+ it("resets index after a non-list block", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "paragraph", content: "break" },
+ { type: "numberedListItem", content: "c" },
+ { type: "numberedListItem", content: "d" },
+ ]);
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([1, 2, 1, 2]);
+ });
+
+ it("single numbered list item gets index 1", () => {
+ const editor = createEditor();
+ setBlocks(editor, [{ type: "numberedListItem", content: "only" }]);
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([1]);
+ });
+
+ it("no decorations for non-list blocks", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "paragraph", content: "a" },
+ { type: "heading", content: "b", props: { level: 1 } },
+ ]);
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([]);
+ });
+});
+
+describe("IndexingPlugin: updates on structural changes", () => {
+ it("updates indices when a block is deleted from the middle", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ // Delete the second block
+ const secondBlock = editor.document[1];
+ editor.removeBlocks([secondBlock]);
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([1, 2]);
+ });
+
+ it("updates indices when a block is inserted in the middle", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ // Insert a block after the first
+ const firstBlock = editor.document[0];
+ editor.insertBlocks(
+ [{ type: "numberedListItem" as any, content: "b" } as any],
+ firstBlock,
+ "after",
+ );
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([1, 2, 3]);
+ });
+
+ it("updates indices when first block is deleted", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ editor.removeBlocks([editor.document[0]]);
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([1, 2]);
+ });
+
+ it("updates indices with nested list when first block is deleted", () => {
+ const editor = createEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "numberedListItem" as any,
+ content: "first item",
+ },
+ {
+ type: "numberedListItem" as any,
+ content: "second item",
+ children: [
+ { type: "numberedListItem" as any, content: "nested item" },
+ { type: "numberedListItem" as any, content: "second nested item" },
+ ],
+ },
+ {
+ type: "numberedListItem" as any,
+ content: "third item",
+ },
+ ] as any);
+
+ // Before deletion: top-level [1, 2, 3], nested [1, 2]
+ const indicesBefore = getDataIndices(editor);
+ expect(indicesBefore).toEqual([1, 2, 1, 2, 3]);
+
+ // Delete first item
+ editor.removeBlocks([editor.document[0]]);
+
+ // After deletion: top-level [1, 2], nested [1, 2]
+ const indicesAfter = getDataIndices(editor);
+ expect(indicesAfter).toEqual([1, 1, 2, 2]);
+ });
+
+ it("updates indices when block type changes from numbered list to paragraph", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ // Change second block to paragraph — splits the list
+ editor.updateBlock(editor.document[1], { type: "paragraph" });
+
+ const indices = getDataIndices(editor);
+ // First list: [1], then paragraph (no decoration), then new list: [1]
+ expect(indices).toEqual([1, 1]);
+ });
+
+ it("updates indices when block type changes from paragraph to numbered list", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "paragraph", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ // Change paragraph to numbered list — merges the lists
+ editor.updateBlock(editor.document[1], { type: "numberedListItem" });
+
+ const indices = getDataIndices(editor);
+ expect(indices).toEqual([1, 2, 3]);
+ });
+});
+
+describe("IndexingPlugin: typing preserves indices (early exit)", () => {
+ it("indices unchanged after typing in the first block", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ const indicesBefore = getDataIndices(editor);
+
+ // Type a character in the first block
+ const view = editor._tiptapEditor.view;
+ view.dispatch(view.state.tr.insertText("x", 4));
+
+ const indicesAfter = getDataIndices(editor);
+ expect(indicesAfter).toEqual(indicesBefore);
+ });
+
+ it("indices unchanged after typing in the last block", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ const indicesBefore = getDataIndices(editor);
+
+ const view = editor._tiptapEditor.view;
+ const pos = view.state.doc.content.size - 4;
+ view.dispatch(view.state.tr.insertText("x", pos));
+
+ const indicesAfter = getDataIndices(editor);
+ expect(indicesAfter).toEqual(indicesBefore);
+ });
+
+ it("indices unchanged after typing in a middle block", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ const indicesBefore = getDataIndices(editor);
+
+ // Find position inside second block's content
+ const view = editor._tiptapEditor.view;
+ let targetPos = 0;
+ view.state.doc.descendants((node, pos) => {
+ if (
+ node.type.name === "numberedListItem" &&
+ targetPos === 0 &&
+ pos > 4
+ ) {
+ targetPos = pos + 1; // inside the inline content
+ }
+ });
+ view.dispatch(view.state.tr.insertText("x", targetPos));
+
+ const indicesAfter = getDataIndices(editor);
+ expect(indicesAfter).toEqual(indicesBefore);
+ });
+});
+
+describe("IndexingPlugin: decoration specs", () => {
+ it("decorations have correct spec with index, isFirst, hasStart", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ ]);
+
+ const specs = getDecoSpecs(editor);
+ expect(specs).toEqual([
+ { index: 1, isFirst: true, hasStart: false },
+ { index: 2, isFirst: false, hasStart: false },
+ ]);
+ });
+
+ it("first item after a paragraph is marked as isFirst", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "paragraph", content: "break" },
+ { type: "numberedListItem", content: "b" },
+ { type: "numberedListItem", content: "c" },
+ ]);
+
+ const specs = getDecoSpecs(editor);
+ expect(specs).toEqual([
+ { index: 1, isFirst: true, hasStart: false },
+ { index: 1, isFirst: true, hasStart: false },
+ { index: 2, isFirst: false, hasStart: false },
+ ]);
+ });
+});
+
+describe("IndexingPlugin: selection-only transactions", () => {
+ it("does not recompute decorations on selection change", () => {
+ const editor = createEditor();
+ setBlocks(editor, [
+ { type: "numberedListItem", content: "a" },
+ { type: "numberedListItem", content: "b" },
+ ]);
+
+ const decosBefore = getDecorationSet(editor);
+
+ // Move selection without changing content
+ const view = editor._tiptapEditor.view;
+ const tr = view.state.tr.setSelection(
+ Selection.near(view.state.doc.resolve(4)),
+ );
+ view.dispatch(tr);
+
+ const decosAfter = getDecorationSet(editor);
+ // Same DecorationSet reference — not recomputed
+ expect(decosAfter).toBe(decosBefore);
+ });
+});
diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts
new file mode 100644
index 0000000000..eb6b06dc7d
--- /dev/null
+++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts
@@ -0,0 +1,245 @@
+import type { Node } from "@tiptap/pm/model";
+import type { Transaction } from "@tiptap/pm/state";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+
+import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js";
+
+// Loosely based on https://github.com/ueberdosis/tiptap/blob/7ac01ef0b816a535e903b5ca92492bff110a71ae/packages/extension-mathematics/src/MathematicsPlugin.ts (MIT)
+
+type DecoSpec = {
+ index: number;
+ isFirst: boolean;
+ hasStart: boolean;
+ side: number;
+};
+
+type Deco = Omit & { spec: DecoSpec };
+
+/**
+ * Calculate the index for a numbered list item based on its position and previous siblings.
+ * Iteratively walks backwards to find the start of the contiguous list (or a cached entry),
+ * then walks forward to populate the cache. This avoids deep recursion that would overflow
+ * the stack on large documents.
+ */
+function calculateListItemIndex(
+ node: Node,
+ pos: number,
+ tr: Transaction,
+ map: Map,
+): { index: number; isFirst: boolean; hasStart: boolean } {
+ const hasStart = !!node.firstChild!.attrs["start"];
+
+ // Fast path: previous sibling already in cache
+ const blockInfo = getBlockInfo({ posBeforeNode: pos, node });
+ if (!blockInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
+ const prevBlock = tr.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore;
+ const prevBlockIndex = prevBlock ? map.get(prevBlock) : undefined;
+ if (prevBlockIndex !== undefined) {
+ const index = prevBlockIndex + 1;
+ map.set(node, index);
+ return { index, isFirst: false, hasStart };
+ }
+
+ // Walk backwards iteratively to collect the chain of consecutive
+ // numbered list items until we hit a cached entry, a non-list block,
+ // or the start of the parent.
+ const chain: { node: Node; pos: number }[] = [{ node, pos }];
+ let curNode = prevBlock;
+ let curBeforePos = blockInfo.bnBlock.beforePos;
+
+ while (curNode) {
+ const cachedIndex = map.get(curNode);
+ if (cachedIndex !== undefined) {
+ // Found a cached predecessor — start counting from here
+ break;
+ }
+ const curInfo = getBlockInfo({
+ posBeforeNode: curBeforePos - curNode.nodeSize,
+ node: curNode,
+ });
+ if (curInfo.blockNoteType !== "numberedListItem") {
+ break;
+ }
+ chain.push({ node: curNode, pos: curBeforePos - curNode.nodeSize });
+ const nextPrev = tr.doc.resolve(curInfo.bnBlock.beforePos).nodeBefore;
+ curBeforePos = curInfo.bnBlock.beforePos;
+ curNode = nextPrev;
+ }
+
+ // Walk forward (reverse of the collected chain) to assign indices
+ // The last element in chain is the furthest predecessor
+ let index: number;
+ let isFirst: boolean;
+
+ // Determine starting index from the block just before the chain
+ const lastInChain = chain[chain.length - 1];
+ const lastInfo = getBlockInfo({
+ posBeforeNode: lastInChain.pos,
+ node: lastInChain.node,
+ });
+ if (!lastInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
+ const predecessorNode = tr.doc.resolve(lastInfo.bnBlock.beforePos).nodeBefore;
+ const predecessorIndex = predecessorNode
+ ? map.get(predecessorNode)
+ : undefined;
+
+ if (predecessorIndex !== undefined) {
+ index = predecessorIndex;
+ isFirst = false;
+ } else {
+ // Start of a new list
+ index = (lastInChain.node.firstChild!.attrs["start"] || 1) - 1;
+ isFirst = true;
+ }
+
+ // Assign indices from the end of the chain (furthest back) to the front (original node)
+ for (let i = chain.length - 1; i >= 0; i--) {
+ const entry = chain[i];
+ if (isFirst && i < chain.length - 1) {
+ // Only the very first item in the list gets isFirst
+ isFirst = false;
+ }
+ index++;
+ map.set(entry.node, index);
+ }
+
+ // isFirst is true only for the very first item in a new list:
+ // chain.length > 1 means we found predecessor list items, so not first.
+ return {
+ index,
+ isFirst: chain.length === 1 ? isFirst || predecessorIndex === undefined : false,
+ hasStart,
+ };
+}
+
+/**
+ * Get the decorations for the current state based on the previous state,
+ * and the transaction that was applied to get to the current state
+ */
+function getDecorations(
+ tr: Transaction,
+ previousPluginState: { decorations: DecorationSet },
+) {
+ const map = new Map();
+
+ const nextDecorationSet = previousPluginState.decorations.map(
+ tr.mapping,
+ tr.doc,
+ );
+
+ // Find the start of the first change to limit traversal scope.
+ // We only need to check from the change point forward, since earlier
+ // blocks are unaffected and their mapped decorations remain correct.
+ // On init (no steps), changedRange() returns null — fall back to a
+ // full scan so initial content gets indexed.
+ const range = tr.changedRange() ?? { from: 0, to: tr.doc.nodeSize - 2 };
+ const decorationsToAdd = [] as Deco[];
+
+ // Track blockGroups where we've verified a decoration match past the
+ // changed range. Within a single blockGroup, indices are sequential —
+ // if one matches, all subsequent siblings must too. But sibling items
+ // in *other* blockGroups (e.g. nested lists) are independent.
+ const completedGroups = new Set();
+
+ tr.doc.nodesBetween(
+ range.from,
+ tr.doc.nodeSize - 2,
+ (node, pos, parent) => {
+ if (parent && completedGroups.has(parent)) {
+ return false;
+ }
+
+ if (
+ node.type.name === "blockContainer" &&
+ node.firstChild!.type.name === "numberedListItem"
+ ) {
+ const { index, isFirst, hasStart } = calculateListItemIndex(
+ node,
+ pos,
+ tr,
+ map,
+ );
+
+ // Search only the numberedListItem node range, not the full
+ // blockContainer (which includes nested blockGroups whose
+ // decorations could falsely match).
+ const blockNode = tr.doc.nodeAt(pos + 1)!;
+ const existingDecorations = nextDecorationSet.find(
+ pos + 1,
+ pos + 1 + blockNode.nodeSize,
+ (deco: DecoSpec) =>
+ deco.index === index &&
+ deco.isFirst === isFirst &&
+ deco.hasStart === hasStart,
+ );
+
+ if (existingDecorations.length === 0) {
+ decorationsToAdd.push(
+ Decoration.node(
+ pos + 1,
+ pos + 1 + blockNode.nodeSize,
+ { "data-index": index.toString() },
+ { index, isFirst, hasStart },
+ ) as Deco,
+ );
+ } else if (pos >= range.to && parent) {
+ // Past the changed range and decoration matches in this blockGroup:
+ // all subsequent siblings must also match. Mark group as done and
+ // skip this node's children (nested lists are unaffected too).
+ completedGroups.add(parent);
+ return false;
+ }
+ }
+ return undefined;
+ },
+ );
+
+ // Remove any decorations that exist at the same position, they will be replaced by the new decorations
+ const decorationsToRemove = decorationsToAdd.flatMap((deco) =>
+ nextDecorationSet.find(deco.from, deco.to),
+ );
+
+ return {
+ decorations: nextDecorationSet
+ // Remove existing decorations that are going to be replaced
+ .remove(decorationsToRemove)
+ // Add any new decorations
+ .add(tr.doc, decorationsToAdd),
+ };
+}
+
+/**
+ * This plugin adds decorations to numbered list items to display their index.
+ */
+export const NumberedListIndexingDecorationPlugin = () => {
+ return new Plugin<{ decorations: DecorationSet }>({
+ key: new PluginKey("numbered-list-indexing-decorations"),
+
+ state: {
+ init(_config, state) {
+ // We create an empty transaction to get the decorations for the initial state based on the initial content
+ return getDecorations(state.tr, {
+ decorations: DecorationSet.empty,
+ });
+ },
+ apply(tr, previousPluginState) {
+ if (!tr.docChanged && previousPluginState.decorations) {
+ // Selection-only changes don't affect list indices, just reuse existing decorations
+ return previousPluginState;
+ }
+ return getDecorations(tr, previousPluginState);
+ },
+ },
+
+ props: {
+ decorations(state) {
+ return this.getState(state)?.decorations ?? DecorationSet.empty;
+ },
+ },
+ });
+};
diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts
new file mode 100644
index 0000000000..fc2537829d
--- /dev/null
+++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts
@@ -0,0 +1,141 @@
+import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js";
+import { createExtension } from "../../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../../schema/index.js";
+import {
+ addDefaultPropsExternalHTML,
+ defaultProps,
+ parseDefaultProps,
+} from "../../defaultProps.js";
+import { handleEnter } from "../../utils/listItemEnterHandler.js";
+import { getListItemContent } from "../getListItemContent.js";
+import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js";
+
+export type NumberedListItemBlockConfig = ReturnType<
+ typeof createNumberedListItemBlockConfig
+>;
+
+export const createNumberedListItemBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "numberedListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ start: { default: undefined, type: "number" } as const,
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const createNumberedListItemBlockSpec = createBlockSpec(
+ createNumberedListItemBlockConfig,
+ {
+ meta: {
+ isolating: false,
+ },
+ parse(element) {
+ if (element.tagName !== "LI") {
+ return undefined;
+ }
+
+ const parent = element.parentElement;
+
+ if (parent === null) {
+ return undefined;
+ }
+
+ if (
+ parent.tagName === "OL" ||
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "OL")
+ ) {
+ const startIndex = parseInt(parent.getAttribute("start") || "1");
+
+ const defaultProps = parseDefaultProps(element);
+
+ if (element.previousElementSibling || startIndex === 1) {
+ return defaultProps;
+ }
+
+ return {
+ ...defaultProps,
+ start: startIndex,
+ };
+ }
+
+ return undefined;
+ },
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ parseContent: ({ el, schema }) =>
+ getListItemContent(el, schema, "numberedListItem"),
+ render() {
+ // We use a tag, because for
tags we'd need a element to put
+ // them in to be semantically correct, which we can't have due to the
+ // schema.
+ const dom = document.createElement("p");
+
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ toExternalHTML(block) {
+ const li = document.createElement("li");
+ const p = document.createElement("p");
+ addDefaultPropsExternalHTML(block.props, li);
+ li.appendChild(p);
+
+ return {
+ dom: li,
+ contentDOM: p,
+ };
+ },
+ },
+ [
+ createExtension({
+ key: "numbered-list-item-shortcuts",
+ inputRules: [
+ {
+ find: /^\s?(\d+)\.\s$/,
+ replace({ match, editor }) {
+ const blockInfo = getBlockInfoFromSelection(
+ editor.prosemirrorState,
+ );
+
+ if (blockInfo.blockNoteType === "heading") {
+ return;
+ }
+ const start = parseInt(match[1]);
+ return {
+ type: "numberedListItem",
+ props: {
+ start: start !== 1 ? start : undefined,
+ },
+ };
+ },
+ },
+ ],
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "numberedListItem");
+ },
+ "Mod-Shift-7": ({ editor }) => {
+ const cursorPosition = editor.getTextCursorPosition();
+
+ if (
+ editor.schema.blockSchema[cursorPosition.block.type].content !==
+ "inline"
+ ) {
+ return false;
+ }
+
+ editor.updateBlock(cursorPosition.block, {
+ type: "numberedListItem",
+ props: {},
+ });
+ return true;
+ },
+ },
+ prosemirrorPlugins: [NumberedListIndexingDecorationPlugin()],
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
new file mode 100644
index 0000000000..54a7a39dfe
--- /dev/null
+++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts
@@ -0,0 +1,128 @@
+import { createExtension } from "../../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../../schema/index.js";
+import {
+ addDefaultPropsExternalHTML,
+ defaultProps,
+ parseDefaultProps,
+} from "../../defaultProps.js";
+import { getDetailsContent } from "../../getDetailsContent.js";
+import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js";
+import { handleEnter } from "../../utils/listItemEnterHandler.js";
+
+export type ToggleListItemBlockConfig = ReturnType<
+ typeof createToggleListItemBlockConfig
+>;
+
+export const createToggleListItemBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "toggleListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ },
+ content: "inline" as const,
+ }) as const,
+);
+
+export const createToggleListItemBlockSpec = createBlockSpec(
+ createToggleListItemBlockConfig,
+ {
+ meta: {
+ isolating: false,
+ },
+ parse(element) {
+ if (element.tagName === "DETAILS") {
+ // Skip that contain a heading in — those are
+ // toggle headings, handled by the heading block's parse rule.
+
+ return parseDefaultProps(element);
+ }
+
+ if (element.tagName === "LI") {
+ const parent = element.parentElement;
+
+ if (
+ parent &&
+ (parent.tagName === "UL" ||
+ (parent.tagName === "DIV" &&
+ parent.parentElement?.tagName === "UL"))
+ ) {
+ const details = element.querySelector(":scope > details");
+ if (details) {
+ return parseDefaultProps(element);
+ }
+ }
+ }
+
+ return undefined;
+ },
+ parseContent: ({ el, schema }) => {
+ const details =
+ el.tagName === "DETAILS" ? el : el.querySelector(":scope > details");
+
+ if (!details) {
+ throw new Error("No details found in toggleListItem parseContent");
+ }
+
+ return getDetailsContent(
+ details as HTMLElement,
+ schema,
+ "toggleListItem",
+ );
+ },
+ runsBefore: ["bulletListItem"],
+ render(block, editor) {
+ const paragraphEl = document.createElement("p");
+ const toggleWrapper = createToggleWrapper(
+ block as any,
+ editor,
+ paragraphEl,
+ );
+ return { ...toggleWrapper, contentDOM: paragraphEl };
+ },
+ toExternalHTML(block) {
+ const li = document.createElement("li");
+ const details = document.createElement("details");
+ details.setAttribute("open", "");
+ const summary = document.createElement("summary");
+ const p = document.createElement("p");
+ summary.appendChild(p);
+ details.appendChild(summary);
+
+ addDefaultPropsExternalHTML(block.props, li);
+ li.appendChild(details);
+
+ return {
+ dom: li,
+ contentDOM: p,
+ childrenDOM: details,
+ };
+ },
+ },
+ [
+ createExtension({
+ key: "toggle-list-item-shortcuts",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "toggleListItem");
+ },
+ "Mod-Shift-6": ({ editor }) => {
+ const cursorPosition = editor.getTextCursorPosition();
+
+ if (
+ editor.schema.blockSchema[cursorPosition.block.type].content !==
+ "inline"
+ ) {
+ return false;
+ }
+
+ editor.updateBlock(cursorPosition.block, {
+ type: "toggleListItem",
+ props: {},
+ });
+ return true;
+ },
+ },
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts b/packages/core/src/blocks/ListItem/getListItemContent.ts
similarity index 100%
rename from packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts
rename to packages/core/src/blocks/ListItem/getListItemContent.ts
diff --git a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
deleted file mode 100644
index e6412633c4..0000000000
--- a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { InputRule } from "@tiptap/core";
-import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js";
-import {
- PropSchema,
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
-} from "../../../schema/index.js";
-import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
-import { defaultProps } from "../../defaultProps.js";
-import { getListItemContent } from "../getListItemContent.js";
-import { handleEnter } from "../ListItemKeyboardShortcuts.js";
-
-export const bulletListItemPropSchema = {
- ...defaultProps,
-} satisfies PropSchema;
-
-const BulletListItemBlockContent = createStronglyTypedTiptapNode({
- name: "bulletListItem",
- content: "inline*",
- group: "blockContent",
- // This is to make sure that check list parse rules run before, since they
- // both parse `li` elements but check lists are more specific.
- priority: 90,
- addInputRules() {
- return [
- // Creates an unordered list when starting with "-", "+", or "*".
- new InputRule({
- find: new RegExp(`^[-+*]\\s$`),
- handler: ({ state, chain, range }) => {
- const blockInfo = getBlockInfoFromSelection(state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return;
- }
-
- chain()
- .command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "bulletListItem",
- props: {},
- }),
- )
- // Removes the "-", "+", or "*" character used to set the list.
- .deleteRange({ from: range.from, to: range.to });
- },
- }),
- ];
- },
-
- addKeyboardShortcuts() {
- return {
- Enter: () => handleEnter(this.options.editor),
- "Mod-Shift-8": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "bulletListItem",
- props: {},
- }),
- );
- },
- };
- },
-
- parseHTML() {
- return [
- // Parse from internal HTML.
- {
- tag: "div[data-content-type=" + this.name + "]",
- contentElement: ".bn-inline-content",
- },
- // Parse from external HTML.
- {
- tag: "li",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- const parent = element.parentElement;
-
- if (parent === null) {
- return false;
- }
-
- if (
- parent.tagName === "UL" ||
- (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
- ) {
- return {};
- }
-
- return false;
- },
- // As `li` elements can contain multiple paragraphs, we need to merge their contents
- // into a single one so that ProseMirror can parse everything correctly.
- getContent: (node, schema) =>
- getListItemContent(node, schema, this.name),
- node: "bulletListItem",
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- return createDefaultBlockDOMOutputSpec(
- this.name,
- // We use a tag, because for
tags we'd need a element to put
- // them in to be semantically correct, which we can't have due to the
- // schema.
- "p",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
- },
-});
-
-export const BulletListItem = createBlockSpecFromStronglyTypedTiptapNode(
- BulletListItemBlockContent,
- bulletListItemPropSchema,
-);
diff --git a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts
deleted file mode 100644
index 8ebf62aa63..0000000000
--- a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts
+++ /dev/null
@@ -1,299 +0,0 @@
-import { InputRule } from "@tiptap/core";
-import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import {
- getBlockInfoFromSelection,
- getNearestBlockPos,
-} from "../../../api/getBlockInfoFromPos.js";
-import {
- PropSchema,
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
- propsToAttributes,
-} from "../../../schema/index.js";
-import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
-import { defaultProps } from "../../defaultProps.js";
-import { getListItemContent } from "../getListItemContent.js";
-import { handleEnter } from "../ListItemKeyboardShortcuts.js";
-
-export const checkListItemPropSchema = {
- ...defaultProps,
- checked: {
- default: false,
- },
-} satisfies PropSchema;
-
-const checkListItemBlockContent = createStronglyTypedTiptapNode({
- name: "checkListItem",
- content: "inline*",
- group: "blockContent",
-
- addAttributes() {
- return propsToAttributes(checkListItemPropSchema);
- },
-
- addInputRules() {
- return [
- // Creates a checklist when starting with "[]" or "[X]".
- new InputRule({
- find: new RegExp(`\\[\\s*\\]\\s$`),
- handler: ({ state, chain, range }) => {
- const blockInfo = getBlockInfoFromSelection(state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return;
- }
-
- chain()
- .command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "checkListItem",
- props: {
- checked: false as any,
- },
- }),
- )
- // Removes the characters used to set the list.
- .deleteRange({ from: range.from, to: range.to });
- },
- }),
- new InputRule({
- find: new RegExp(`\\[[Xx]\\]\\s$`),
- handler: ({ state, chain, range }) => {
- const blockInfo = getBlockInfoFromSelection(state);
-
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return;
- }
-
- chain()
- .command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "checkListItem",
- props: {
- checked: true as any,
- },
- }),
- )
- // Removes the characters used to set the list.
- .deleteRange({ from: range.from, to: range.to });
- },
- }),
- ];
- },
-
- addKeyboardShortcuts() {
- return {
- Enter: () => handleEnter(this.options.editor),
- "Mod-Shift-9": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "checkListItem",
- props: {},
- }),
- );
- },
- };
- },
-
- parseHTML() {
- return [
- // Parse from internal HTML.
- {
- tag: "div[data-content-type=" + this.name + "]",
- contentElement: ".bn-inline-content",
- },
- // Parse from external HTML.
- {
- tag: "input",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- // Ignore if we already parsed an ancestor list item to avoid double-parsing.
- if (element.closest("[data-content-type]") || element.closest("li")) {
- return false;
- }
-
- if ((element as HTMLInputElement).type === "checkbox") {
- return { checked: (element as HTMLInputElement).checked };
- }
-
- return false;
- },
- node: "checkListItem",
- },
- {
- tag: "li",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- const parent = element.parentElement;
-
- if (parent === null) {
- return false;
- }
-
- if (
- parent.tagName === "UL" ||
- (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
- ) {
- const checkbox =
- (element.querySelector(
- "input[type=checkbox]",
- ) as HTMLInputElement) || null;
-
- if (checkbox === null) {
- return false;
- }
-
- return { checked: checkbox.checked };
- }
-
- return false;
- },
- // As `li` elements can contain multiple paragraphs, we need to merge their contents
- // into a single one so that ProseMirror can parse everything correctly.
- getContent: (node, schema) =>
- getListItemContent(node, schema, this.name),
- node: "checkListItem",
- },
- ];
- },
-
- // Since there is no HTML checklist element, there isn't really any
- // standardization for what checklists should look like in the DOM. GDocs'
- // and Notion's aren't cross compatible, for example. This implementation
- // has a semantically correct DOM structure (though missing a label for the
- // checkbox) which is also converted correctly to Markdown by remark.
- renderHTML({ node, HTMLAttributes }) {
- const checkbox = document.createElement("input");
- checkbox.type = "checkbox";
- checkbox.checked = node.attrs.checked;
- if (node.attrs.checked) {
- checkbox.setAttribute("checked", "");
- }
-
- const { dom, contentDOM } = createDefaultBlockDOMOutputSpec(
- this.name,
- "p",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
-
- dom.insertBefore(checkbox, contentDOM);
-
- return { dom, contentDOM };
- },
-
- // Need to render node view since the checkbox needs to be able to update the
- // node. This is only possible with a node view as it exposes `getPos`.
- addNodeView() {
- return ({ node, getPos, editor, HTMLAttributes }) => {
- // Need to wrap certain elements in a div or keyboard navigation gets
- // confused.
- const wrapper = document.createElement("div");
- const checkboxWrapper = document.createElement("div");
- checkboxWrapper.contentEditable = "false";
-
- const checkbox = document.createElement("input");
- checkbox.type = "checkbox";
- checkbox.checked = node.attrs.checked;
- if (node.attrs.checked) {
- checkbox.setAttribute("checked", "");
- }
-
- const changeHandler = () => {
- if (!editor.isEditable) {
- // This seems like the most effective way of blocking the checkbox
- // from being toggled, as event.preventDefault() does not stop it for
- // "click" or "change" events.
- checkbox.checked = !checkbox.checked;
- return;
- }
-
- // TODO: test
- if (typeof getPos !== "boolean") {
- const beforeBlockContainerPos = getNearestBlockPos(
- editor.state.doc,
- getPos(),
- );
-
- if (beforeBlockContainerPos.node.type.name !== "blockContainer") {
- throw new Error(
- `Expected blockContainer node, got ${beforeBlockContainerPos.node.type.name}`,
- );
- }
-
- this.editor.commands.command(
- updateBlockCommand(beforeBlockContainerPos.posBeforeNode, {
- type: "checkListItem",
- props: {
- checked: checkbox.checked as any,
- },
- }),
- );
- }
- };
- checkbox.addEventListener("change", changeHandler);
-
- const { dom, contentDOM } = createDefaultBlockDOMOutputSpec(
- this.name,
- "p",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
-
- if (typeof getPos !== "boolean") {
- // Since `node` is a blockContent node, we have to get the block ID from
- // the parent blockContainer node. This means we can't add the label in
- // `renderHTML` as we can't use `getPos` and therefore can't get the
- // parent blockContainer node.
- const blockID = this.editor.state.doc.resolve(getPos()).node().attrs.id;
- const label = "label-" + blockID;
- checkbox.setAttribute("aria-labelledby", label);
- contentDOM.id = label;
- }
-
- dom.removeChild(contentDOM);
- dom.appendChild(wrapper);
- wrapper.appendChild(checkboxWrapper);
- wrapper.appendChild(contentDOM);
- checkboxWrapper.appendChild(checkbox);
-
- return {
- dom,
- contentDOM,
- destroy: () => {
- checkbox.removeEventListener("change", changeHandler);
- },
- };
- };
- },
-});
-
-export const CheckListItem = createBlockSpecFromStronglyTypedTiptapNode(
- checkListItemBlockContent,
- checkListItemPropSchema,
-);
diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
deleted file mode 100644
index a17024647c..0000000000
--- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Plugin, PluginKey } from "prosemirror-state";
-import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js";
-
-// ProseMirror Plugin which automatically assigns indices to ordered list items per nesting level.
-const PLUGIN_KEY = new PluginKey(`numbered-list-indexing`);
-export const NumberedListIndexingPlugin = () => {
- return new Plugin({
- key: PLUGIN_KEY,
- appendTransaction: (_transactions, _oldState, newState) => {
- const tr = newState.tr;
- tr.setMeta("numberedListIndexing", true);
-
- let modified = false;
-
- // Traverses each node the doc using DFS, so blocks which are on the same nesting level will be traversed in the
- // same order they appear. This means the index of each list item block can be calculated by incrementing the
- // index of the previous list item block.
- newState.doc.descendants((node, pos) => {
- if (
- node.type.name === "blockContainer" &&
- node.firstChild!.type.name === "numberedListItem"
- ) {
- let newIndex = `${node.firstChild!.attrs["start"] || 1}`;
-
- const blockInfo = getBlockInfo({
- posBeforeNode: pos,
- node,
- });
-
- if (!blockInfo.isBlockContainer) {
- throw new Error("impossible");
- }
-
- // Checks if this block is the start of a new ordered list, i.e. if it's the first block in the document, the
- // first block in its nesting level, or the previous block is not an ordered list item.
-
- const prevBlock = tr.doc.resolve(
- blockInfo.bnBlock.beforePos,
- ).nodeBefore;
-
- if (prevBlock) {
- const prevBlockInfo = getBlockInfo({
- posBeforeNode: blockInfo.bnBlock.beforePos - prevBlock.nodeSize,
- node: prevBlock,
- });
-
- const isPrevBlockOrderedListItem =
- prevBlockInfo.blockNoteType === "numberedListItem";
-
- if (isPrevBlockOrderedListItem) {
- if (!prevBlockInfo.isBlockContainer) {
- throw new Error("impossible");
- }
- const prevBlockIndex =
- prevBlockInfo.blockContent.node.attrs["index"];
-
- newIndex = (parseInt(prevBlockIndex) + 1).toString();
- }
- }
-
- const contentNode = blockInfo.blockContent.node;
- const index = contentNode.attrs["index"];
- const isFirst =
- prevBlock?.firstChild?.type.name !== "numberedListItem";
-
- if (index !== newIndex || (contentNode.attrs.start && !isFirst)) {
- modified = true;
-
- const { start, ...attrs } = contentNode.attrs;
-
- tr.setNodeMarkup(blockInfo.blockContent.beforePos, undefined, {
- ...attrs,
- index: newIndex,
- ...(typeof start === "number" &&
- isFirst && {
- start,
- }),
- });
- }
- }
- });
-
- return modified ? tr : null;
- },
- });
-};
diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
deleted file mode 100644
index 4e271bae14..0000000000
--- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-import { InputRule } from "@tiptap/core";
-import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js";
-import {
- PropSchema,
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
- propsToAttributes,
-} from "../../../schema/index.js";
-import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers.js";
-import { defaultProps } from "../../defaultProps.js";
-import { getListItemContent } from "../getListItemContent.js";
-import { handleEnter } from "../ListItemKeyboardShortcuts.js";
-import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin.js";
-
-export const numberedListItemPropSchema = {
- ...defaultProps,
- start: { default: undefined, type: "number" },
-} satisfies PropSchema;
-
-const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
- name: "numberedListItem",
- content: "inline*",
- group: "blockContent",
- priority: 90,
- addAttributes() {
- return {
- ...propsToAttributes(numberedListItemPropSchema),
- // the index attribute is only used internally (it's not part of the blocknote schema)
- // that's why it's defined explicitly here, and not part of the prop schema
- index: {
- default: null,
- parseHTML: (element) => element.getAttribute("data-index"),
- renderHTML: (attributes) => {
- return {
- "data-index": attributes.index,
- };
- },
- },
- };
- },
-
- addInputRules() {
- return [
- // Creates an ordered list when starting with "1.".
- new InputRule({
- find: new RegExp(`^(\\d+)\\.\\s$`),
- handler: ({ state, chain, range, match }) => {
- const blockInfo = getBlockInfoFromSelection(state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*" ||
- blockInfo.blockNoteType === "numberedListItem"
- ) {
- return;
- }
- const startIndex = parseInt(match[1]);
-
- chain()
- .command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "numberedListItem",
- props:
- (startIndex === 1 && {}) ||
- ({
- start: startIndex,
- } as any),
- }),
- )
- // Removes the "1." characters used to set the list.
- .deleteRange({ from: range.from, to: range.to });
- },
- }),
- ];
- },
-
- addKeyboardShortcuts() {
- return {
- Enter: () => handleEnter(this.options.editor),
- "Mod-Shift-7": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "numberedListItem",
- props: {},
- }),
- );
- },
- };
- },
-
- addProseMirrorPlugins() {
- return [NumberedListIndexingPlugin()];
- },
-
- parseHTML() {
- return [
- // Parse from internal HTML.
- {
- tag: "div[data-content-type=" + this.name + "]",
- contentElement: ".bn-inline-content",
- },
- // Parse from external HTML.
- {
- tag: "li",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- const parent = element.parentElement;
-
- if (parent === null) {
- return false;
- }
-
- if (
- parent.tagName === "OL" ||
- (parent.tagName === "DIV" && parent.parentElement?.tagName === "OL")
- ) {
- const startIndex =
- parseInt(parent.getAttribute("start") || "1") || 1;
-
- if (element.previousSibling || startIndex === 1) {
- return {};
- }
-
- return {
- start: startIndex,
- };
- }
-
- return false;
- },
- // As `li` elements can contain multiple paragraphs, we need to merge their contents
- // into a single one so that ProseMirror can parse everything correctly.
- getContent: (node, schema) =>
- getListItemContent(node, schema, this.name),
- priority: 300,
- node: "numberedListItem",
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- return createDefaultBlockDOMOutputSpec(
- this.name,
- // We use a tag, because for
tags we'd need an element to
- // put them in to be semantically correct, which we can't have due to the
- // schema.
- "p",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
- },
-});
-
-export const NumberedListItem = createBlockSpecFromStronglyTypedTiptapNode(
- NumberedListItemBlockContent,
- numberedListItemPropSchema,
-);
diff --git a/packages/core/src/blocks/PageBreak/block.ts b/packages/core/src/blocks/PageBreak/block.ts
new file mode 100644
index 0000000000..49d7d2bd94
--- /dev/null
+++ b/packages/core/src/blocks/PageBreak/block.ts
@@ -0,0 +1,72 @@
+import {
+ BlockSchema,
+ createBlockConfig,
+ createBlockSpec,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../schema/index.js";
+import { BlockNoteSchema } from "../BlockNoteSchema.js";
+
+export type PageBreakBlockConfig = ReturnType<
+ typeof createPageBreakBlockConfig
+>;
+
+export const createPageBreakBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "pageBreak" as const,
+ propSchema: {},
+ content: "none",
+ }) as const,
+);
+
+export const createPageBreakBlockSpec = createBlockSpec(
+ createPageBreakBlockConfig,
+ {
+ parse(element) {
+ if (
+ element.tagName === "DIV" &&
+ element.hasAttribute("data-page-break")
+ ) {
+ return {};
+ }
+
+ return undefined;
+ },
+ render() {
+ const pageBreak = document.createElement("div");
+
+ pageBreak.setAttribute("data-page-break", "");
+
+ return {
+ dom: pageBreak,
+ };
+ },
+ toExternalHTML() {
+ const pageBreak = document.createElement("div");
+
+ pageBreak.setAttribute("data-page-break", "");
+
+ return {
+ dom: pageBreak,
+ };
+ },
+ },
+);
+
+/**
+ * Adds page break support to the given schema.
+ */
+export const withPageBreak = <
+ B extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(
+ schema: BlockNoteSchema,
+) => {
+ return schema.extend({
+ blockSpecs: {
+ pageBreak: createPageBreakBlockSpec(),
+ },
+ });
+};
diff --git a/packages/core/src/blocks/PageBreak/getPageBreakSlashMenuItems.ts b/packages/core/src/blocks/PageBreak/getPageBreakSlashMenuItems.ts
new file mode 100644
index 0000000000..8edc3c5d62
--- /dev/null
+++ b/packages/core/src/blocks/PageBreak/getPageBreakSlashMenuItems.ts
@@ -0,0 +1,47 @@
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { DefaultSuggestionItem } from "../../extensions/SuggestionMenu/DefaultSuggestionItem.js";
+import { insertOrUpdateBlockForSlashMenu } from "../../extensions/SuggestionMenu/getDefaultSlashMenuItems.js";
+import {
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "../../schema/index.js";
+import { createPageBreakBlockConfig } from "./block.js";
+
+export function checkPageBreakBlocksInSchema<
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(
+ editor: BlockNoteEditor,
+): editor is BlockNoteEditor<
+ {
+ pageBreak: ReturnType;
+ },
+ I,
+ S
+> {
+ return "pageBreak" in editor.schema.blockSchema;
+}
+
+export function getPageBreakSlashMenuItems<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(editor: BlockNoteEditor) {
+ const items: (Omit & { key: "page_break" })[] =
+ [];
+
+ if (checkPageBreakBlocksInSchema(editor)) {
+ items.push({
+ ...editor.dictionary.slash_menu.page_break,
+ onItemClick: () => {
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "pageBreak",
+ });
+ },
+ key: "page_break",
+ });
+ }
+
+ return items;
+}
diff --git a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts
deleted file mode 100644
index c8343a30f3..0000000000
--- a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import {
- createBlockSpec,
- CustomBlockConfig,
- Props,
-} from "../../schema/index.js";
-
-export const pageBreakConfig = {
- type: "pageBreak" as const,
- propSchema: {},
- content: "none",
- isFileBlock: false,
- isSelectable: false,
-} satisfies CustomBlockConfig;
-export const pageBreakRender = () => {
- const pageBreak = document.createElement("div");
-
- pageBreak.className = "bn-page-break";
- pageBreak.setAttribute("data-page-break", "");
-
- return {
- dom: pageBreak,
- };
-};
-export const pageBreakParse = (
- element: HTMLElement,
-): Partial> | undefined => {
- if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) {
- return {
- type: "pageBreak",
- };
- }
-
- return undefined;
-};
-export const pageBreakToExternalHTML = () => {
- const pageBreak = document.createElement("div");
-
- pageBreak.setAttribute("data-page-break", "");
-
- return {
- dom: pageBreak,
- };
-};
-
-export const PageBreak = createBlockSpec(pageBreakConfig, {
- render: pageBreakRender,
- parse: pageBreakParse,
- toExternalHTML: pageBreakToExternalHTML,
-});
diff --git a/packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts b/packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts
deleted file mode 100644
index ad58140a2e..0000000000
--- a/packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import { DefaultSuggestionItem } from "../../extensions/SuggestionMenu/DefaultSuggestionItem.js";
-import { insertOrUpdateBlock } from "../../extensions/SuggestionMenu/getDefaultSlashMenuItems.js";
-import {
- BlockSchema,
- InlineContentSchema,
- StyleSchema,
-} from "../../schema/index.js";
-import { pageBreakSchema } from "./schema.js";
-
-export function checkPageBreakBlocksInSchema<
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- editor: BlockNoteEditor,
-): editor is BlockNoteEditor {
- return (
- "pageBreak" in editor.schema.blockSchema &&
- editor.schema.blockSchema["pageBreak"] ===
- pageBreakSchema.blockSchema["pageBreak"]
- );
-}
-
-export function getPageBreakSlashMenuItems<
- BSchema extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(editor: BlockNoteEditor) {
- const items: (Omit & { key: "page_break" })[] =
- [];
-
- if (checkPageBreakBlocksInSchema(editor)) {
- items.push({
- ...editor.dictionary.slash_menu.page_break,
- onItemClick: () => {
- insertOrUpdateBlock(editor, {
- type: "pageBreak",
- });
- },
- key: "page_break",
- });
- }
-
- return items;
-}
diff --git a/packages/core/src/blocks/PageBreakBlockContent/schema.ts b/packages/core/src/blocks/PageBreakBlockContent/schema.ts
deleted file mode 100644
index 15ea54cbe5..0000000000
--- a/packages/core/src/blocks/PageBreakBlockContent/schema.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { BlockNoteSchema } from "../../editor/BlockNoteSchema.js";
-import {
- BlockSchema,
- InlineContentSchema,
- StyleSchema,
-} from "../../schema/index.js";
-import { PageBreak } from "./PageBreakBlockContent.js";
-
-export const pageBreakSchema = BlockNoteSchema.create({
- blockSpecs: {
- pageBreak: PageBreak,
- },
-});
-
-/**
- * Adds page break support to the given schema.
- */
-export const withPageBreak = <
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- schema: BlockNoteSchema,
-) => {
- return BlockNoteSchema.create({
- blockSpecs: {
- ...schema.blockSpecs,
- ...pageBreakSchema.blockSpecs,
- },
- inlineContentSpecs: schema.inlineContentSpecs,
- styleSpecs: schema.styleSpecs,
- }) as any as BlockNoteSchema<
- // typescript needs some help here
- B & {
- pageBreak: typeof PageBreak.config;
- },
- I,
- S
- >;
-};
diff --git a/packages/core/src/blocks/Paragraph/block.ts b/packages/core/src/blocks/Paragraph/block.ts
new file mode 100644
index 0000000000..4473fd1578
--- /dev/null
+++ b/packages/core/src/blocks/Paragraph/block.ts
@@ -0,0 +1,80 @@
+import { createExtension } from "../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
+import {
+ addDefaultPropsExternalHTML,
+ defaultProps,
+ parseDefaultProps,
+} from "../defaultProps.js";
+
+export type ParagraphBlockConfig = ReturnType<
+ typeof createParagraphBlockConfig
+>;
+
+export const createParagraphBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "paragraph" as const,
+ propSchema: defaultProps,
+ content: "inline" as const,
+ }) as const,
+);
+
+export const createParagraphBlockSpec = createBlockSpec(
+ createParagraphBlockConfig,
+ {
+ meta: {
+ isolating: false,
+ },
+ parse: (e) => {
+ if (e.tagName !== "P") {
+ return undefined;
+ }
+
+ // Edge case for things like images directly inside paragraph.
+ if (!e.textContent?.trim()) {
+ return undefined;
+ }
+
+ return parseDefaultProps(e);
+ },
+ render: () => {
+ const dom = document.createElement("p");
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ toExternalHTML: (block) => {
+ const dom = document.createElement("p");
+ addDefaultPropsExternalHTML(block.props, dom);
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ runsBefore: ["default", "heading"],
+ },
+ [
+ createExtension({
+ key: "paragraph-shortcuts",
+ keyboardShortcuts: {
+ "Mod-Alt-0": ({ editor }) => {
+ const cursorPosition = editor.getTextCursorPosition();
+
+ if (
+ editor.schema.blockSchema[cursorPosition.block.type].content !==
+ "inline"
+ ) {
+ return false;
+ }
+
+ editor.updateBlock(cursorPosition.block, {
+ type: "paragraph",
+ props: {},
+ });
+ return true;
+ },
+ },
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts
deleted file mode 100644
index 0c35c117a7..0000000000
--- a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
-import {
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
-} from "../../schema/index.js";
-import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
-import { defaultProps } from "../defaultProps.js";
-
-export const paragraphPropSchema = {
- ...defaultProps,
-};
-
-export const ParagraphBlockContent = createStronglyTypedTiptapNode({
- name: "paragraph",
- content: "inline*",
- group: "blockContent",
-
- addKeyboardShortcuts() {
- return {
- "Mod-Alt-0": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "paragraph",
- props: {},
- }),
- );
- },
- };
- },
-
- parseHTML() {
- return [
- // Parse from internal HTML.
- {
- tag: "div[data-content-type=" + this.name + "]",
- contentElement: ".bn-inline-content",
- },
- // Parse from external HTML.
- {
- tag: "p",
- getAttrs: (element) => {
- if (typeof element === "string" || !element.textContent?.trim()) {
- return false;
- }
-
- return {};
- },
- node: "paragraph",
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- return createDefaultBlockDOMOutputSpec(
- this.name,
- "p",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
- },
-});
-
-export const Paragraph = createBlockSpecFromStronglyTypedTiptapNode(
- ParagraphBlockContent,
- paragraphPropSchema,
-);
diff --git a/packages/core/src/blocks/Quote/block.ts b/packages/core/src/blocks/Quote/block.ts
new file mode 100644
index 0000000000..b6b6a7710f
--- /dev/null
+++ b/packages/core/src/blocks/Quote/block.ts
@@ -0,0 +1,99 @@
+import { createExtension } from "../../editor/BlockNoteExtension.js";
+import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
+import {
+ addDefaultPropsExternalHTML,
+ defaultProps,
+ parseDefaultProps,
+} from "../defaultProps.js";
+
+export type QuoteBlockConfig = ReturnType;
+
+export const createQuoteBlockConfig = createBlockConfig(
+ () =>
+ ({
+ type: "quote" as const,
+ propSchema: {
+ backgroundColor: defaultProps.backgroundColor,
+ textColor: defaultProps.textColor,
+ },
+ content: "inline" as const,
+ }) as const,
+);
+
+export const createQuoteBlockSpec = createBlockSpec(
+ createQuoteBlockConfig,
+ {
+ meta: {
+ isolating: false,
+ },
+ parse(element) {
+ if (element.tagName === "BLOCKQUOTE") {
+ const { backgroundColor, textColor } = parseDefaultProps(element);
+
+ return { backgroundColor, textColor };
+ }
+
+ return undefined;
+ },
+ render() {
+ const quote = document.createElement("blockquote");
+
+ return {
+ dom: quote,
+ contentDOM: quote,
+ };
+ },
+ toExternalHTML(block) {
+ const quote = document.createElement("blockquote");
+ addDefaultPropsExternalHTML(block.props, quote);
+
+ return {
+ dom: quote,
+ contentDOM: quote,
+ };
+ },
+ },
+ [
+ createExtension({
+ key: "quote-block-shortcuts",
+ keyboardShortcuts: {
+ "Mod-Alt-q": ({ editor }) => {
+ const cursorPosition = editor.getTextCursorPosition();
+
+ if (
+ editor.schema.blockSchema[cursorPosition.block.type].content !==
+ "inline"
+ ) {
+ return false;
+ }
+
+ editor.updateBlock(cursorPosition.block, {
+ type: "quote",
+ props: {},
+ });
+ return true;
+ },
+ },
+ inputRules: [
+ {
+ find: new RegExp(`^>\\s$`),
+ replace() {
+ return {
+ type: "quote",
+ props: {},
+ };
+ },
+ },
+ {
+ find: new RegExp(`^\\p{Quotation_Mark}\\s$`, "u"),
+ replace() {
+ return {
+ type: "quote",
+ props: {},
+ };
+ },
+ },
+ ],
+ }),
+ ],
+);
diff --git a/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts b/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts
deleted file mode 100644
index 3c13c56c2d..0000000000
--- a/packages/core/src/blocks/QuoteBlockContent/QuoteBlockContent.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import {
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
-} from "../../schema/index.js";
-import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
-import { defaultProps } from "../defaultProps.js";
-import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
-import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import { InputRule } from "@tiptap/core";
-
-export const quotePropSchema = {
- ...defaultProps,
-};
-
-export const QuoteBlockContent = createStronglyTypedTiptapNode({
- name: "quote",
- content: "inline*",
- group: "blockContent",
-
- addInputRules() {
- return [
- // Creates a block quote when starting with ">".
- new InputRule({
- find: new RegExp(`^>\\s$`),
- handler: ({ state, chain, range }) => {
- const blockInfo = getBlockInfoFromSelection(state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return;
- }
-
- chain()
- .command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "quote",
- props: {},
- }),
- )
- // Removes the ">" character used to set the list.
- .deleteRange({ from: range.from, to: range.to });
- },
- }),
- ];
- },
-
- addKeyboardShortcuts() {
- return {
- "Mod-Alt-q": () => {
- const blockInfo = getBlockInfoFromSelection(this.editor.state);
- if (
- !blockInfo.isBlockContainer ||
- blockInfo.blockContent.node.type.spec.content !== "inline*"
- ) {
- return true;
- }
-
- return this.editor.commands.command(
- updateBlockCommand(blockInfo.bnBlock.beforePos, {
- type: "quote",
- }),
- );
- },
- };
- },
-
- parseHTML() {
- return [
- // Parse from internal HTML.
- {
- tag: "div[data-content-type=" + this.name + "]",
- contentElement: ".bn-inline-content",
- },
- // Parse from external HTML.
- {
- tag: "blockquote",
- node: "quote",
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- return createDefaultBlockDOMOutputSpec(
- this.name,
- "blockquote",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
- },
-});
-
-export const Quote = createBlockSpecFromStronglyTypedTiptapNode(
- QuoteBlockContent,
- quotePropSchema,
-);
diff --git a/packages/core/src/blocks/Table/TableExtension.ts b/packages/core/src/blocks/Table/TableExtension.ts
new file mode 100644
index 0000000000..1d2cf9d47f
--- /dev/null
+++ b/packages/core/src/blocks/Table/TableExtension.ts
@@ -0,0 +1,112 @@
+import { callOrReturn, Extension, getExtensionField } from "@tiptap/core";
+import { TextSelection } from "prosemirror-state";
+import {
+ columnResizing,
+ goToNextCell,
+ isInTable,
+ moveCellForward,
+ nextCell,
+ selectionCell,
+ tableEditing,
+} from "prosemirror-tables";
+
+export const RESIZE_MIN_WIDTH = 35;
+export const EMPTY_CELL_WIDTH = 120;
+export const EMPTY_CELL_HEIGHT = 31;
+
+export const TableExtension = Extension.create({
+ name: "BlockNoteTableExtension",
+
+ addProseMirrorPlugins: () => {
+ return [
+ columnResizing({
+ cellMinWidth: RESIZE_MIN_WIDTH,
+ defaultCellMinWidth: EMPTY_CELL_WIDTH,
+ // We set this to null as we implement our own node view in the table
+ // block content. This node view is the same as what's used by default,
+ // but is wrapped in a `blockContent` HTML element.
+ View: null,
+ }),
+ tableEditing(),
+ ];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ // Moves the selection to the cell below.
+ Enter: () => {
+ if (
+ this.editor.state.selection.$head.parent.type.name !==
+ "tableParagraph"
+ ) {
+ return false;
+ }
+
+ return this.editor.commands.command(({ state, dispatch }) => {
+ if (!isInTable(state)) {
+ return false;
+ }
+
+ const $cell = selectionCell(state);
+ const $nextCell = nextCell($cell, "vert", 1);
+
+ if (!$nextCell) {
+ return false;
+ }
+
+ if (dispatch) {
+ dispatch(
+ state.tr
+ .setSelection(
+ TextSelection.between($nextCell, moveCellForward($nextCell)),
+ )
+ .scrollIntoView(),
+ );
+ }
+
+ return true;
+ });
+ },
+ // Ensures that backspace won't delete the table if the text cursor is at
+ // the start of a cell and the selection is empty.
+ Backspace: () => {
+ const selection = this.editor.state.selection;
+ const selectionIsEmpty = selection.empty;
+ const selectionIsAtStartOfNode = selection.$head.parentOffset === 0;
+ const selectionIsInTableParagraphNode =
+ selection.$head.node().type.name === "tableParagraph";
+
+ return (
+ selectionIsEmpty &&
+ selectionIsAtStartOfNode &&
+ selectionIsInTableParagraphNode
+ );
+ },
+ // Enables navigating cells using the tab key.
+ Tab: () => {
+ return this.editor.commands.command(({ state, dispatch, view }) =>
+ goToNextCell(1)(state, dispatch, view),
+ );
+ },
+ "Shift-Tab": () => {
+ return this.editor.commands.command(({ state, dispatch, view }) =>
+ goToNextCell(-1)(state, dispatch, view),
+ );
+ },
+ };
+ },
+
+ extendNodeSchema(extension) {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: extension.storage,
+ };
+
+ return {
+ tableRole: callOrReturn(
+ getExtensionField(extension, "tableRole", context),
+ ),
+ };
+ },
+});
diff --git a/packages/core/src/blocks/Table/block.ts b/packages/core/src/blocks/Table/block.ts
new file mode 100644
index 0000000000..c71d9ffb7d
--- /dev/null
+++ b/packages/core/src/blocks/Table/block.ts
@@ -0,0 +1,487 @@
+import { Node, mergeAttributes } from "@tiptap/core";
+import { DOMParser, Fragment, Node as PMNode, Schema } from "prosemirror-model";
+import { CellSelection, TableView } from "prosemirror-tables";
+import { NodeView } from "prosemirror-view";
+
+import { createExtension } from "../../editor/BlockNoteExtension.js";
+import {
+ BlockConfig,
+ createBlockSpecFromTiptapNode,
+ TableContent,
+} from "../../schema/index.js";
+import { mergeCSSClasses } from "../../util/browser.js";
+import { camelToDataKebab } from "../../util/string.js";
+import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
+import { defaultProps } from "../defaultProps.js";
+import { EMPTY_CELL_WIDTH, TableExtension } from "./TableExtension.js";
+
+export const tablePropSchema = {
+ textColor: defaultProps.textColor,
+};
+
+const TiptapTableHeader = Node.create<{
+ HTMLAttributes: Record;
+}>({
+ name: "tableHeader",
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
+ },
+
+ /**
+ * We allow table headers and cells to have multiple tableContent nodes because
+ * when merging cells, prosemirror-tables will concat the contents of the cells naively.
+ * This would cause that content to overflow into other cells when prosemirror tries to enforce the cell structure.
+ *
+ * So, we manually fix this up when reading back in the `nodeToBlock` and only ever place a single tableContent back into the cell.
+ */
+ content: "tableContent+",
+
+ addAttributes() {
+ return {
+ colspan: {
+ default: 1,
+ },
+ rowspan: {
+ default: 1,
+ },
+ colwidth: {
+ default: null,
+ parseHTML: (element) => {
+ const colwidth = element.getAttribute("colwidth");
+ const value = colwidth
+ ? colwidth.split(",").map((width) => parseInt(width, 10))
+ : null;
+
+ return value;
+ },
+ },
+ };
+ },
+
+ tableRole: "header_cell",
+
+ isolating: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: "th",
+ // As `th` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ getContent: (node, schema) =>
+ parseTableContent(node as HTMLElement, schema),
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "th",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0,
+ ];
+ },
+});
+
+const TiptapTableCell = Node.create<{
+ HTMLAttributes: Record;
+}>({
+ name: "tableCell",
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
+ },
+
+ content: "tableContent+",
+
+ addAttributes() {
+ return {
+ colspan: {
+ default: 1,
+ },
+ rowspan: {
+ default: 1,
+ },
+ colwidth: {
+ default: null,
+ parseHTML: (element) => {
+ const colwidth = element.getAttribute("colwidth");
+ const value = colwidth
+ ? colwidth.split(",").map((width) => parseInt(width, 10))
+ : null;
+
+ return value;
+ },
+ },
+ };
+ },
+
+ tableRole: "cell",
+
+ isolating: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: "td",
+ // As `td` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ getContent: (node, schema) =>
+ parseTableContent(node as HTMLElement, schema),
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "td",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0,
+ ];
+ },
+});
+
+const TiptapTableNode = Node.create({
+ name: "table",
+ content: "tableRow+",
+ group: "blockContent",
+ tableRole: "table",
+
+ marks: "deletion insertion modification",
+ isolating: true,
+
+ parseHTML() {
+ return [
+ {
+ tag: "table",
+ },
+ ];
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ const domOutputSpec = createDefaultBlockDOMOutputSpec(
+ this.name,
+ "table",
+ {
+ ...(this.options.domAttributes?.blockContent || {}),
+ ...HTMLAttributes,
+ },
+ this.options.domAttributes?.inlineContent || {},
+ );
+
+ // Need to manually add colgroup element
+ const colGroup = document.createElement("colgroup");
+ for (const tableCell of node.children[0].children) {
+ const colWidths: null | (number | undefined)[] =
+ tableCell.attrs["colwidth"];
+
+ if (colWidths) {
+ for (const colWidth of tableCell.attrs["colwidth"]) {
+ const col = document.createElement("col");
+ if (colWidth) {
+ col.style = `width: ${colWidth}px`;
+ }
+
+ colGroup.appendChild(col);
+ }
+ } else {
+ colGroup.appendChild(document.createElement("col"));
+ }
+ }
+
+ domOutputSpec.dom.firstChild?.appendChild(colGroup);
+
+ return domOutputSpec;
+ },
+
+ // This node view is needed for the `columnResizing` plugin. By default, the
+ // plugin adds its own node view, which overrides how the node is rendered vs
+ // `renderHTML`. This means that the wrapping `blockContent` HTML element is
+ // no longer rendered. The `columnResizing` plugin uses the `TableView` as its
+ // default node view. `BlockNoteTableView` extends it by wrapping it in a
+ // `blockContent` element, so the DOM structure is consistent with other block
+ // types.
+ addNodeView() {
+ return ({ node, HTMLAttributes }) => {
+ class BlockNoteTableView extends TableView {
+ constructor(
+ public node: PMNode,
+ public cellMinWidth: number,
+ public blockContentHTMLAttributes: Record,
+ ) {
+ super(node, cellMinWidth);
+
+ const blockContent = document.createElement("div");
+ blockContent.className = mergeCSSClasses(
+ "bn-block-content",
+ blockContentHTMLAttributes.class,
+ );
+ blockContent.setAttribute("data-content-type", "table");
+ for (const [attribute, value] of Object.entries(
+ blockContentHTMLAttributes,
+ )) {
+ if (attribute !== "class") {
+ blockContent.setAttribute(attribute, value);
+ }
+ }
+
+ const tableWrapper = this.dom;
+
+ const tableWrapperInner = document.createElement("div");
+ tableWrapperInner.className = "tableWrapper-inner";
+ tableWrapperInner.appendChild(tableWrapper.firstChild!);
+
+ tableWrapper.appendChild(tableWrapperInner);
+
+ blockContent.appendChild(tableWrapper);
+ const floatingContainer = document.createElement("div");
+ floatingContainer.className = "table-widgets-container";
+ floatingContainer.style.position = "relative";
+ tableWrapper.appendChild(floatingContainer);
+
+ this.dom = blockContent;
+ }
+
+ ignoreMutation(record: MutationRecord): boolean {
+ return (
+ !(record.target as HTMLElement).closest(".tableWrapper-inner") ||
+ super.ignoreMutation(record)
+ );
+ }
+
+ // `TableView` implements its own `update` method, as the view needs to
+ // be persisted across updates for column resizing to work properly.
+ // However, it doesn't do anything else, so we have to re-apply the
+ // HTML attributes from props manually. This isn't an issue for node
+ // views created e.g. by custom blocks, as those aren't persisted
+ // across updates (they are reinstantiated each time), and so
+ // `HTMLAttributes` is always up-to-date for those.
+ update(updatedNode: PMNode): boolean {
+ if (!super.update(updatedNode)) {
+ return false;
+ }
+
+ for (const [propName, propSpec] of Object.entries(tablePropSchema)) {
+ const attrName = camelToDataKebab(propName);
+ const value = updatedNode.attrs[propName];
+ if (value !== propSpec.default) {
+ this.dom.setAttribute(attrName, String(value));
+ } else {
+ this.dom.removeAttribute(attrName);
+ }
+ }
+
+ return true;
+ }
+ }
+
+ return new BlockNoteTableView(node, EMPTY_CELL_WIDTH, {
+ ...(this.options.domAttributes?.blockContent || {}),
+ ...HTMLAttributes,
+ }) as NodeView; // needs cast, tiptap types (wrongly) doesn't support return tableview here
+ };
+ },
+});
+
+const TiptapTableParagraph = Node.create({
+ name: "tableParagraph",
+ group: "tableContent",
+ content: "inline*",
+
+ parseHTML() {
+ return [
+ {
+ tag: "p",
+ getAttrs: (element) => {
+ if (typeof element === "string" || !element.textContent) {
+ return false;
+ }
+
+ // Only parse in internal HTML.
+ if (!element.closest("[data-content-type]")) {
+ return false;
+ }
+
+ const parent = element.parentElement;
+
+ if (parent === null) {
+ return false;
+ }
+
+ if (parent.tagName === "TD" || parent.tagName === "TH") {
+ return {};
+ }
+
+ return false;
+ },
+ node: "tableParagraph",
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ["p", HTMLAttributes, 0];
+ },
+});
+
+/**
+ * This extension allows you to create table rows.
+ * @see https://www.tiptap.dev/api/nodes/table-row
+ */
+const TiptapTableRow = Node.create<{
+ HTMLAttributes: Record;
+}>({
+ name: "tableRow",
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
+ },
+
+ content: "(tableCell | tableHeader)+",
+
+ tableRole: "row",
+ marks: "deletion insertion modification",
+ parseHTML() {
+ return [{ tag: "tr" }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "tr",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0,
+ ];
+ },
+});
+
+/*
+ * This will flatten a node's content to fit into a table cell's paragraph.
+ */
+function parseTableContent(node: HTMLElement, schema: Schema) {
+ const parser = DOMParser.fromSchema(schema);
+
+ // This will parse the content of the table paragraph as though it were a blockGroup.
+ // Resulting in a structure like:
+ //
+ //
+ // Hello
+ //
+ //
+ // Hello
+ //
+ //
+ const parsedContent = parser.parse(node, {
+ topNode: schema.nodes.blockGroup.create(),
+ });
+ const extractedContent: PMNode[] = [];
+
+ // Try to extract any content within the blockContainer.
+ parsedContent.content.descendants((child) => {
+ // As long as the child is an inline node, we can append it to the fragment.
+ if (child.isInline) {
+ // And append it to the fragment
+ extractedContent.push(child);
+ return false;
+ }
+
+ return undefined;
+ });
+
+ return Fragment.fromArray(extractedContent);
+}
+
+export type TableBlockConfig = BlockConfig<
+ "table",
+ {
+ textColor: {
+ default: "default";
+ };
+ },
+ "table"
+>;
+
+export const createTableBlockSpec = () =>
+ createBlockSpecFromTiptapNode(
+ { node: TiptapTableNode, type: "table", content: "table" },
+ tablePropSchema,
+ [
+ createExtension({
+ key: "table-extensions",
+ tiptapExtensions: [
+ TableExtension,
+ TiptapTableParagraph,
+ TiptapTableHeader,
+ TiptapTableCell,
+ TiptapTableRow,
+ ],
+ }),
+ // Extension for keyboard shortcut which deletes the table if it's empty
+ // and all cells are selected. Uses a separate extension as it needs
+ // priority over keyboard handlers in the `TableExtension`'s
+ // `tableEditing` plugin.
+ createExtension({
+ key: "table-keyboard-delete",
+ keyboardShortcuts: {
+ Backspace: ({ editor }) => {
+ if (!(editor.prosemirrorState.selection instanceof CellSelection)) {
+ return false;
+ }
+
+ const block = editor.getTextCursorPosition().block;
+ const content = block.content as TableContent;
+
+ let numCells = 0;
+ for (const row of content.rows) {
+ for (const cell of row.cells) {
+ // Returns `false` if any cell isn't empty.
+ if (
+ ("type" in cell && cell.content.length > 0) ||
+ (!("type" in cell) && cell.length > 0)
+ ) {
+ return false;
+ }
+
+ numCells++;
+ }
+ }
+
+ // Need to use ProseMirror API to check number of selected cells.
+ let selectionNumCells = 0;
+ editor.prosemirrorState.selection.forEachCell(() => {
+ selectionNumCells++;
+ });
+
+ if (selectionNumCells < numCells) {
+ return false;
+ }
+
+ editor.transact(() => {
+ const selectionBlock =
+ editor.getPrevBlock(block) || editor.getNextBlock(block);
+ if (selectionBlock) {
+ editor.setTextCursorPosition(block);
+ }
+
+ editor.removeBlocks([block]);
+ });
+
+ return true;
+ },
+ },
+ }),
+ ],
+ );
+
+// We need to declare this here because we aren't using the table extensions from tiptap, so the types are not automatically inferred.
+declare module "@tiptap/core" {
+ interface NodeConfig {
+ tableRole?: string;
+ }
+}
diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts
deleted file mode 100644
index f56de3b4b1..0000000000
--- a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { TableCell } from "@tiptap/extension-table-cell";
-import { TableHeader } from "@tiptap/extension-table-header";
-import { TableRow } from "@tiptap/extension-table-row";
-import { DOMParser, Fragment, Node as PMNode, Schema } from "prosemirror-model";
-import { TableView } from "prosemirror-tables";
-
-import { NodeView } from "prosemirror-view";
-import {
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
-} from "../../schema/index.js";
-import { mergeCSSClasses } from "../../util/browser.js";
-import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
-import { defaultProps } from "../defaultProps.js";
-import { EMPTY_CELL_WIDTH, TableExtension } from "./TableExtension.js";
-
-export const tablePropSchema = {
- textColor: defaultProps.textColor,
-};
-
-export const TableBlockContent = createStronglyTypedTiptapNode({
- name: "table",
- content: "tableRow+",
- group: "blockContent",
- tableRole: "table",
-
- isolating: true,
-
- parseHTML() {
- return [
- {
- tag: "table",
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- return createDefaultBlockDOMOutputSpec(
- this.name,
- "table",
- {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- },
- this.options.domAttributes?.inlineContent || {},
- );
- },
-
- // This node view is needed for the `columnResizing` plugin. By default, the
- // plugin adds its own node view, which overrides how the node is rendered vs
- // `renderHTML`. This means that the wrapping `blockContent` HTML element is
- // no longer rendered. The `columnResizing` plugin uses the `TableView` as its
- // default node view. `BlockNoteTableView` extends it by wrapping it in a
- // `blockContent` element, so the DOM structure is consistent with other block
- // types.
- addNodeView() {
- return ({ node, HTMLAttributes }) => {
- class BlockNoteTableView extends TableView {
- constructor(
- public node: PMNode,
- public cellMinWidth: number,
- public blockContentHTMLAttributes: Record,
- ) {
- super(node, cellMinWidth);
-
- const blockContent = document.createElement("div");
- blockContent.className = mergeCSSClasses(
- "bn-block-content",
- blockContentHTMLAttributes.class,
- );
- blockContent.setAttribute("data-content-type", "table");
- for (const [attribute, value] of Object.entries(
- blockContentHTMLAttributes,
- )) {
- if (attribute !== "class") {
- blockContent.setAttribute(attribute, value);
- }
- }
-
- const tableWrapper = this.dom;
-
- const tableWrapperInner = document.createElement("div");
- tableWrapperInner.className = "tableWrapper-inner";
- tableWrapperInner.appendChild(tableWrapper.firstChild!);
-
- tableWrapper.appendChild(tableWrapperInner);
-
- blockContent.appendChild(tableWrapper);
- const floatingContainer = document.createElement("div");
- floatingContainer.className = "table-widgets-container";
- floatingContainer.style.position = "relative";
- tableWrapper.appendChild(floatingContainer);
-
- this.dom = blockContent;
- }
-
- ignoreMutation(record: MutationRecord): boolean {
- return (
- !(record.target as HTMLElement).closest(".tableWrapper-inner") ||
- super.ignoreMutation(record)
- );
- }
- }
-
- return new BlockNoteTableView(node, EMPTY_CELL_WIDTH, {
- ...(this.options.domAttributes?.blockContent || {}),
- ...HTMLAttributes,
- }) as NodeView; // needs cast, tiptap types (wrongly) doesn't support return tableview here
- };
- },
-});
-
-const TableParagraph = createStronglyTypedTiptapNode({
- name: "tableParagraph",
- group: "tableContent",
- content: "inline*",
-
- parseHTML() {
- return [
- {
- tag: "p",
- getAttrs: (element) => {
- if (typeof element === "string" || !element.textContent) {
- return false;
- }
-
- // Only parse in internal HTML.
- if (!element.closest("[data-content-type]")) {
- return false;
- }
-
- const parent = element.parentElement;
-
- if (parent === null) {
- return false;
- }
-
- if (parent.tagName === "TD" || parent.tagName === "TH") {
- return {};
- }
-
- return false;
- },
- node: "tableParagraph",
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- return ["p", HTMLAttributes, 0];
- },
-});
-
-/**
- * This will flatten a node's content to fit into a table cell's paragraph.
- */
-function parseTableContent(node: HTMLElement, schema: Schema) {
- const parser = DOMParser.fromSchema(schema);
-
- // This will parse the content of the table paragraph as though it were a blockGroup.
- // Resulting in a structure like:
- //
- //
- // Hello
- //
- //
- // Hello
- //
- //
- const parsedContent = parser.parse(node, {
- topNode: schema.nodes.blockGroup.create(),
- });
- const extractedContent: PMNode[] = [];
-
- // Try to extract any content within the blockContainer.
- parsedContent.content.descendants((child) => {
- // As long as the child is an inline node, we can append it to the fragment.
- if (child.isInline) {
- // And append it to the fragment
- extractedContent.push(child);
- return false;
- }
-
- return undefined;
- });
-
- return Fragment.fromArray(extractedContent);
-}
-
-export const Table = createBlockSpecFromStronglyTypedTiptapNode(
- TableBlockContent,
- tablePropSchema,
- [
- TableExtension,
- TableParagraph,
- TableHeader.extend({
- /**
- * We allow table headers and cells to have multiple tableContent nodes because
- * when merging cells, prosemirror-tables will concat the contents of the cells naively.
- * This would cause that content to overflow into other cells when prosemirror tries to enforce the cell structure.
- *
- * So, we manually fix this up when reading back in the `nodeToBlock` and only ever place a single tableContent back into the cell.
- */
- content: "tableContent+",
- parseHTML() {
- return [
- {
- tag: "th",
- // As `th` elements can contain multiple paragraphs, we need to merge their contents
- // into a single one so that ProseMirror can parse everything correctly.
- getContent: (node, schema) =>
- parseTableContent(node as HTMLElement, schema),
- },
- ];
- },
- }),
- TableCell.extend({
- content: "tableContent+",
- parseHTML() {
- return [
- {
- tag: "td",
- // As `td` elements can contain multiple paragraphs, we need to merge their contents
- // into a single one so that ProseMirror can parse everything correctly.
- getContent: (node, schema) =>
- parseTableContent(node as HTMLElement, schema),
- },
- ];
- },
- }),
- TableRow,
- ],
-);
diff --git a/packages/core/src/blocks/TableBlockContent/TableExtension.ts b/packages/core/src/blocks/TableBlockContent/TableExtension.ts
deleted file mode 100644
index 3660d8c620..0000000000
--- a/packages/core/src/blocks/TableBlockContent/TableExtension.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { callOrReturn, Extension, getExtensionField } from "@tiptap/core";
-import { columnResizing, goToNextCell, tableEditing } from "prosemirror-tables";
-
-export const RESIZE_MIN_WIDTH = 35;
-export const EMPTY_CELL_WIDTH = 120;
-export const EMPTY_CELL_HEIGHT = 31;
-
-export const TableExtension = Extension.create({
- name: "BlockNoteTableExtension",
-
- addProseMirrorPlugins: () => {
- return [
- columnResizing({
- cellMinWidth: RESIZE_MIN_WIDTH,
- defaultCellMinWidth: EMPTY_CELL_WIDTH,
- // We set this to null as we implement our own node view in the table
- // block content. This node view is the same as what's used by default,
- // but is wrapped in a `blockContent` HTML element.
- View: null,
- }),
- tableEditing(),
- ];
- },
-
- addKeyboardShortcuts() {
- return {
- // Makes enter create a new line within the cell.
- Enter: () => {
- if (
- this.editor.state.selection.empty &&
- this.editor.state.selection.$head.parent.type.name ===
- "tableParagraph"
- ) {
- this.editor.commands.insertContent({ type: "hardBreak" });
-
- return true;
- }
-
- return false;
- },
- // Ensures that backspace won't delete the table if the text cursor is at
- // the start of a cell and the selection is empty.
- Backspace: () => {
- const selection = this.editor.state.selection;
- const selectionIsEmpty = selection.empty;
- const selectionIsAtStartOfNode = selection.$head.parentOffset === 0;
- const selectionIsInTableParagraphNode =
- selection.$head.node().type.name === "tableParagraph";
-
- return (
- selectionIsEmpty &&
- selectionIsAtStartOfNode &&
- selectionIsInTableParagraphNode
- );
- },
- // Enables navigating cells using the tab key.
- Tab: () => {
- return this.editor.commands.command(({ state, dispatch, view }) =>
- goToNextCell(1)(state, dispatch, view),
- );
- },
- "Shift-Tab": () => {
- return this.editor.commands.command(({ state, dispatch, view }) =>
- goToNextCell(-1)(state, dispatch, view),
- );
- },
- };
- },
-
- extendNodeSchema(extension) {
- const context = {
- name: extension.name,
- options: extension.options,
- storage: extension.storage,
- };
-
- return {
- tableRole: callOrReturn(
- getExtensionField(extension, "tableRole", context),
- ),
- };
- },
-});
diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts b/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts
new file mode 100644
index 0000000000..de2ba427c1
--- /dev/null
+++ b/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts
@@ -0,0 +1,186 @@
+import { ViewMutationRecord } from "@tiptap/pm/view";
+
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { Block } from "../defaultBlocks.js";
+
+type ToggledState = {
+ set: (block: Block, isToggled: boolean) => void;
+ get: (block: Block) => boolean;
+};
+
+export const defaultToggledState: ToggledState = {
+ set: (block, isToggled: boolean) =>
+ window.localStorage.setItem(
+ `toggle-${block.id}`,
+ isToggled ? "true" : "false",
+ ),
+ get: (block) => window.localStorage.getItem(`toggle-${block.id}`) === "true",
+};
+
+export const createToggleWrapper = (
+ block: Block,
+ editor: BlockNoteEditor,
+ renderedElement: HTMLElement,
+ toggledState: ToggledState = defaultToggledState,
+): {
+ dom: HTMLElement;
+ contentDOM?: HTMLElement;
+ ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
+ destroy?: () => void;
+} => {
+ if ("isToggleable" in block.props && !block.props.isToggleable) {
+ return {
+ dom: renderedElement,
+ };
+ }
+
+ const dom = document.createElement("div");
+
+ const toggleWrapper = document.createElement("div");
+ toggleWrapper.className = "bn-toggle-wrapper";
+
+ const toggleButton = document.createElement("button");
+ toggleButton.className = "bn-toggle-button";
+ toggleButton.type = "button";
+ toggleButton.innerHTML =
+ // https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed
+ ' ';
+ const toggleButtonMouseDown = (event: MouseEvent) => event.preventDefault();
+ toggleButton.addEventListener("mousedown", toggleButtonMouseDown);
+ const toggleButtonOnClick = () => {
+ // Toggles visibility of child blocks. Also adds/removes the "add block"
+ // button if there are no child blocks.
+ if (toggleWrapper.getAttribute("data-show-children") === "true") {
+ toggleWrapper.setAttribute("data-show-children", "false");
+ toggledState.set(editor.getBlock(block)!, false);
+
+ if (dom.contains(toggleAddBlockButton)) {
+ dom.removeChild(toggleAddBlockButton);
+ }
+ } else {
+ toggleWrapper.setAttribute("data-show-children", "true");
+ toggledState.set(editor.getBlock(block)!, true);
+
+ if (
+ editor.isEditable &&
+ editor.getBlock(block)?.children.length === 0 &&
+ !dom.contains(toggleAddBlockButton)
+ ) {
+ dom.appendChild(toggleAddBlockButton);
+ }
+ }
+ };
+ toggleButton.addEventListener("click", toggleButtonOnClick);
+
+ toggleWrapper.appendChild(toggleButton);
+ toggleWrapper.appendChild(renderedElement);
+
+ const toggleAddBlockButton = document.createElement("button");
+ toggleAddBlockButton.className = "bn-toggle-add-block-button";
+ toggleAddBlockButton.type = "button";
+ toggleAddBlockButton.textContent =
+ editor.dictionary.toggle_blocks.add_block_button;
+ const toggleAddBlockButtonMouseDown = (event: MouseEvent) =>
+ event.preventDefault();
+ toggleAddBlockButton.addEventListener(
+ "mousedown",
+ toggleAddBlockButtonMouseDown,
+ );
+ const toggleAddBlockButtonOnClick = () => {
+ // Adds a single empty child block.
+ editor.transact(() => {
+ // dom.removeChild(toggleAddBlockButton);
+
+ const updatedBlock = editor.updateBlock(block, {
+ // Single empty block with default type.
+ children: [{}],
+ });
+ editor.setTextCursorPosition(updatedBlock.children[0].id, "end");
+ editor.focus();
+ });
+ };
+ toggleAddBlockButton.addEventListener("click", toggleAddBlockButtonOnClick);
+
+ dom.appendChild(toggleWrapper);
+
+ let childCount = block.children.length;
+ const onEditorChange = editor.onChange(() => {
+ const newChildCount = editor.getBlock(block)?.children.length ?? 0;
+
+ if (newChildCount > childCount) {
+ // If a child block is added while children are hidden, show children.
+ if (toggleWrapper.getAttribute("data-show-children") === "false") {
+ toggleWrapper.setAttribute("data-show-children", "true");
+ toggledState.set(editor.getBlock(block)!, true);
+ }
+
+ // Remove the "add block" button as we want to show child blocks and
+ // there is at least one child block.
+ if (dom.contains(toggleAddBlockButton)) {
+ dom.removeChild(toggleAddBlockButton);
+ }
+ } else if (newChildCount === 0 && newChildCount < childCount) {
+ // If the last child block is removed while children are shown, hide
+ // children.
+ if (toggleWrapper.getAttribute("data-show-children") === "true") {
+ toggleWrapper.setAttribute("data-show-children", "false");
+ toggledState.set(editor.getBlock(block)!, false);
+ }
+
+ // Remove the "add block" button as we want to hide child blocks,
+ // regardless of whether there are child blocks or not.
+ if (dom.contains(toggleAddBlockButton)) {
+ dom.removeChild(toggleAddBlockButton);
+ }
+ }
+
+ childCount = newChildCount;
+ });
+
+ if (toggledState.get(block)) {
+ toggleWrapper.setAttribute("data-show-children", "true");
+
+ if (editor.isEditable && block.children.length === 0) {
+ // If the toggle is set to show children, but there are no children,
+ // we add the "add block" button.
+ dom.appendChild(toggleAddBlockButton);
+ }
+ } else {
+ toggleWrapper.setAttribute("data-show-children", "false");
+ }
+
+ return {
+ dom,
+ // Prevents re-renders when the toggle button is clicked.
+ ignoreMutation: (mutation) => {
+ if (
+ mutation instanceof MutationRecord &&
+ // We want to prevent re-renders when the view changes, so we ignore
+ // all mutations where the `data-show-children` attribute is changed
+ // or the "add block" button is added/removed.
+ ((mutation.type === "attributes" &&
+ mutation.target === toggleWrapper &&
+ mutation.attributeName === "data-show-children") ||
+ (mutation.type === "childList" &&
+ (mutation.addedNodes[0] === toggleAddBlockButton ||
+ mutation.removedNodes[0] === toggleAddBlockButton)))
+ ) {
+ return true;
+ }
+ return false;
+ },
+ destroy: () => {
+ toggleButton.removeEventListener("mousedown", toggleButtonMouseDown);
+ toggleButton.removeEventListener("click", toggleButtonOnClick);
+ toggleAddBlockButton.removeEventListener(
+ "mousedown",
+ toggleAddBlockButtonMouseDown,
+ );
+ toggleAddBlockButton.removeEventListener(
+ "click",
+ toggleAddBlockButtonOnClick,
+ );
+ onEditorChange?.();
+ },
+ };
+};
diff --git a/packages/core/src/blocks/Video/block.ts b/packages/core/src/blocks/Video/block.ts
new file mode 100644
index 0000000000..73193e6ddb
--- /dev/null
+++ b/packages/core/src/blocks/Video/block.ts
@@ -0,0 +1,142 @@
+import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
+import { defaultProps, parseDefaultProps } from "../defaultProps.js";
+import { parseFigureElement } from "../File/helpers/parse/parseFigureElement.js";
+import { createResizableFileBlockWrapper } from "../File/helpers/render/createResizableFileBlockWrapper.js";
+import { createFigureWithCaption } from "../File/helpers/toExternalHTML/createFigureWithCaption.js";
+import { createLinkWithCaption } from "../File/helpers/toExternalHTML/createLinkWithCaption.js";
+import { parseVideoElement } from "./parseVideoElement.js";
+
+export const FILE_VIDEO_ICON_SVG =
+ ' ';
+
+export interface VideoOptions {
+ icon?: string;
+}
+
+export type VideoBlockConfig = ReturnType;
+
+export const createVideoBlockConfig = createBlockConfig(
+ (_ctx: VideoOptions) => ({
+ type: "video" as const,
+ propSchema: {
+ textAlignment: defaultProps.textAlignment,
+ backgroundColor: defaultProps.backgroundColor,
+ name: { default: "" as const },
+ url: { default: "" as const },
+ caption: { default: "" as const },
+ showPreview: { default: true },
+ previewWidth: { default: undefined, type: "number" as const },
+ },
+ content: "none" as const,
+ }),
+);
+
+export const videoParse = (_config: VideoOptions) => (element: HTMLElement) => {
+ if (element.tagName === "VIDEO") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseVideoElement(element as HTMLVideoElement),
+ backgroundColor,
+ };
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "video");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ const { backgroundColor } = parseDefaultProps(element);
+
+ return {
+ ...parseVideoElement(targetElement as HTMLVideoElement),
+ backgroundColor,
+ caption,
+ };
+ }
+
+ return undefined;
+};
+
+export const createVideoBlockSpec = createBlockSpec(
+ createVideoBlockConfig,
+ (config) => ({
+ meta: {
+ fileBlockAccept: ["video/*"],
+ },
+ parse: videoParse(config),
+ render(block, editor) {
+ const icon = document.createElement("div");
+ icon.innerHTML = config.icon ?? FILE_VIDEO_ICON_SVG;
+
+ const videoWrapper = document.createElement("div");
+ videoWrapper.className = "bn-visual-media-wrapper";
+
+ const video = document.createElement("video");
+ video.className = "bn-visual-media";
+ if (editor.resolveFileUrl) {
+ editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
+ video.src = downloadUrl;
+ });
+ } else {
+ video.src = block.props.url;
+ }
+ video.controls = true;
+ video.contentEditable = "false";
+ video.draggable = false;
+ if (block.props.previewWidth) {
+ video.width = block.props.previewWidth;
+ }
+ videoWrapper.appendChild(video);
+
+ return createResizableFileBlockWrapper(
+ block,
+ editor,
+ { dom: videoWrapper },
+ videoWrapper,
+ icon.firstElementChild as HTMLElement,
+ );
+ },
+ toExternalHTML(block) {
+ if (!block.props.url) {
+ return {
+ dom: document.createElement("video"),
+ };
+ }
+
+ let video;
+ if (block.props.showPreview) {
+ video = document.createElement("video");
+ video.src = block.props.url;
+ if (block.props.previewWidth) {
+ video.width = block.props.previewWidth;
+ }
+ } else {
+ video = document.createElement("a");
+ video.href = block.props.url;
+ video.textContent = block.props.name || block.props.url;
+ }
+
+ if (block.props.caption) {
+ if (block.props.showPreview) {
+ return createFigureWithCaption(video, block.props.caption);
+ } else {
+ return createLinkWithCaption(video, block.props.caption);
+ }
+ }
+
+ return {
+ dom: video,
+ };
+ },
+ runsBefore: ["file"],
+ }),
+);
diff --git a/packages/core/src/blocks/Video/parseVideoElement.ts b/packages/core/src/blocks/Video/parseVideoElement.ts
new file mode 100644
index 0000000000..7852866d4b
--- /dev/null
+++ b/packages/core/src/blocks/Video/parseVideoElement.ts
@@ -0,0 +1,7 @@
+export const parseVideoElement = (videoElement: HTMLVideoElement) => {
+ const url = videoElement.src || undefined;
+ const previewWidth = videoElement.width || undefined;
+ const name = videoElement.getAttribute("data-name") || undefined;
+
+ return { url, previewWidth, name };
+};
diff --git a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts
deleted file mode 100644
index af65e3d0df..0000000000
--- a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import {
- BlockFromConfig,
- createBlockSpec,
- FileBlockConfig,
- Props,
- PropSchema,
-} from "../../schema/index.js";
-import { defaultProps } from "../defaultProps.js";
-import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js";
-import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
-import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
-import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js";
-import { parseVideoElement } from "./parseVideoElement.js";
-
-export const FILE_VIDEO_ICON_SVG =
- ' ';
-
-export const videoPropSchema = {
- textAlignment: defaultProps.textAlignment,
- backgroundColor: defaultProps.backgroundColor,
- // File name.
- name: {
- default: "" as const,
- },
- // File url.
- url: {
- default: "" as const,
- },
- // File caption.
- caption: {
- default: "" as const,
- },
-
- showPreview: {
- default: true,
- },
- // File preview width in px.
- previewWidth: {
- default: undefined,
- type: "number",
- },
-} satisfies PropSchema;
-
-export const videoBlockConfig = {
- type: "video" as const,
- propSchema: videoPropSchema,
- content: "none",
- isFileBlock: true,
- fileBlockAccept: ["video/*"],
-} satisfies FileBlockConfig;
-
-export const videoRender = (
- block: BlockFromConfig,
- editor: BlockNoteEditor,
-) => {
- const icon = document.createElement("div");
- icon.innerHTML = FILE_VIDEO_ICON_SVG;
-
- const videoWrapper = document.createElement("div");
- videoWrapper.className = "bn-visual-media-wrapper";
-
- const video = document.createElement("video");
- video.className = "bn-visual-media";
- if (editor.resolveFileUrl) {
- editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
- video.src = downloadUrl;
- });
- } else {
- video.src = block.props.url;
- }
- video.controls = true;
- video.contentEditable = "false";
- video.draggable = false;
- video.width = block.props.previewWidth;
- videoWrapper.appendChild(video);
-
- return createResizableFileBlockWrapper(
- block,
- editor,
- { dom: videoWrapper },
- videoWrapper,
- editor.dictionary.file_blocks.video.add_button_text,
- icon.firstElementChild as HTMLElement,
- );
-};
-
-export const videoParse = (
- element: HTMLElement,
-): Partial> | undefined => {
- if (element.tagName === "VIDEO") {
- // Ignore if parent figure has already been parsed.
- if (element.closest("figure")) {
- return undefined;
- }
-
- return parseVideoElement(element as HTMLVideoElement);
- }
-
- if (element.tagName === "FIGURE") {
- const parsedFigure = parseFigureElement(element, "video");
- if (!parsedFigure) {
- return undefined;
- }
-
- const { targetElement, caption } = parsedFigure;
-
- return {
- ...parseVideoElement(targetElement as HTMLVideoElement),
- caption,
- };
- }
-
- return undefined;
-};
-
-export const videoToExternalHTML = (
- block: BlockFromConfig,
-) => {
- if (!block.props.url) {
- const div = document.createElement("p");
- div.textContent = "Add video";
-
- return {
- dom: div,
- };
- }
-
- let video;
- if (block.props.showPreview) {
- video = document.createElement("video");
- video.src = block.props.url;
- if (block.props.previewWidth) {
- video.width = block.props.previewWidth;
- }
- } else {
- video = document.createElement("a");
- video.href = block.props.url;
- video.textContent = block.props.name || block.props.url;
- }
-
- if (block.props.caption) {
- if (block.props.showPreview) {
- return createFigureWithCaption(video, block.props.caption);
- } else {
- return createLinkWithCaption(video, block.props.caption);
- }
- }
-
- return {
- dom: video,
- };
-};
-
-export const VideoBlock = createBlockSpec(videoBlockConfig, {
- render: videoRender,
- parse: videoParse,
- toExternalHTML: videoToExternalHTML,
-});
diff --git a/packages/core/src/blocks/VideoBlockContent/parseVideoElement.ts b/packages/core/src/blocks/VideoBlockContent/parseVideoElement.ts
deleted file mode 100644
index 4b11481d48..0000000000
--- a/packages/core/src/blocks/VideoBlockContent/parseVideoElement.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export const parseVideoElement = (videoElement: HTMLVideoElement) => {
- const url = videoElement.src || undefined;
- const previewWidth = videoElement.width || undefined;
-
- return { url, previewWidth };
-};
diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts
index 5f19cc052f..ccadf93e11 100644
--- a/packages/core/src/blocks/defaultBlockHelpers.ts
+++ b/packages/core/src/blocks/defaultBlockHelpers.ts
@@ -100,13 +100,13 @@ export const defaultBlockToHTML = <
// This is used when parsing blocks like list items and table cells, as they may
// contain multiple paragraphs that ProseMirror will not be able to handle
// properly.
-export function mergeParagraphs(element: HTMLElement) {
+export function mergeParagraphs(element: HTMLElement, separator = " ") {
const paragraphs = element.querySelectorAll("p");
if (paragraphs.length > 1) {
const firstParagraph = paragraphs[0];
for (let i = 1; i < paragraphs.length; i++) {
const paragraph = paragraphs[i];
- firstParagraph.innerHTML += " " + paragraph.innerHTML;
+ firstParagraph.innerHTML += separator + paragraph.innerHTML;
paragraph.remove();
}
}
diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts
index 5db988da9a..e9a4f8e9b5 100644
--- a/packages/core/src/blocks/defaultBlockTypeGuards.ts
+++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts
@@ -1,167 +1,159 @@
import { CellSelection } from "prosemirror-tables";
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
-import {
- BlockFromConfig,
- BlockSchema,
- FileBlockConfig,
- InlineContentSchema,
- StyleSchema,
-} from "../schema/index.js";
-import {
- Block,
- DefaultBlockSchema,
- DefaultInlineContentSchema,
- defaultBlockSchema,
- defaultInlineContentSchema,
-} from "./defaultBlocks.js";
-import { defaultProps } from "./defaultProps.js";
+import { BlockConfig, PropSchema, PropSpec } from "../schema/index.js";
+import { Block } from "./defaultBlocks.js";
import { Selection } from "prosemirror-state";
-export function checkDefaultBlockTypeInSchema<
- BlockType extends keyof DefaultBlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
+export function editorHasBlockWithType<
+ BType extends string,
+ Props extends
+ | PropSchema
+ | Record
+ | undefined = undefined,
>(
- blockType: BlockType,
- editor: BlockNoteEditor,
-): editor is BlockNoteEditor<{ Type: DefaultBlockSchema[BlockType] }, I, S> {
- return (
- blockType in editor.schema.blockSchema &&
- editor.schema.blockSchema[blockType] === defaultBlockSchema[blockType]
- );
-}
-
-export function checkDefaultInlineContentTypeInSchema<
- InlineContentType extends keyof DefaultInlineContentSchema,
- B extends BlockSchema,
- S extends StyleSchema,
->(
- inlineContentType: InlineContentType,
- editor: BlockNoteEditor,
+ editor: BlockNoteEditor,
+ blockType: BType,
+ props?: Props,
): editor is BlockNoteEditor<
- B,
- { Type: DefaultInlineContentSchema[InlineContentType] },
- S
+ {
+ [BT in BType]: Props extends PropSchema
+ ? BlockConfig
+ : Props extends Record
+ ? BlockConfig<
+ BT,
+ {
+ [PN in keyof Props]: {
+ default: undefined;
+ type: Props[PN];
+ values?: any[];
+ };
+ }
+ >
+ : BlockConfig;
+ },
+ any,
+ any
> {
- return (
- inlineContentType in editor.schema.inlineContentSchema &&
- editor.schema.inlineContentSchema[inlineContentType] ===
- defaultInlineContentSchema[inlineContentType]
- );
-}
+ if (!(blockType in editor.schema.blockSpecs)) {
+ return false;
+ }
-export function checkBlockIsDefaultType<
- BlockType extends keyof DefaultBlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- blockType: BlockType,
- block: Block,
- editor: BlockNoteEditor,
-): block is BlockFromConfig {
- return (
- block.type === blockType &&
- block.type in editor.schema.blockSchema &&
- checkDefaultBlockTypeInSchema(block.type, editor)
- );
-}
+ if (!props) {
+ return true;
+ }
-export function checkBlockIsFileBlock<
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- block: Block,
- editor: BlockNoteEditor,
-): block is BlockFromConfig {
- return (
- (block.type in editor.schema.blockSchema &&
- editor.schema.blockSchema[block.type].isFileBlock) ||
- false
- );
-}
+ for (const [propName, propSpec] of Object.entries(props)) {
+ if (!(propName in editor.schema.blockSpecs[blockType].config.propSchema)) {
+ return false;
+ }
-export function checkBlockIsFileBlockWithPreview<
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- block: Block,
- editor: BlockNoteEditor,
-): block is BlockFromConfig<
- FileBlockConfig & {
- propSchema: Required;
- },
- I,
- S
-> {
- return (
- (block.type in editor.schema.blockSchema &&
- editor.schema.blockSchema[block.type].isFileBlock &&
- "showPreview" in editor.schema.blockSchema[block.type].propSchema) ||
- false
- );
-}
+ if (typeof propSpec === "string") {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default !== undefined &&
+ typeof editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default !== propSpec
+ ) {
+ return false;
+ }
+
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName].type !==
+ undefined &&
+ editor.schema.blockSpecs[blockType].config.propSchema[propName].type !==
+ propSpec
+ ) {
+ return false;
+ }
+ } else {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default !== propSpec.default
+ ) {
+ return false;
+ }
+
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default === undefined &&
+ propSpec.default === undefined
+ ) {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .type !== propSpec.type
+ ) {
+ return false;
+ }
+ }
+
+ if (
+ typeof editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .values !== typeof propSpec.values
+ ) {
+ return false;
+ }
-export function checkBlockIsFileBlockWithPlaceholder<
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(block: Block, editor: BlockNoteEditor) {
- const config = editor.schema.blockSchema[block.type];
- return config.isFileBlock && !block.props.url;
+ if (
+ typeof editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .values === "object" &&
+ typeof propSpec.values === "object"
+ ) {
+ for (const value of propSpec.values) {
+ if (
+ !editor.schema.blockSpecs[blockType].config.propSchema[
+ propName
+ ].values.includes(value)
+ ) {
+ return false;
+ }
+ }
+ }
+ }
+ }
+
+ return true;
}
-export function checkBlockTypeHasDefaultProp<
- Prop extends keyof typeof defaultProps,
- I extends InlineContentSchema,
- S extends StyleSchema,
+export function blockHasType<
+ BType extends string,
+ Props extends
+ | PropSchema
+ | Record
+ | undefined = undefined,
>(
- prop: Prop,
- blockType: string,
- editor: BlockNoteEditor,
-): editor is BlockNoteEditor<
+ block: Block,
+ editor: BlockNoteEditor,
+ blockType: BType,
+ props?: Props,
+): block is Block<
{
- [BT in string]: {
- type: BT;
- propSchema: {
- [P in Prop]: (typeof defaultProps)[P];
- };
- content: "table" | "inline" | "none";
- };
+ [BT in BType]: Props extends PropSchema
+ ? BlockConfig
+ : Props extends Record
+ ? BlockConfig<
+ BT,
+ {
+ [PN in keyof Props]: PropSpec<
+ Props[PN] extends "boolean"
+ ? boolean
+ : Props[PN] extends "number"
+ ? number
+ : Props[PN] extends "string"
+ ? string
+ : never
+ >;
+ }
+ >
+ : BlockConfig;
},
- I,
- S
+ any,
+ any
> {
return (
- blockType in editor.schema.blockSchema &&
- prop in editor.schema.blockSchema[blockType].propSchema &&
- editor.schema.blockSchema[blockType].propSchema[prop] === defaultProps[prop]
+ editorHasBlockWithType(editor, blockType, props) && block.type === blockType
);
}
-export function checkBlockHasDefaultProp<
- Prop extends keyof typeof defaultProps,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- prop: Prop,
- block: Block