diff --git a/.claude/skills/testing-skill/SKILL.md b/.claude/skills/testing-skill/SKILL.md index 982d9ea03b..6ff6af56a9 100644 --- a/.claude/skills/testing-skill/SKILL.md +++ b/.claude/skills/testing-skill/SKILL.md @@ -51,12 +51,12 @@ End-to-end tests run in vitest browser mode (chromium, firefox and webkit) insid bash tests/docker-run.sh -e CI=1 -- --run [filters] ``` +**Never run the browser suite natively** (`vp test -c vite.config.browser.ts` outside the container) — not even "just one file". Two failure modes, both silent: the screenshot matcher _seeds_ a new `-darwin`/`-win32` baseline for every screenshot test that has none (passing without comparing anything, and littering the tree with hundreds of unvetted PNGs — these are gitignored as a backstop, and only `-linux` baselines are tracked), and several suites genuinely behave differently outside Linux (e.g. caret placement is font-metric-dependent, so platform-shared JSON snapshots mismatch). If Docker isn't running, notify the user to launch it instead of falling back to a native run. + A specific test file may be targeted by appending (part of) its name as a filter. A single browser may be targeted with `--project "e2e (chromium)"`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass). Screenshot baselines can be regenerated with the `-u` argument, which must come **after** the filters (`--run -u`): written as `--run -u `, the filter is parsed as the flag's value and the **whole** suite runs in update mode, silently rewriting unrelated baselines. Note that `-u` only rewrites baselines whose comparison **fails** — a small intended change (e.g. a short text edit) that fits inside the suite's 2% pixel tolerance leaves the baseline stale while the test passes. To force a fresh capture, delete the baseline file first. Baselines are per-browser (`--linux.png`); after regenerating, always inspect the images before committing them. -If Docker isn't running, notify the user to launch it. - When testing a visual change, prefer writing screenshots to verify that the change is working as expected. **Screenshots of tall content**: browser-suite tests run inside a tester iframe sized to the browser window (1280x720), and element screenshots only contain what the iframe actually paints — anything below its fold captures as blank white, silently. Growing the iframe with `page.viewport()` alone doesn't fix this at full resolution: the harness scales the iframe down to fit the window, shrinking the resulting baseline (`static.test.tsx` accepts that trade-off). For full-resolution captures use `screenshotFull` (`tests/src/utils/screenshotFull.ts`), which grows the iframe past the content and neutralizes the harness's scale transform during the capture — the same mechanism upstream Vitest adopted in vitest-dev/vitest#9745 (milestone 5.0.0; the util can be deleted once vite-plus ships it). Always eyeball newly generated baselines for truncation. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00aead0595..d6a941508b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,9 @@ jobs: - name: Lint packages run: vp lint + - name: Install PDF tooling (veraPDF conformance gate + poppler visual snapshots) + run: bash tests/scripts/install-pdf-tooling.sh + - name: Run unit tests run: vp run test @@ -110,6 +113,9 @@ jobs: with: name: e2e-attachments-${{ matrix.browser }}-${{ matrix.shardIndex }} path: tests/.vitest-attachments/ + # The attachments dir is a dot-directory; without this the action + # silently uploads nothing ("No files were found"). + include-hidden-files: true retention-days: 7 merge-reports: diff --git a/.github/workflows/fresh-install-tests.yml b/.github/workflows/fresh-install-tests.yml index edf45382d0..b713b4a785 100644 --- a/.github/workflows/fresh-install-tests.yml +++ b/.github/workflows/fresh-install-tests.yml @@ -82,6 +82,9 @@ jobs: name: Build packages run: vp run -r build + - name: Install PDF tooling (veraPDF conformance gate + poppler visual snapshots) + run: bash tests/scripts/install-pdf-tooling.sh + - id: run_unit_tests name: Run unit tests run: vp run test diff --git a/.github/workflows/update-e2e-screenshots.yml b/.github/workflows/update-e2e-screenshots.yml new file mode 100644 index 0000000000..b4c0ada69e --- /dev/null +++ b/.github/workflows/update-e2e-screenshots.yml @@ -0,0 +1,68 @@ +# Regenerates the Linux screenshot baselines for the browser (e2e) suite in +# the same Playwright container the e2e jobs use, and uploads them as an +# artifact to download and commit. Linux baselines can otherwise only be +# produced locally through docker; this is the push-button alternative. +name: Update e2e screenshots + +on: + workflow_dispatch: + inputs: + filter: + description: "Test file filter (empty = all browser tests)" + required: false + default: "exporterImages" + +permissions: + contents: read + +jobs: + update-screenshots: + name: "Update screenshots" + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: mcr.microsoft.com/playwright:v1.60.0-noble + env: + # Routed through env so the input never expands into the shell script + # itself (template-level expansion is a code-injection vector - zizmor + # template-injection). Deliberately unquoted at use sites: an empty + # filter must expand to no argument at all. + FILTER: ${{ inputs.filter }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 100 + persist-credentials: false + + - uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0 + with: + node-version-file: ".node-version" + cache: true + + - name: Install dependencies + run: vp install + + # `-u` must come after the filter, and each browser runs separately so a + # failure in one doesn't stop the others' baselines from updating. + - name: Update screenshots (chromium) + run: HOME=/root vp test -c vite.config.browser.ts --run --browser chromium $FILTER -u + working-directory: tests + continue-on-error: true + - name: Update screenshots (firefox) + run: HOME=/root vp test -c vite.config.browser.ts --run --browser firefox $FILTER -u + working-directory: tests + continue-on-error: true + - name: Update screenshots (webkit) + run: HOME=/root vp test -c vite.config.browser.ts --run --browser webkit $FILTER -u + working-directory: tests + continue-on-error: true + + - name: Show changed baselines + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" && git status --porcelain tests/src + + - name: Upload updated screenshots + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: updated-screenshots + path: tests/src/**/__screenshots__/ + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 22b10b6527..a57ca58812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ All commands below are listed under `package.json` in the project root. See `vit - `vp run preview`: Previews the build on port 3000. - `vp run test`: Runs unit tests. Append with `-u` to update snapshots. Append with a file name to target only that file. - To run individual unit tests, use `vp run test `. For example, `vp run test packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts`. -- `vp run e2e`: Runs end-to-end tests. Append with a file name to target only that file. +- `vp run e2e`: Runs end-to-end tests (always in Docker - NEVER run the browser suite natively; it seeds bogus per-platform snapshots). Append with a file name to target only that file. - `vp run e2e:updateSnaps`: Runs end-to-end tests & updates snapshots. Append with a file name to target only that file. - `vp help`: Prints a list of all available commands. diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000..643577dfae --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/app/demo/_components/DemoEditor.tsx b/docs/app/demo/_components/DemoEditor.tsx index d1d6ccd54f..724b2760fb 100644 --- a/docs/app/demo/_components/DemoEditor.tsx +++ b/docs/app/demo/_components/DemoEditor.tsx @@ -46,9 +46,11 @@ import { } from "@blocknote/xl-odt-exporter"; import { PDFExporter, - pdfDefaultSchemaMappings, + typstDefaultSchemaMappings, } from "@blocknote/xl-pdf-exporter"; -import { pdf } from "@react-pdf/renderer"; +// Bundle the Typst compiler wasm (resolved to a local asset in +// next.config.ts) instead of loading it from a CDN. +import compilerWasmUrl from "@myriaddreamin/typst-ts-web-compiler/wasm?url"; import { DefaultChatTransport } from "ai"; import { useTheme } from "next-themes"; import { useEffect, useMemo, useState } from "react"; @@ -336,9 +338,17 @@ function DemoEditorInner({ let filename = `blocknote-export.${format}`; if (format === "pdf") { - const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings); - const pdfDocs = await exporter.toReactPDFDocument(editor.document); - blob = await pdf(pdfDocs).toBlob(); + // Tagged PDF/UA-1 via the Typst-based exporter; the default fonts + // (matching the editor) load lazily from the package. + const exporter = new PDFExporter( + editor.schema, + typstDefaultSchemaMappings, + ); + blob = await exporter.toBlob( + editor.document, + { getModule: () => compilerWasmUrl }, + { title: "BlockNote demo document" }, + ); } else if (format === "docx") { const exporter = new DOCXExporter( editor.schema, diff --git a/docs/components/typstCompilerWasmUrl.ts b/docs/components/typstCompilerWasmUrl.ts new file mode 100644 index 0000000000..dc3ec24e6e --- /dev/null +++ b/docs/components/typstCompilerWasmUrl.ts @@ -0,0 +1,11 @@ +// The Typst compiler wasm for the docs build (see next.config.ts): the +// pdf-ua example bundles it via Vite's `?url`; under the docs site the +// import is aliased here. `new URL(..., import.meta.url)` is Turbopack's +// static-asset reference: the ~29MB wasm is emitted as a hashed static +// file and served by the site itself - no CDN involved, and the version +// can't skew from the installed package because it IS the installed file. +const url = new URL( + "../node_modules/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm", + import.meta.url, +).href; +export default url; diff --git a/docs/components/typstRendererStub.ts b/docs/components/typstRendererStub.ts new file mode 100644 index 0000000000..a0a636f2d6 --- /dev/null +++ b/docs/components/typstRendererStub.ts @@ -0,0 +1,4 @@ +// Stub for @myriaddreamin/typst-ts-renderer (see next.config.ts): the +// pdf-ua demo only compiles Typst, never canvas-renders, so the optional +// renderer peer is not installed. +export {}; diff --git a/docs/content/docs/features/blocks/diagrams.mdx b/docs/content/docs/features/blocks/diagrams.mdx index e06f57455e..605759d0ce 100644 --- a/docs/content/docs/features/blocks/diagrams.mdx +++ b/docs/content/docs/features/blocks/diagrams.mdx @@ -68,7 +68,7 @@ The example below wires them all up. Diagrams export to every format BlockNote supports. [Markdown](/docs/features/export/markdown) works out of the box — diagrams export as ` ```mermaid ` fenced code blocks, their common Markdown notation. -The [PDF](/docs/features/export/pdf), [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters embed the diagram as an image via their mappings — they live as subpaths of this package, and each exports a `createDiagramBlockMapping` factory to spread into the exporter's default mappings. The [DOCX exporter](/docs/features/export/docx) shown here; the [PDF](/docs/features/export/pdf), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters work the same way with their respective subpaths: +The [PDF](/docs/features/export/pdf) (and [Typst](/docs/features/export/typst)), [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters embed the diagram as an image via their mappings — they live as subpaths of this package, and each exports a `createDiagramBlockMapping` factory to spread into the exporter's default mappings. The [DOCX exporter](/docs/features/export/docx) shown here; the others work the same way with their respective subpaths: ```typescript import { @@ -76,7 +76,8 @@ import { docxDefaultSchemaMappings, } from "@blocknote/xl-docx-exporter"; import { createDiagramBlockMapping } from "@blocknote/diagram-block/docx-exporter"; -// ...or "@blocknote/diagram-block/pdf-exporter", +// ...or "@blocknote/diagram-block/typst-exporter" (PDF + Typst - embeds +// vector SVG with labels as real text), // "@blocknote/diagram-block/odt-exporter", // "@blocknote/diagram-block/email-exporter" diff --git a/docs/content/docs/features/blocks/math.mdx b/docs/content/docs/features/blocks/math.mdx index 048686f2b8..3bf753c1da 100644 --- a/docs/content/docs/features/blocks/math.mdx +++ b/docs/content/docs/features/blocks/math.mdx @@ -121,34 +121,29 @@ import { ### PDF -With the [PDF exporter](/docs/features/export/pdf), math blocks export as vector formulas — no rasterization, so they also work server-side out of the box. Inline math is rasterized to images that flow with the text: +With the [PDF exporter](/docs/features/export/pdf) (or the standalone +[Typst exporter](/docs/features/export/typst) — they share mappings), math +blocks and inline math export as **native Typst equations**: real, selectable +text, no rasterization, so they also work server-side out of the box: ```typescript import { - createInlineMathMapping, + inlineMathMapping, mathBlockMapping, -} from "@blocknote/math-block/pdf-exporter"; +} from "@blocknote/math-block/typst-exporter"; -// Spread into the PDFExporter's mappings as for DOCX above - note that -// inline math is a factory here: `math: createInlineMathMapping()`. +// Spread into the PDFExporter's mappings as for DOCX above: +// `mathBlock: mathBlockMapping` and `math: inlineMathMapping`. ``` -The inline math factory takes one option: - -```typescript -createInlineMathMapping(options?: { - /** - * Rasterizes the formula SVG to an image. Defaults to the built-in - * canvas rasterizer, which only works in the browser - when exporting - * server-side, pass one backed by e.g. `@resvg/resvg-js` or `sharp`; - * without it, a server-side export throws. The `RasterizeSVG` type is - * exported from the same subpath. - */ - rasterize?: RasterizeSVG; -}); -``` +The LaTeX is converted to Typst's math notation. Most KaTeX commands are +covered; a valid formula using an uncovered command fails the export loudly +(rather than silently exporting something else), while invalid LaTeX renders +the error placeholder as in other formats. -Math blocks require the `@react-pdf/math` package (a peer dependency of the PDF mapping). +The deprecated [react-pdf exporter](/docs/features/export/pdf#deprecated-the-react-pdf-exporter)'s +mappings remain available from `@blocknote/math-block/pdf-exporter` during its +deprecation window. ### Email diff --git a/docs/content/docs/features/export/meta.json b/docs/content/docs/features/export/meta.json index eba425dcaf..de3ccac056 100644 --- a/docs/content/docs/features/export/meta.json +++ b/docs/content/docs/features/export/meta.json @@ -1,4 +1,4 @@ { "title": "Export", - "pages": ["markdown", "html", "pdf", "docx", "email", "odt", "..."] + "pages": ["markdown", "html", "pdf", "docx", "email", "odt", "typst", "..."] } diff --git a/docs/content/docs/features/export/pdf.mdx b/docs/content/docs/features/export/pdf.mdx index ffea275c0f..3d0dec3328 100644 --- a/docs/content/docs/features/export/pdf.mdx +++ b/docs/content/docs/features/export/pdf.mdx @@ -1,6 +1,6 @@ --- title: PDF -description: Export BlockNote documents to a PDF. +description: Export BlockNote documents to an accessible, tagged PDF (PDF/UA). imageTitle: PDF Export path: /docs/export-to-pdf --- @@ -8,6 +8,10 @@ path: /docs/export-to-pdf # PDF Export It's possible to export BlockNote documents to PDF, completely client-side. +The exporter is powered by the [Typst](https://typst.app) typesetting engine +(compiled to WebAssembly) and produces **accessible, tagged PDF/UA-1** +documents: the PDF carries a logical structure tree (headings, paragraphs, +lists, tables, figures with alt text, links) that screen readers can navigate. This feature is provided by the `@blocknote/xl-pdf-exporter`. `xl-` packages @@ -16,98 +20,252 @@ It's possible to export BlockNote documents to PDF, completely client-side. [Business subscription](/pricing). -First, install the `@blocknote/xl-pdf-exporter` and `@react-pdf/renderer` packages: +First, install the `@blocknote/xl-pdf-exporter` package: ```bash -npm install @blocknote/xl-pdf-exporter @react-pdf/renderer +npm install @blocknote/xl-pdf-exporter ``` -Then, create an instance of the `PDFExporter` class. This exposes the following methods: +Then, create an instance of the `PDFExporter` class and export the document: ```typescript import { PDFExporter, - pdfDefaultSchemaMappings, + typstDefaultSchemaMappings, } from "@blocknote/xl-pdf-exporter"; -import * as ReactPDF from "@react-pdf/renderer"; // Create the exporter -const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings); - -// Convert the blocks to a react-pdf document -const pdfDocument = await exporter.toReactPDFDocument(editor.document); +const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings); -// Use react-pdf to write to file: -await ReactPDF.render(pdfDocument, `filename.pdf`); +// Export the document to a PDF Blob (there's also `toBytes` for a Uint8Array) +const blob = await exporter.toBlob( + editor.document, + {}, + { title: "My document", lang: "en" }, +); ``` -See the [full example](/examples/interoperability/converting-blocks-to-pdf) with live PDF preview below: +This works out of the box: the export matches the editor's look (Inter body +text, Geist Mono code) and handles math blocks and emoji — the default fonts +ship inside the package and load lazily on the first export, with no CDN +involved. The Typst compiler itself (a ~30MB wasm file) is the one thing +loaded from a CDN by default; for production you'll want to bundle it: see +[Fonts & offline use](#fonts--offline-use). - +When repeatedly exporting changing content (e.g. a live preview), create a +fresh exporter per export — construction is cheap, and an exporter instance +accumulates the image assets it has resolved for as long as it lives. -### Customizing the PDF +See the [full example](/examples/interoperability/converting-blocks-to-pdf-ua) +with a live PDF preview below: -`toReactPDFDocument` takes an optional `options` parameter, which allows you to customize the header and footer of the PDF: + -Example usage: +### Customizing the PDF + +The second parameter of `toBlob` / `toBytes` takes the compile options +(covered in [Fonts & offline use](#fonts--offline-use)); the third takes +per-document options: ```typescript -import { Text } from "@react-pdf/renderer"; -const pdfDocument = await exporter.toReactPDFDocument(editor.document, { - header: Header, - footer: Footer, +const blob = await exporter.toBlob(editor.document, {}, { + // Document title - required for PDF/UA (also shown in the viewer's title bar) + title: "My document", + // Document author, written to the PDF metadata + author: "John Doe", + // BCP-47 language tag of the document's natural language + lang: "en", + // Typst paper name, e.g. "a4" (default) or "us-letter" + paper: "a4", + // Page margin as a Typst length + margin: "48pt", + // Raw Typst markup for the running page header / footer, e.g. a + // page counter: "#context counter(page).display()" + header: "My document", + footer: "#context counter(page).display()", }); ``` ### Custom mappings / custom schemas -The `PDFExporter` constructor takes a `schema` and `mappings` parameter. -A _mapping_ defines how to convert a BlockNote schema element (a Block, Inline Content, or Style) to a React-PDF element. -If you're using a [custom schema](/docs/features/custom-schemas) in your editor, or if you want to overwrite how default BlockNote elements are converted to PDF, you can pass your own `mappings`: +The `PDFExporter` constructor takes a `schema` and `mappings` parameter. A +_mapping_ defines how to convert a BlockNote schema element (a Block, Inline +Content, or Style) — for this exporter, into a **Typst markup string**. The +same mappings drive the standalone [Typst export](/docs/features/export/typst), +so one custom-block mapping serves both formats. -For example, use the following code in case your schema has an `extraBlock` type: +If you're using a [custom schema](/docs/features/custom-schemas) in your +editor, or if you want to overwrite how default BlockNote elements are +converted, you can pass your own `mappings`: ```typescript -import { PDFExporter, pdfDefaultSchemaMappings } from "@blocknote/xl-pdf-exporter"; -import { Text } from "@react-pdf/renderer"; +import { + PDFExporter, + typstDefaultSchemaMappings, + strLit, +} from "@blocknote/xl-pdf-exporter"; new PDFExporter(schema, { - blockMapping: { - ...pdfDefaultSchemaMappings.blockMapping, - myCustomBlock: (block, exporter) => { - return My custom block; - }, + ...typstDefaultSchemaMappings, + blockMapping: { + ...typstDefaultSchemaMappings.blockMapping, + myCustomBlock: (block, exporter) => { + // Return Typst markup; `strLit` safely embeds user text as a + // Typst string literal. + return `#${strLit("My custom block")}`; }, - inlineContentMapping: pdfDefaultSchemaMappings.inlineContentMapping, - styleMapping: pdfDefaultSchemaMappings.styleMapping, + }, }); ``` +For a block with inline content, render it the way the default mappings do: +`exporter.transformInlineContent(block.content).join("")` (inline results are +markup strings, so plain concatenation composes them). + ### Math & diagram blocks -The [math](/docs/features/blocks/math) and [diagram](/docs/features/blocks/diagrams) blocks ship their own PDF mappings — math blocks export as vector formulas, inline math as images flowing with the text, diagrams as embedded images. See [exporting math](/docs/features/blocks/math#pdf) and [exporting diagrams](/docs/features/blocks/diagrams#exporting) for the setup. +The [math](/docs/features/blocks/math) and +[diagram](/docs/features/blocks/diagrams) blocks ship Typst mappings — math +exports as native Typst equations (real text, not images), diagrams as +embedded vector SVG — both carrying alt text, as PDF/UA requires: + +```typescript +import { diagramBlockMapping } from "@blocknote/diagram-block/typst-exporter"; +import { + inlineMathMapping, + mathBlockMapping, +} from "@blocknote/math-block/typst-exporter"; + +new PDFExporter(editor.schema, { + ...typstDefaultSchemaMappings, + blockMapping: { + ...typstDefaultSchemaMappings.blockMapping, + mathBlock: mathBlockMapping, + diagram: diagramBlockMapping, + }, + inlineContentMapping: { + ...typstDefaultSchemaMappings.inlineContentMapping, + math: inlineMathMapping, + }, +}); +``` + +### Fonts & offline use + +By default, exports use a font set matching the editor — Inter (body), Geist +Mono (code), New Computer Modern Math (math blocks) and Noto Color Emoji +(emoji, [required for PDF/UA](#pdfua-conformance)) — embedded in the package +and loaded lazily on the first export. Fonts never touch a CDN; the compiler +wasm is the only CDN default, and bundling it makes the export fully +offline. The compile options (the second parameter of `toBlob` / `toBytes`) +control all of it: + +```typescript +import compilerWasmUrl from "@myriaddreamin/typst-ts-web-compiler/wasm?url"; + +const blob = await exporter.toBlob(editor.document, { + // The compiler wasm, bundled by your bundler (Vite shown here) instead + // of loaded from a CDN. Install @myriaddreamin/typst-ts-web-compiler to + // import it. + getModule: () => compilerWasmUrl, +}); +``` + +To take full control of fonts (e.g. a different look, or trimming the lazily +loaded defaults — the emoji font alone is ~5MB), pass your own font bytes. +Each option independently replaces its bundled default: supplying `fonts` +keeps the default emoji font (and vice versa), and an explicit empty array +disables one entirely: + +```typescript +const blob = await exporter.toBlob(editor.document, { + // Font bytes (Uint8Array) to load into the compiler. + fonts: [myBodyFont, myMonoFont], + // An emoji-capable font. Browsers give the compiler no access to OS + // fonts, so without one emoji render as missing glyphs (and fail PDF/UA). + emojiFont: myEmojiFont, + // Optionally also preload Typst's stock fonts (from its CDN) as + // fallback faces for glyphs your fonts don't cover. + preloadDefaultFonts: true, +}); +``` + +The wasm and fonts are loaded once, on the page's first export, and reused +afterwards — pass every font the page will need on that first call. The +[example](/examples/interoperability/converting-blocks-to-pdf-ua) bundles +everything explicitly and works fully offline. + +When passing custom fonts, set the exporter's font families to match +(defaults: `"Inter 18pt"` body, `"Geist Mono"` code, `"Noto Color Emoji"` +emoji). This is also how you cover scripts the primary font doesn't, e.g. +CJK — load the extra font's bytes and declare a fallback list: + +```typescript +const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings, { + fontFamily: ["Inter 18pt", "Noto Sans SC"], +}); +``` + +### PDF/UA conformance + +The produced PDF is tagged and **declares** PDF/UA-1 conformance. Two things +to know: + +- **Alt text**: every image needs it. BlockNote's image block has no dedicated + alt field yet, so the caption (or file name) is used — give images captions. +- **Headings**: PDF/UA requires the document's first heading to be level 1 — + start documents with an H1. + +The declaration doesn't itself guarantee conformance of arbitrary input, so +validate exports with [veraPDF](https://verapdf.org) (`--flavour ua1`) if +conformance matters to you. For a document known not to conform, pass +`declarePdfUA: false` in the compile options to produce an honest +tagged-but-unclaimed PDF instead of a false claim. ### Exporter options -The `PDFExporter` constructor takes an optional `options` parameter. -While conversion happens on the client-side, the default setup uses two server based resources: +The `PDFExporter` constructor takes an optional third `options` parameter: ```typescript const defaultOptions = { - // emoji source, this is passed to the react-pdf library (https://react-pdf.org/fonts#registeremojisource) - // these are loaded from cloudflare + twemoji by default - emojiSource: { - format: "png", - url: "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/", - }, - // a function to resolve external resources in order to avoid CORS issues - // by default, this calls a BlockNote hosted server-side proxy to resolve files + // a function to resolve external resources (e.g. images) in order to avoid + // CORS issues; by default, this calls a BlockNote hosted server-side proxy resolveFileUrl: corsProxyResolveFileUrl, // the strings rendered into the exported document (file link texts, error // placeholders); pass a locale from @blocknote/core/locales (or your // editor's dictionary) to export in another language dictionary: locales.en, - // the colors to use in the PDF for things like highlighting, background colors and font colors. + // the colors used for highlighting, background colors and font colors colors: COLORS_DEFAULT, // defaults from @blocknote/core + // font families, see "Fonts & offline use" above + fontFamily: "Inter 18pt", + monoFontFamily: "Geist Mono", + // base font size in points + fontSize: 12, }; ``` + +### Exporting Typst markup + +The underlying Typst source export is available standalone (e.g. to compile +with your own Typst toolchain, including server-side) — see +[Typst export](/docs/features/export/typst). + +### Deprecated: the react-pdf exporter + +Previous versions of `@blocknote/xl-pdf-exporter` exported PDFs with +[react-pdf](https://react-pdf.org), producing untagged (not accessible) +documents. That exporter is deprecated and will be removed after a few +releases; until then it remains available unchanged from the +`@blocknote/xl-pdf-exporter/react-pdf` subpath: + +```typescript +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter/react-pdf"; +``` + +Note that its mappings are react-pdf mappings — when migrating to the new +exporter, custom blocks need a [Typst mapping](#custom-mappings--custom-schemas) +instead. diff --git a/docs/content/docs/features/export/typst.mdx b/docs/content/docs/features/export/typst.mdx new file mode 100644 index 0000000000..b498e25194 --- /dev/null +++ b/docs/content/docs/features/export/typst.mdx @@ -0,0 +1,65 @@ +--- +title: Typst +description: Export BlockNote documents to Typst markup. +imageTitle: Typst Export +path: /docs/export-to-typst +--- + +# Typst Export + +It's possible to export BlockNote documents to [Typst](https://typst.app) +markup — for example to post-process documents in a Typst workflow, or to +compile them to PDF with your own Typst toolchain (including server-side). +This exporter is also the foundation of the +[PDF exporter](/docs/features/export/pdf), which compiles its output to +accessible, tagged PDF/UA-1 in the browser. + + + This feature is provided by the `@blocknote/xl-typst-exporter`. `xl-` + packages are fully open source, but released under a copyleft license. A + commercial license for usage in closed source, proprietary products comes as + part of the [Business subscription](/pricing). + + +First, install the `@blocknote/xl-typst-exporter` package: + +```bash +npm install @blocknote/xl-typst-exporter +``` + +Then, create an instance of the `TypstExporter` class: + +```typescript +import { + TypstExporter, + typstDefaultSchemaMappings, +} from "@blocknote/xl-typst-exporter"; + +// Create the exporter +const exporter = new TypstExporter(editor.schema, typstDefaultSchemaMappings); + +// Convert the blocks to a Typst source string +const typst = await exporter.toTypst(editor.document, { + title: "My document", + lang: "en", +}); + +// The document's images, keyed by the virtual path referenced in the markup - +// map these into your Typst compiler's filesystem before compiling +const assets = exporter.assetFiles; +``` + +`toTypst` takes the same per-document options as the PDF exporter's export +methods (title, author, language, paper size, margins, page header/footer) — +see [customizing the PDF](/docs/features/export/pdf#customizing-the-pdf) — +and the constructor takes the same +[exporter options](/docs/features/export/pdf#exporter-options). + +### Custom mappings + +Mappings convert schema elements into Typst markup strings, and they are +shared with the PDF exporter — one custom-block mapping serves both formats. +See [custom mappings](/docs/features/export/pdf#custom-mappings--custom-schemas) +on the PDF page; the [math](/docs/features/blocks/math#pdf) and +[diagram](/docs/features/blocks/diagrams) blocks ship ready-made Typst +mappings from their `typst-exporter` subpaths. diff --git a/docs/content/docs/foundations/supported-formats.mdx b/docs/content/docs/foundations/supported-formats.mdx index c5e302e19a..0124782e9e 100644 --- a/docs/content/docs/foundations/supported-formats.mdx +++ b/docs/content/docs/foundations/supported-formats.mdx @@ -29,6 +29,7 @@ See the table below for a summary of the formats we support and their lossiness: | **[DOCX](/docs/features/export/docx)** (`@blocknote/xl-docx-exporter`) | ❌ | ✅ | ✅ | | **[ODT](/docs/features/export/odt)** (`@blocknote/xl-odt-exporter`) | ❌ | ✅ | ✅ | | **[Email](/docs/features/export/email)** (`@blocknote/xl-email-exporter`) | ❌ | ✅ | ✅ | +| **[Typst](/docs/features/export/typst)** (`@blocknote/xl-typst-exporter`) | ❌ | ✅ | ✅ | **Tip:** It's recommended to use **BlockNote JSON (`editor.document`)** for @@ -248,3 +249,5 @@ BlockNote can also export to these additional formats: - Via the [`@blocknote/xl-odt-exporter` package](/docs/features/export/odt) - Email - Via the [`@blocknote/xl-email-exporter` package](/docs/features/export/email) +- Typst + - Via the [`@blocknote/xl-typst-exporter` package](/docs/features/export/typst) diff --git a/docs/next.config.ts b/docs/next.config.ts index fafa53592a..02981ea446 100644 --- a/docs/next.config.ts +++ b/docs/next.config.ts @@ -17,6 +17,24 @@ const config = { useTypeScriptCli: true, }, redirects, + // `next build` runs Turbopack; the demo examples are written for Vite, so + // give their `?url` asset imports (fonts, the Typst compiler wasm) the + // same URL-string semantics here, and stub the compiler's optional + // canvas-renderer peer (the pdf-ua demo only compiles, never renders to + // canvas, so the peer isn't installed). + turbopack: { + resolveAlias: { + "@myriaddreamin/typst-ts-renderer": "./components/typstRendererStub.ts", + // Both key forms on purpose: Turbopack matches the alias key against + // the request with its query in some resolution paths and without it + // in others, so covering `/wasm` and `/wasm?url` makes the demo's + // `?url` import resolve in both. + "@myriaddreamin/typst-ts-web-compiler/wasm": + "./components/typstCompilerWasmUrl.ts", + "@myriaddreamin/typst-ts-web-compiler/wasm?url": + "./components/typstCompilerWasmUrl.ts", + }, + }, images: { remotePatterns: [ { diff --git a/docs/package.json b/docs/package.json index a2771cd1cf..b3d3cd7313 100644 --- a/docs/package.json +++ b/docs/package.json @@ -47,6 +47,8 @@ "@marsidev/react-turnstile": "^1.4.2", "@mui/icons-material": "^5.16.1", "@mui/material": "^5.16.1", + "@myriaddreamin/typst-ts-web-compiler": "^0.8.0-rc3", + "@myriaddreamin/typst.ts": "^0.8.0-rc3", "@orama/orama": "^3.1.18", "@polar-sh/better-auth": "^1.6.4", "@polar-sh/sdk": "^0.42.2", @@ -112,7 +114,8 @@ "y-partykit": "^0.0.25", "y-websocket": "^2.1.0", "yjs": "^13.6.27", - "zod": "^4.3.5" + "zod": "^4.3.5", + "@blocknote/xl-typst-exporter": "workspace:*" }, "devDependencies": { "@blocknote/code-block": "workspace:*", @@ -126,7 +129,6 @@ "@blocknote/xl-email-exporter": "workspace:*", "@blocknote/xl-multi-column": "workspace:*", "@blocknote/xl-odt-exporter": "workspace:*", - "@blocknote/xl-pdf-exporter": "workspace:*", "@tailwindcss/postcss": "^4.1.18", "@types/better-sqlite3": "^7.6.13", "@types/mdx": "^2.0.13", diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json b/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json index 553ec97d2c..abf12f3d8e 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json @@ -3,6 +3,7 @@ "docs": true, "author": "yousefed", "tags": ["Interoperability"], + "sharedTestDocument": true, "dependencies": { "@blocknote/diagram-block": "latest", "@blocknote/math-block": "latest", diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/README.md b/examples/05-interoperability/05-converting-blocks-to-pdf/README.md index 38286e00ef..c147d90bb4 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/README.md +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/README.md @@ -1,5 +1,11 @@ -# Exporting documents to PDF +# Exporting documents to PDF (react-pdf, deprecated) -This example exports the current document (all blocks) as an PDF file and downloads it to your computer. +> **Deprecated:** this example uses the react-pdf based exporter +> (`@blocknote/xl-pdf-exporter/react-pdf`), which is deprecated and will be +> removed after a few releases. Use the Typst-based `PDFExporter` instead - +> see the "Exporting documents to tagged PDF (PDF/UA)" example - which +> produces accessible, tagged PDF/UA-1 output. + +This example exports the current document (all blocks) as a PDF file and downloads it to your computer. **Try it out:** Edit the document and click "Download .pdf" at the top to download the PDF file. diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/index.html b/examples/05-interoperability/05-converting-blocks-to-pdf/index.html index 3db92f7699..4d59fc32bf 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/index.html +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/index.html @@ -2,7 +2,7 @@ - Exporting documents to PDF + Exporting documents to PDF (react-pdf, deprecated) diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx b/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx index 956e045e8f..c60aa61749 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx @@ -1,3 +1,4 @@ +import { testDocumentBlocks } from "./testDocumentBlocks"; import { BlockNoteSchema, combineByGroup, @@ -28,7 +29,7 @@ import { import { PDFExporter, pdfDefaultSchemaMappings, -} from "@blocknote/xl-pdf-exporter"; +} from "@blocknote/xl-pdf-exporter/react-pdf"; import { diagramBlockMapping } from "@blocknote/diagram-block/pdf-exporter"; import { inlineMathMapping, @@ -72,283 +73,10 @@ export default function App() { }, // Sets initial editor content. initialContent: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Welcome to this ", - styles: { - italic: true, - }, - }, - { - type: "text", - text: "demo!", - styles: { - italic: true, - bold: true, - }, - }, - ], - children: [ - { - type: "paragraph", - content: "Hello World nested", - children: [ - { - type: "paragraph", - content: "Hello World double nested", - }, - ], - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This paragraph has a background color", - styles: { bold: true }, - }, - ], - props: { - backgroundColor: "red", - }, - }, - { type: "divider" }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This one too, but it's blue", - styles: { italic: true }, - }, - ], - props: { - backgroundColor: "blue", - }, - }, - { - type: "paragraph", - content: "Paragraph", - }, - { - type: "heading", - content: "Heading", - }, - { - type: "heading", - content: "Heading right", - props: { - textAlignment: "right", - }, - }, - { - type: "paragraph", - content: - "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - - props: { - textAlignment: "justify", - }, - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - children: [ - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - props: { - textAlignment: "right", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item 2", - children: [ - { - type: "numberedListItem", - content: "Numbered List Item Nested 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested 2", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky right", - props: { - textAlignment: "right", - backgroundColor: "red", - textColor: "blue", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky center", - props: { - textAlignment: "center", - backgroundColor: "red", - textColor: "blue", - }, - }, - ], - }, - ], - }, - { - type: "numberedListItem", - content: "Numbered List Item", - }, - { - type: "checkListItem", - content: "Check List Item", - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - ], - }, - }, - { - type: "pageBreak", - }, - { - type: "file", - }, - { - type: "image", - props: { - url: "https://placehold.co/332x322.jpg", - caption: "From https://placehold.co/332x322.jpg", - }, - }, - { - type: "image", - props: { - previewWidth: 200, - url: "https://placehold.co/332x322.jpg", - textAlignment: "right", - }, - }, - { - type: "video", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - }, - }, - { - type: "audio", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - }, - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Inline Content:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Styled Text", - styles: { - bold: true, - italic: true, - textColor: "red", - backgroundColor: "blue", - }, - }, - { - type: "text", - text: " ", - styles: {}, - }, - { - type: "link", - content: "Link", - href: "https://www.blocknotejs.org", - }, - ], - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell 1", "Table Cell 2", "Table Cell 3"], - }, - { - cells: [ - "Table Cell 4", - [ - { - type: "text", - text: "Table Cell Bold 5", - styles: { - bold: true, - }, - }, - ], - "Table Cell 6", - ], - }, - { - cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], - }, - ], - }, - }, - { - type: "codeBlock", - props: { - language: "javascript", - }, - content: `const helloWorld = (message) => { - console.log("Hello World", message); -};`, - }, + ...testDocumentBlocks, + // The math & diagram blocks aren't part of the shared test document, + // since the exporter unit tests' schemas don't register them, so they're + // appended here instead. { type: "mathBlock", content: "a^2 = \\sqrt{b^2 + c^2}", @@ -374,59 +102,6 @@ export default function App() { }, ], }, - { - type: "columnList", - children: [ - { - type: "column", - props: { - width: 0.8, - }, - children: [ - { - type: "paragraph", - content: "This paragraph is in a column!", - }, - ], - }, - { - type: "column", - props: { - width: 1.4, - }, - children: [ - { - type: "heading", - content: "So is this heading!", - }, - ], - }, - { - type: "column", - props: { - width: 0.8, - }, - children: [ - { - type: "paragraph", - content: "You can have multiple blocks in a column too", - }, - { - type: "bulletListItem", - content: "Block 1", - }, - { - type: "bulletListItem", - content: "Block 2", - }, - { - type: "bulletListItem", - content: "Block 3", - }, - ], - }, - ], - }, ], }); diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/src/testDocumentBlocks.ts b/examples/05-interoperability/05-converting-blocks-to-pdf/src/testDocumentBlocks.ts new file mode 100644 index 0000000000..7b629ffc11 --- /dev/null +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/src/testDocumentBlocks.ts @@ -0,0 +1,494 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY. +// Generated from shared/testDocumentBlocks.ts — run `pnpm run gen` to update. +import type { PartialBlock } from "@blocknote/core"; + +/** + * The shared example/test document, as partial blocks. + * + * This file is intentionally self-contained — it has only a type-only import + * from `@blocknote/core` (no runtime imports) — so the example generator can + * copy it verbatim into each exporter playground example and the examples stay + * runnable on their own (e.g. opened directly in StackBlitz). + * + * It is the single source of truth for both the playground examples' editor + * `initialContent` and the exporters' `testDocument` unit-test fixture. + */ +export const testDocumentBlocks: PartialBlock[] = [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Welcome to this ", + styles: { + italic: true, + }, + }, + { + type: "text", + text: "demo 🙌!", + styles: { + italic: true, + bold: true, + }, + }, + ], + children: [ + { + type: "paragraph", + content: "Hello World nested", + children: [ + { + type: "paragraph", + content: "Hello World double nested", + }, + ], + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "This paragraph has a background color", + styles: { bold: true }, + }, + ], + props: { + backgroundColor: "red", + }, + }, + { + type: "paragraph", + content: "Paragraph", + }, + // An empty paragraph: a blank line in the editor, which exporters must + // preserve as vertical space rather than dropping the block. + { + type: "paragraph", + }, + { + type: "heading", + content: "Heading", + }, + { + type: "heading", + content: "Heading right", + props: { + textAlignment: "right", + }, + }, + { + type: "heading", + content: "Heading 2", + props: { level: 2 }, + }, + { + type: "heading", + content: "Heading 3", + props: { level: 3 }, + }, + { + type: "heading", + content: "Heading 4", + props: { level: 4 }, + }, + { + type: "heading", + content: "Heading 5", + props: { level: 5 }, + }, + { + type: "heading", + content: "Heading 6", + props: { level: 6 }, + }, + { + type: "paragraph", + content: "Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️", + }, + { + type: "paragraph", + content: "Centered paragraph", + props: { + textAlignment: "center", + }, + }, + { + type: "paragraph", + content: + "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + + props: { + textAlignment: "justify", + }, + }, + { type: "pageBreak" }, + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + children: [ + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + }, + { + type: "bulletListItem", + content: + "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + props: { + textAlignment: "right", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item 2", + children: [ + { + type: "numberedListItem", + content: "Numbered List Item Nested 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested 2", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky right", + props: { + textAlignment: "right", + backgroundColor: "red", + textColor: "blue", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky center", + props: { + textAlignment: "center", + backgroundColor: "red", + textColor: "blue", + }, + }, + ], + }, + ], + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "checkListItem", + content: "Checked List Item", + props: { + checked: true, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item starting at 5", + props: { + start: 5, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 6", + }, + { + type: "toggleListItem", + content: "Toggle List Item", + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle list item.", + }, + { + type: "bulletListItem", + content: "A nested bullet inside the toggle", + }, + ], + }, + { + type: "heading", + content: "Toggle Heading", + props: { + level: 2, + isToggleable: true, + }, + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle heading.", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + columnWidths: [200, undefined, undefined], + rows: [ + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://placehold.co/332x322.jpg", + caption: "From https://placehold.co/332x322.jpg", + }, + }, + { + type: "image", + props: { + previewWidth: 200, + url: "https://placehold.co/332x322.jpg", + textAlignment: "right", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + }, + }, + { + type: "paragraph", + }, + { + type: "audio", + props: { + caption: "Audio file caption", + name: "audio.mp3", + }, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline Content:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "underlined", + styles: { underline: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "strikethrough", + styles: { strike: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { + cells: ["Table Header 1", "Table Header 2", "Table Header 3"], + }, + { + cells: [ + "Table Cell 4", + [ + { + type: "text", + text: "Table Cell Bold Colored 5", + styles: { + bold: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + ], + "Table Cell 6", + ], + }, + { + cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], + }, + ], + }, + }, + // An "advanced" table: two header rows and merged cells (colspan / + // rowspan) - the features behind the editor's `splitCells` / + // `headers` table options, which exporters must place correctly. + { + type: "table", + content: { + type: "tableContent", + // Explicit widths for all three tracks: the merged first-row cell + // means the row alone doesn't reveal the column count. + columnWidths: [undefined, undefined, undefined], + headerRows: 2, + rows: [ + { + cells: [ + { + type: "tableCell", + content: "Merged Header", + props: { colspan: 2 }, + }, + { type: "tableCell", content: "Header C" }, + ], + }, + { + cells: ["Header A", "Header B", "Header C2"], + }, + { + cells: [ + { + type: "tableCell", + content: "Merged Rows", + props: { rowspan: 2 }, + }, + { type: "tableCell", content: "Cell B1" }, + { type: "tableCell", content: "Cell C1" }, + ], + }, + { + cells: ["Cell B2", "Cell C2"], + }, + ], + }, + }, + // A hard line break (shift+enter) inside one paragraph - a single block + // whose text spans two lines. + { + type: "paragraph", + content: "A hard line break\nwithin a single paragraph", + }, + { + type: "codeBlock", + props: { + language: "javascript", + }, + content: `const helloWorld = (message) => { + console.log("Hello World", message); +};`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Some inline code: ", + styles: { bold: true }, + }, + { + type: "text", + text: "var foo = 'bar';", + styles: { code: true }, + }, + ], + }, + { + type: "columnList", + children: [ + { + type: "column", + props: { width: 0.8 }, + children: [ + { type: "paragraph", content: "This paragraph is in a column!" }, + ], + }, + { + type: "column", + props: { width: 1.4 }, + children: [{ type: "heading", content: "So is this heading!" }], + }, + { + type: "column", + props: { width: 0.8 }, + children: [ + { + type: "paragraph", + content: "You can have multiple blocks in a column too", + }, + { type: "bulletListItem", content: "Block 1" }, + { type: "bulletListItem", content: "Block 2" }, + { type: "bulletListItem", content: "Block 3" }, + ], + }, + ], + }, + { type: "divider" }, + { + type: "quote", + content: "All those moments will be lost in time, like tears in rain.", + }, +]; diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json b/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json index f370c604f4..858383aeb4 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json +++ b/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json @@ -3,6 +3,7 @@ "docs": true, "author": "yousefed", "tags": [""], + "sharedTestDocument": true, "dependencies": { "@blocknote/diagram-block": "latest", "@blocknote/math-block": "latest", diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx b/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx index 8907bf760f..3a047c97af 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx +++ b/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx @@ -1,3 +1,4 @@ +import { testDocumentBlocks } from "./testDocumentBlocks"; import { BlockNoteSchema, combineByGroup, @@ -66,283 +67,10 @@ export default function App() { }, // Sets initial editor content. initialContent: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Welcome to this ", - styles: { - italic: true, - }, - }, - { - type: "text", - text: "demo!", - styles: { - italic: true, - bold: true, - }, - }, - ], - children: [ - { - type: "paragraph", - content: "Hello World nested", - children: [ - { - type: "paragraph", - content: "Hello World double nested", - }, - ], - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This paragraph has a background color", - styles: { bold: true }, - }, - ], - props: { - backgroundColor: "red", - }, - }, - { type: "divider" }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This one too, but it's blue", - styles: { italic: true }, - }, - ], - props: { - backgroundColor: "blue", - }, - }, - { - type: "paragraph", - content: "Paragraph", - }, - { - type: "heading", - content: "Heading", - }, - { - type: "heading", - content: "Heading right", - props: { - textAlignment: "right", - }, - }, - { - type: "paragraph", - content: - "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - - props: { - textAlignment: "justify", - }, - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - children: [ - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - props: { - textAlignment: "right", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item 2", - children: [ - { - type: "numberedListItem", - content: "Numbered List Item Nested 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested 2", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky right", - props: { - textAlignment: "right", - backgroundColor: "red", - textColor: "blue", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky center", - props: { - textAlignment: "center", - backgroundColor: "red", - textColor: "blue", - }, - }, - ], - }, - ], - }, - { - type: "numberedListItem", - content: "Numbered List Item", - }, - { - type: "checkListItem", - content: "Check List Item", - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - ], - }, - }, - { - type: "pageBreak", - }, - { - type: "file", - }, - { - type: "image", - props: { - url: "https://placehold.co/332x322.jpg", - caption: "From https://placehold.co/332x322.jpg", - }, - }, - { - type: "image", - props: { - previewWidth: 200, - url: "https://placehold.co/332x322.jpg", - textAlignment: "right", - }, - }, - { - type: "video", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - }, - }, - { - type: "audio", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - }, - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Inline Content:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Styled Text", - styles: { - bold: true, - italic: true, - textColor: "red", - backgroundColor: "blue", - }, - }, - { - type: "text", - text: " ", - styles: {}, - }, - { - type: "link", - content: "Link", - href: "https://www.blocknotejs.org", - }, - ], - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell 1", "Table Cell 2", "Table Cell 3"], - }, - { - cells: [ - "Table Cell 4", - [ - { - type: "text", - text: "Table Cell Bold 5", - styles: { - bold: true, - }, - }, - ], - "Table Cell 6", - ], - }, - { - cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], - }, - ], - }, - }, - { - type: "codeBlock", - props: { - language: "javascript", - }, - content: `const helloWorld = (message) => { - console.log("Hello World", message); -};`, - }, + ...testDocumentBlocks, + // The math & diagram blocks aren't part of the shared test document, + // since the exporter unit tests' schemas don't register them, so they're + // appended here instead. { type: "mathBlock", content: "a^2 = \\sqrt{b^2 + c^2}", @@ -368,60 +96,6 @@ export default function App() { }, ], }, - - { - type: "columnList", - children: [ - { - type: "column", - props: { - width: 0.8, - }, - children: [ - { - type: "paragraph", - content: "This paragraph is in a column!", - }, - ], - }, - { - type: "column", - props: { - width: 1.4, - }, - children: [ - { - type: "heading", - content: "So is this heading!", - }, - ], - }, - { - type: "column", - props: { - width: 0.8, - }, - children: [ - { - type: "paragraph", - content: "You can have multiple blocks in a column too", - }, - { - type: "bulletListItem", - content: "Block 1", - }, - { - type: "bulletListItem", - content: "Block 2", - }, - { - type: "bulletListItem", - content: "Block 3", - }, - ], - }, - ], - }, ], }); diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/src/testDocumentBlocks.ts b/examples/05-interoperability/06-converting-blocks-to-docx/src/testDocumentBlocks.ts new file mode 100644 index 0000000000..7b629ffc11 --- /dev/null +++ b/examples/05-interoperability/06-converting-blocks-to-docx/src/testDocumentBlocks.ts @@ -0,0 +1,494 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY. +// Generated from shared/testDocumentBlocks.ts — run `pnpm run gen` to update. +import type { PartialBlock } from "@blocknote/core"; + +/** + * The shared example/test document, as partial blocks. + * + * This file is intentionally self-contained — it has only a type-only import + * from `@blocknote/core` (no runtime imports) — so the example generator can + * copy it verbatim into each exporter playground example and the examples stay + * runnable on their own (e.g. opened directly in StackBlitz). + * + * It is the single source of truth for both the playground examples' editor + * `initialContent` and the exporters' `testDocument` unit-test fixture. + */ +export const testDocumentBlocks: PartialBlock[] = [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Welcome to this ", + styles: { + italic: true, + }, + }, + { + type: "text", + text: "demo 🙌!", + styles: { + italic: true, + bold: true, + }, + }, + ], + children: [ + { + type: "paragraph", + content: "Hello World nested", + children: [ + { + type: "paragraph", + content: "Hello World double nested", + }, + ], + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "This paragraph has a background color", + styles: { bold: true }, + }, + ], + props: { + backgroundColor: "red", + }, + }, + { + type: "paragraph", + content: "Paragraph", + }, + // An empty paragraph: a blank line in the editor, which exporters must + // preserve as vertical space rather than dropping the block. + { + type: "paragraph", + }, + { + type: "heading", + content: "Heading", + }, + { + type: "heading", + content: "Heading right", + props: { + textAlignment: "right", + }, + }, + { + type: "heading", + content: "Heading 2", + props: { level: 2 }, + }, + { + type: "heading", + content: "Heading 3", + props: { level: 3 }, + }, + { + type: "heading", + content: "Heading 4", + props: { level: 4 }, + }, + { + type: "heading", + content: "Heading 5", + props: { level: 5 }, + }, + { + type: "heading", + content: "Heading 6", + props: { level: 6 }, + }, + { + type: "paragraph", + content: "Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️", + }, + { + type: "paragraph", + content: "Centered paragraph", + props: { + textAlignment: "center", + }, + }, + { + type: "paragraph", + content: + "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + + props: { + textAlignment: "justify", + }, + }, + { type: "pageBreak" }, + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + children: [ + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + }, + { + type: "bulletListItem", + content: + "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + props: { + textAlignment: "right", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item 2", + children: [ + { + type: "numberedListItem", + content: "Numbered List Item Nested 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested 2", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky right", + props: { + textAlignment: "right", + backgroundColor: "red", + textColor: "blue", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky center", + props: { + textAlignment: "center", + backgroundColor: "red", + textColor: "blue", + }, + }, + ], + }, + ], + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "checkListItem", + content: "Checked List Item", + props: { + checked: true, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item starting at 5", + props: { + start: 5, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 6", + }, + { + type: "toggleListItem", + content: "Toggle List Item", + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle list item.", + }, + { + type: "bulletListItem", + content: "A nested bullet inside the toggle", + }, + ], + }, + { + type: "heading", + content: "Toggle Heading", + props: { + level: 2, + isToggleable: true, + }, + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle heading.", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + columnWidths: [200, undefined, undefined], + rows: [ + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://placehold.co/332x322.jpg", + caption: "From https://placehold.co/332x322.jpg", + }, + }, + { + type: "image", + props: { + previewWidth: 200, + url: "https://placehold.co/332x322.jpg", + textAlignment: "right", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + }, + }, + { + type: "paragraph", + }, + { + type: "audio", + props: { + caption: "Audio file caption", + name: "audio.mp3", + }, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline Content:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "underlined", + styles: { underline: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "strikethrough", + styles: { strike: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { + cells: ["Table Header 1", "Table Header 2", "Table Header 3"], + }, + { + cells: [ + "Table Cell 4", + [ + { + type: "text", + text: "Table Cell Bold Colored 5", + styles: { + bold: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + ], + "Table Cell 6", + ], + }, + { + cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], + }, + ], + }, + }, + // An "advanced" table: two header rows and merged cells (colspan / + // rowspan) - the features behind the editor's `splitCells` / + // `headers` table options, which exporters must place correctly. + { + type: "table", + content: { + type: "tableContent", + // Explicit widths for all three tracks: the merged first-row cell + // means the row alone doesn't reveal the column count. + columnWidths: [undefined, undefined, undefined], + headerRows: 2, + rows: [ + { + cells: [ + { + type: "tableCell", + content: "Merged Header", + props: { colspan: 2 }, + }, + { type: "tableCell", content: "Header C" }, + ], + }, + { + cells: ["Header A", "Header B", "Header C2"], + }, + { + cells: [ + { + type: "tableCell", + content: "Merged Rows", + props: { rowspan: 2 }, + }, + { type: "tableCell", content: "Cell B1" }, + { type: "tableCell", content: "Cell C1" }, + ], + }, + { + cells: ["Cell B2", "Cell C2"], + }, + ], + }, + }, + // A hard line break (shift+enter) inside one paragraph - a single block + // whose text spans two lines. + { + type: "paragraph", + content: "A hard line break\nwithin a single paragraph", + }, + { + type: "codeBlock", + props: { + language: "javascript", + }, + content: `const helloWorld = (message) => { + console.log("Hello World", message); +};`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Some inline code: ", + styles: { bold: true }, + }, + { + type: "text", + text: "var foo = 'bar';", + styles: { code: true }, + }, + ], + }, + { + type: "columnList", + children: [ + { + type: "column", + props: { width: 0.8 }, + children: [ + { type: "paragraph", content: "This paragraph is in a column!" }, + ], + }, + { + type: "column", + props: { width: 1.4 }, + children: [{ type: "heading", content: "So is this heading!" }], + }, + { + type: "column", + props: { width: 0.8 }, + children: [ + { + type: "paragraph", + content: "You can have multiple blocks in a column too", + }, + { type: "bulletListItem", content: "Block 1" }, + { type: "bulletListItem", content: "Block 2" }, + { type: "bulletListItem", content: "Block 3" }, + ], + }, + ], + }, + { type: "divider" }, + { + type: "quote", + content: "All those moments will be lost in time, like tears in rain.", + }, +]; diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json b/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json index 3fee215859..4ecf6cdc83 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json +++ b/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json @@ -3,6 +3,7 @@ "docs": true, "author": "areknawo", "tags": [""], + "sharedTestDocument": true, "dependencies": { "@blocknote/diagram-block": "latest", "@blocknote/math-block": "latest", diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx b/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx index 518617bafd..9a59344fef 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx +++ b/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx @@ -1,3 +1,4 @@ +import { testDocumentBlocks } from "./testDocumentBlocks"; import { BlockNoteSchema, combineByGroup, @@ -66,283 +67,10 @@ export default function App() { }, // Sets initial editor content. initialContent: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Welcome to this ", - styles: { - italic: true, - }, - }, - { - type: "text", - text: "demo!", - styles: { - italic: true, - bold: true, - }, - }, - ], - children: [ - { - type: "paragraph", - content: "Hello World nested", - children: [ - { - type: "paragraph", - content: "Hello World double nested", - }, - ], - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This paragraph has a background color", - styles: { bold: true }, - }, - ], - props: { - backgroundColor: "red", - }, - }, - { type: "divider" }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This one too, but it's blue", - styles: { italic: true }, - }, - ], - props: { - backgroundColor: "blue", - }, - }, - { - type: "paragraph", - content: "Paragraph", - }, - { - type: "heading", - content: "Heading", - }, - { - type: "heading", - content: "Heading right", - props: { - textAlignment: "right", - }, - }, - { - type: "paragraph", - content: - "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - - props: { - textAlignment: "justify", - }, - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - children: [ - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - props: { - textAlignment: "right", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item 2", - children: [ - { - type: "numberedListItem", - content: "Numbered List Item Nested 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested 2", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky right", - props: { - textAlignment: "right", - backgroundColor: "red", - textColor: "blue", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky center", - props: { - textAlignment: "center", - backgroundColor: "red", - textColor: "blue", - }, - }, - ], - }, - ], - }, - { - type: "numberedListItem", - content: "Numbered List Item", - }, - { - type: "checkListItem", - content: "Check List Item", - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - ], - }, - }, - { - type: "pageBreak", - }, - { - type: "file", - }, - { - type: "image", - props: { - url: "https://placehold.co/332x322.jpg", - caption: "From https://placehold.co/332x322.jpg", - }, - }, - { - type: "image", - props: { - previewWidth: 200, - url: "https://placehold.co/332x322.jpg", - textAlignment: "right", - }, - }, - { - type: "video", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - }, - }, - { - type: "audio", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - }, - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Inline Content:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Styled Text", - styles: { - bold: true, - italic: true, - textColor: "red", - backgroundColor: "blue", - }, - }, - { - type: "text", - text: " ", - styles: {}, - }, - { - type: "link", - content: "Link", - href: "https://www.blocknotejs.org", - }, - ], - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell 1", "Table Cell 2", "Table Cell 3"], - }, - { - cells: [ - "Table Cell 4", - [ - { - type: "text", - text: "Table Cell Bold 5", - styles: { - bold: true, - }, - }, - ], - "Table Cell 6", - ], - }, - { - cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], - }, - ], - }, - }, - { - type: "codeBlock", - props: { - language: "javascript", - }, - content: `const helloWorld = (message) => { - console.log("Hello World", message); -};`, - }, + ...testDocumentBlocks, + // The math & diagram blocks aren't part of the shared test document, + // since the exporter unit tests' schemas don't register them, so they're + // appended here instead. { type: "mathBlock", content: "a^2 = \\sqrt{b^2 + c^2}", @@ -368,59 +96,6 @@ export default function App() { }, ], }, - { - type: "columnList", - children: [ - { - type: "column", - props: { - width: 0.8, - }, - children: [ - { - type: "paragraph", - content: "This paragraph is in a column!", - }, - ], - }, - { - type: "column", - props: { - width: 1.4, - }, - children: [ - { - type: "heading", - content: "So is this heading!", - }, - ], - }, - { - type: "column", - props: { - width: 0.8, - }, - children: [ - { - type: "paragraph", - content: "You can have multiple blocks in a column too", - }, - { - type: "bulletListItem", - content: "Block 1", - }, - { - type: "bulletListItem", - content: "Block 2", - }, - { - type: "bulletListItem", - content: "Block 3", - }, - ], - }, - ], - }, ], }); diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/src/testDocumentBlocks.ts b/examples/05-interoperability/07-converting-blocks-to-odt/src/testDocumentBlocks.ts new file mode 100644 index 0000000000..7b629ffc11 --- /dev/null +++ b/examples/05-interoperability/07-converting-blocks-to-odt/src/testDocumentBlocks.ts @@ -0,0 +1,494 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY. +// Generated from shared/testDocumentBlocks.ts — run `pnpm run gen` to update. +import type { PartialBlock } from "@blocknote/core"; + +/** + * The shared example/test document, as partial blocks. + * + * This file is intentionally self-contained — it has only a type-only import + * from `@blocknote/core` (no runtime imports) — so the example generator can + * copy it verbatim into each exporter playground example and the examples stay + * runnable on their own (e.g. opened directly in StackBlitz). + * + * It is the single source of truth for both the playground examples' editor + * `initialContent` and the exporters' `testDocument` unit-test fixture. + */ +export const testDocumentBlocks: PartialBlock[] = [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Welcome to this ", + styles: { + italic: true, + }, + }, + { + type: "text", + text: "demo 🙌!", + styles: { + italic: true, + bold: true, + }, + }, + ], + children: [ + { + type: "paragraph", + content: "Hello World nested", + children: [ + { + type: "paragraph", + content: "Hello World double nested", + }, + ], + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "This paragraph has a background color", + styles: { bold: true }, + }, + ], + props: { + backgroundColor: "red", + }, + }, + { + type: "paragraph", + content: "Paragraph", + }, + // An empty paragraph: a blank line in the editor, which exporters must + // preserve as vertical space rather than dropping the block. + { + type: "paragraph", + }, + { + type: "heading", + content: "Heading", + }, + { + type: "heading", + content: "Heading right", + props: { + textAlignment: "right", + }, + }, + { + type: "heading", + content: "Heading 2", + props: { level: 2 }, + }, + { + type: "heading", + content: "Heading 3", + props: { level: 3 }, + }, + { + type: "heading", + content: "Heading 4", + props: { level: 4 }, + }, + { + type: "heading", + content: "Heading 5", + props: { level: 5 }, + }, + { + type: "heading", + content: "Heading 6", + props: { level: 6 }, + }, + { + type: "paragraph", + content: "Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️", + }, + { + type: "paragraph", + content: "Centered paragraph", + props: { + textAlignment: "center", + }, + }, + { + type: "paragraph", + content: + "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + + props: { + textAlignment: "justify", + }, + }, + { type: "pageBreak" }, + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + children: [ + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + }, + { + type: "bulletListItem", + content: + "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + props: { + textAlignment: "right", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item 2", + children: [ + { + type: "numberedListItem", + content: "Numbered List Item Nested 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested 2", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky right", + props: { + textAlignment: "right", + backgroundColor: "red", + textColor: "blue", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky center", + props: { + textAlignment: "center", + backgroundColor: "red", + textColor: "blue", + }, + }, + ], + }, + ], + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "checkListItem", + content: "Checked List Item", + props: { + checked: true, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item starting at 5", + props: { + start: 5, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 6", + }, + { + type: "toggleListItem", + content: "Toggle List Item", + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle list item.", + }, + { + type: "bulletListItem", + content: "A nested bullet inside the toggle", + }, + ], + }, + { + type: "heading", + content: "Toggle Heading", + props: { + level: 2, + isToggleable: true, + }, + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle heading.", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + columnWidths: [200, undefined, undefined], + rows: [ + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://placehold.co/332x322.jpg", + caption: "From https://placehold.co/332x322.jpg", + }, + }, + { + type: "image", + props: { + previewWidth: 200, + url: "https://placehold.co/332x322.jpg", + textAlignment: "right", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + }, + }, + { + type: "paragraph", + }, + { + type: "audio", + props: { + caption: "Audio file caption", + name: "audio.mp3", + }, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline Content:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "underlined", + styles: { underline: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "strikethrough", + styles: { strike: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { + cells: ["Table Header 1", "Table Header 2", "Table Header 3"], + }, + { + cells: [ + "Table Cell 4", + [ + { + type: "text", + text: "Table Cell Bold Colored 5", + styles: { + bold: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + ], + "Table Cell 6", + ], + }, + { + cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], + }, + ], + }, + }, + // An "advanced" table: two header rows and merged cells (colspan / + // rowspan) - the features behind the editor's `splitCells` / + // `headers` table options, which exporters must place correctly. + { + type: "table", + content: { + type: "tableContent", + // Explicit widths for all three tracks: the merged first-row cell + // means the row alone doesn't reveal the column count. + columnWidths: [undefined, undefined, undefined], + headerRows: 2, + rows: [ + { + cells: [ + { + type: "tableCell", + content: "Merged Header", + props: { colspan: 2 }, + }, + { type: "tableCell", content: "Header C" }, + ], + }, + { + cells: ["Header A", "Header B", "Header C2"], + }, + { + cells: [ + { + type: "tableCell", + content: "Merged Rows", + props: { rowspan: 2 }, + }, + { type: "tableCell", content: "Cell B1" }, + { type: "tableCell", content: "Cell C1" }, + ], + }, + { + cells: ["Cell B2", "Cell C2"], + }, + ], + }, + }, + // A hard line break (shift+enter) inside one paragraph - a single block + // whose text spans two lines. + { + type: "paragraph", + content: "A hard line break\nwithin a single paragraph", + }, + { + type: "codeBlock", + props: { + language: "javascript", + }, + content: `const helloWorld = (message) => { + console.log("Hello World", message); +};`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Some inline code: ", + styles: { bold: true }, + }, + { + type: "text", + text: "var foo = 'bar';", + styles: { code: true }, + }, + ], + }, + { + type: "columnList", + children: [ + { + type: "column", + props: { width: 0.8 }, + children: [ + { type: "paragraph", content: "This paragraph is in a column!" }, + ], + }, + { + type: "column", + props: { width: 1.4 }, + children: [{ type: "heading", content: "So is this heading!" }], + }, + { + type: "column", + props: { width: 0.8 }, + children: [ + { + type: "paragraph", + content: "You can have multiple blocks in a column too", + }, + { type: "bulletListItem", content: "Block 1" }, + { type: "bulletListItem", content: "Block 2" }, + { type: "bulletListItem", content: "Block 3" }, + ], + }, + ], + }, + { type: "divider" }, + { + type: "quote", + content: "All those moments will be lost in time, like tears in rain.", + }, +]; diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json b/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json index 12f951eec2..2f7d003804 100644 --- a/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json @@ -3,10 +3,12 @@ "docs": true, "author": "jmarbutt", "tags": [""], + "sharedTestDocument": true, "dependencies": { "@blocknote/diagram-block": "latest", "@blocknote/math-block": "latest", "@blocknote/xl-email-exporter": "latest", + "@blocknote/xl-multi-column": "latest", "@react-email/render": "^2.0.4" }, "pro": true diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/package.json b/examples/05-interoperability/08-converting-blocks-to-react-email/package.json index c69fbbbcc0..c0b4efe821 100644 --- a/examples/05-interoperability/08-converting-blocks-to-react-email/package.json +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/package.json @@ -23,6 +23,7 @@ "@blocknote/diagram-block": "latest", "@blocknote/math-block": "latest", "@blocknote/xl-email-exporter": "latest", + "@blocknote/xl-multi-column": "latest", "@react-email/render": "^2.0.4" }, "devDependencies": { diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx b/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx index 86640c049b..26f9925453 100644 --- a/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx @@ -1,3 +1,4 @@ +import { testDocumentBlocks } from "./testDocumentBlocks"; import { BlockNoteSchema, COLORS_DARK_MODE_DEFAULT, @@ -31,6 +32,13 @@ import { ReactEmailExporter, reactEmailDefaultSchemaMappings, } from "@blocknote/xl-email-exporter"; +import { + getMultiColumnSlashMenuItems, + locales as multiColumnLocales, + multiColumnDropCursor, + withMultiColumn, +} from "@blocknote/xl-multi-column"; +import * as locales from "@blocknote/core/locales"; import { useEffect, useMemo, useState } from "react"; import "./styles.css"; @@ -42,9 +50,9 @@ export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ - // Adds support for page breaks. + // Adds support for page breaks & multi-column blocks. // Adds support for math & diagram blocks. - schema: withPageBreak(BlockNoteSchema.create()).extend({ + schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({ blockSpecs: { mathBlock: createReactMathBlockSpec(), diagram: createReactDiagramBlockSpec(), @@ -53,6 +61,11 @@ export default function App() { math: createReactInlineMathSpec(), }, }), + dropCursor: multiColumnDropCursor, + dictionary: { + ...locales.en, + multi_column: multiColumnLocales.en, + }, // Adds support for advanced table features. tables: { splitCells: true, @@ -62,287 +75,21 @@ export default function App() { }, // Sets initial editor content. initialContent: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Welcome to this ", - styles: { - italic: true, - }, - }, - { - type: "text", - text: "demo!", - styles: { - italic: true, - bold: true, - }, - }, - ], - children: [ - { - type: "paragraph", - content: "Hello World nested", - children: [ - { - type: "paragraph", - content: "Hello World double nested", - }, - ], - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This paragraph has a background color", - styles: { bold: true }, - }, - ], - props: { - backgroundColor: "red", - }, - }, - { type: "divider" }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This one too, but it's blue", - styles: { italic: true }, - }, - ], - props: { - backgroundColor: "blue", - }, - }, - { - type: "paragraph", - content: "Paragraph", - }, - { - type: "heading", - content: "Heading", - }, - { - type: "heading", - content: "Heading right", - props: { - textAlignment: "right", - }, - }, - { - type: "paragraph", - content: - "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - - props: { - textAlignment: "justify", - }, - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - children: [ - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - }, - { - type: "bulletListItem", - content: - "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - props: { - textAlignment: "right", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item 2", - children: [ - { - type: "numberedListItem", - content: "Numbered List Item Nested 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested 2", - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky right", - props: { - textAlignment: "right", - backgroundColor: "red", - textColor: "blue", - }, - }, - { - type: "numberedListItem", - content: "Numbered List Item Nested funky center", - props: { - textAlignment: "center", - backgroundColor: "red", - textColor: "blue", - }, - }, - ], - }, - ], - }, - { - type: "numberedListItem", - content: "Numbered List Item", - }, - { - type: "checkListItem", - content: "Check List Item", - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - ], - }, - }, - { - type: "pageBreak", - }, - { - type: "file", - }, - { - type: "image", - props: { - url: "https://placehold.co/332x322.jpg", - caption: "From https://placehold.co/332x322.jpg", - }, - }, - { - type: "image", - props: { - previewWidth: 200, - url: "https://placehold.co/332x322.jpg", - textAlignment: "right", - }, - }, - { - type: "video", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - }, - }, - { - type: "audio", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - }, - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Inline Content:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Styled Text", - styles: { - bold: true, - italic: true, - textColor: "red", - backgroundColor: "blue", - }, - }, - { - type: "text", - text: " ", - styles: {}, - }, - { - type: "link", - content: "Link", - href: "https://www.blocknotejs.org", - }, - ], - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell 1", "Table Cell 2", "Table Cell 3"], - }, - { - cells: [ - "Table Cell 4", - [ - { - type: "text", - text: "Table Cell Bold 5", - styles: { - bold: true, - }, - }, - ], - "Table Cell 6", - ], - }, - { - cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], - }, - ], - }, - }, - { - type: "codeBlock", - props: { - language: "javascript", - }, - content: `const helloWorld = (message) => { - console.log("Hello World", message); -};`, - }, + ...testDocumentBlocks, + // The math & diagram blocks aren't part of the shared test document, + // since the exporter unit tests' schemas don't register them, so they're + // appended here instead. { type: "mathBlock", content: "a^2 = \\sqrt{b^2 + c^2}", }, + { + type: "diagram", + content: `graph TD + A[Start] --> B{Works?} + B -->|Yes| C[Ship it] + B -->|No| A`, + }, { type: "paragraph", content: [ @@ -357,13 +104,6 @@ export default function App() { }, ], }, - { - type: "diagram", - content: `graph TD - A[Start] --> B{Works?} - B -->|Yes| C[Ship it] - B -->|No| A`, - }, ], }); @@ -374,6 +114,7 @@ export default function App() { combineByGroup( getDefaultReactSlashMenuItems(editor), getPageBreakReactSlashMenuItems(editor), + getMultiColumnSlashMenuItems(editor), ), query, ), diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/src/testDocumentBlocks.ts b/examples/05-interoperability/08-converting-blocks-to-react-email/src/testDocumentBlocks.ts new file mode 100644 index 0000000000..7b629ffc11 --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/src/testDocumentBlocks.ts @@ -0,0 +1,494 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY. +// Generated from shared/testDocumentBlocks.ts — run `pnpm run gen` to update. +import type { PartialBlock } from "@blocknote/core"; + +/** + * The shared example/test document, as partial blocks. + * + * This file is intentionally self-contained — it has only a type-only import + * from `@blocknote/core` (no runtime imports) — so the example generator can + * copy it verbatim into each exporter playground example and the examples stay + * runnable on their own (e.g. opened directly in StackBlitz). + * + * It is the single source of truth for both the playground examples' editor + * `initialContent` and the exporters' `testDocument` unit-test fixture. + */ +export const testDocumentBlocks: PartialBlock[] = [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Welcome to this ", + styles: { + italic: true, + }, + }, + { + type: "text", + text: "demo 🙌!", + styles: { + italic: true, + bold: true, + }, + }, + ], + children: [ + { + type: "paragraph", + content: "Hello World nested", + children: [ + { + type: "paragraph", + content: "Hello World double nested", + }, + ], + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "This paragraph has a background color", + styles: { bold: true }, + }, + ], + props: { + backgroundColor: "red", + }, + }, + { + type: "paragraph", + content: "Paragraph", + }, + // An empty paragraph: a blank line in the editor, which exporters must + // preserve as vertical space rather than dropping the block. + { + type: "paragraph", + }, + { + type: "heading", + content: "Heading", + }, + { + type: "heading", + content: "Heading right", + props: { + textAlignment: "right", + }, + }, + { + type: "heading", + content: "Heading 2", + props: { level: 2 }, + }, + { + type: "heading", + content: "Heading 3", + props: { level: 3 }, + }, + { + type: "heading", + content: "Heading 4", + props: { level: 4 }, + }, + { + type: "heading", + content: "Heading 5", + props: { level: 5 }, + }, + { + type: "heading", + content: "Heading 6", + props: { level: 6 }, + }, + { + type: "paragraph", + content: "Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️", + }, + { + type: "paragraph", + content: "Centered paragraph", + props: { + textAlignment: "center", + }, + }, + { + type: "paragraph", + content: + "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + + props: { + textAlignment: "justify", + }, + }, + { type: "pageBreak" }, + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + children: [ + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + }, + { + type: "bulletListItem", + content: + "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + props: { + textAlignment: "right", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item 2", + children: [ + { + type: "numberedListItem", + content: "Numbered List Item Nested 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested 2", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky right", + props: { + textAlignment: "right", + backgroundColor: "red", + textColor: "blue", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky center", + props: { + textAlignment: "center", + backgroundColor: "red", + textColor: "blue", + }, + }, + ], + }, + ], + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "checkListItem", + content: "Checked List Item", + props: { + checked: true, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item starting at 5", + props: { + start: 5, + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 6", + }, + { + type: "toggleListItem", + content: "Toggle List Item", + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle list item.", + }, + { + type: "bulletListItem", + content: "A nested bullet inside the toggle", + }, + ], + }, + { + type: "heading", + content: "Toggle Heading", + props: { + level: 2, + isToggleable: true, + }, + children: [ + { + type: "paragraph", + content: "Content nested inside the toggle heading.", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + columnWidths: [200, undefined, undefined], + rows: [ + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Wide Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://placehold.co/332x322.jpg", + caption: "From https://placehold.co/332x322.jpg", + }, + }, + { + type: "image", + props: { + previewWidth: 200, + url: "https://placehold.co/332x322.jpg", + textAlignment: "right", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + }, + }, + { + type: "paragraph", + }, + { + type: "audio", + props: { + caption: "Audio file caption", + name: "audio.mp3", + }, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline Content:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "underlined", + styles: { underline: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "text", + text: "strikethrough", + styles: { strike: true }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { + cells: ["Table Header 1", "Table Header 2", "Table Header 3"], + }, + { + cells: [ + "Table Cell 4", + [ + { + type: "text", + text: "Table Cell Bold Colored 5", + styles: { + bold: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + ], + "Table Cell 6", + ], + }, + { + cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], + }, + ], + }, + }, + // An "advanced" table: two header rows and merged cells (colspan / + // rowspan) - the features behind the editor's `splitCells` / + // `headers` table options, which exporters must place correctly. + { + type: "table", + content: { + type: "tableContent", + // Explicit widths for all three tracks: the merged first-row cell + // means the row alone doesn't reveal the column count. + columnWidths: [undefined, undefined, undefined], + headerRows: 2, + rows: [ + { + cells: [ + { + type: "tableCell", + content: "Merged Header", + props: { colspan: 2 }, + }, + { type: "tableCell", content: "Header C" }, + ], + }, + { + cells: ["Header A", "Header B", "Header C2"], + }, + { + cells: [ + { + type: "tableCell", + content: "Merged Rows", + props: { rowspan: 2 }, + }, + { type: "tableCell", content: "Cell B1" }, + { type: "tableCell", content: "Cell C1" }, + ], + }, + { + cells: ["Cell B2", "Cell C2"], + }, + ], + }, + }, + // A hard line break (shift+enter) inside one paragraph - a single block + // whose text spans two lines. + { + type: "paragraph", + content: "A hard line break\nwithin a single paragraph", + }, + { + type: "codeBlock", + props: { + language: "javascript", + }, + content: `const helloWorld = (message) => { + console.log("Hello World", message); +};`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Some inline code: ", + styles: { bold: true }, + }, + { + type: "text", + text: "var foo = 'bar';", + styles: { code: true }, + }, + ], + }, + { + type: "columnList", + children: [ + { + type: "column", + props: { width: 0.8 }, + children: [ + { type: "paragraph", content: "This paragraph is in a column!" }, + ], + }, + { + type: "column", + props: { width: 1.4 }, + children: [{ type: "heading", content: "So is this heading!" }], + }, + { + type: "column", + props: { width: 0.8 }, + children: [ + { + type: "paragraph", + content: "You can have multiple blocks in a column too", + }, + { type: "bulletListItem", content: "Block 1" }, + { type: "bulletListItem", content: "Block 2" }, + { type: "bulletListItem", content: "Block 3" }, + ], + }, + ], + }, + { type: "divider" }, + { + type: "quote", + content: "All those moments will be lost in time, like tears in rain.", + }, +]; diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/.bnexample.json b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/.bnexample.json new file mode 100644 index 0000000000..596cc792ba --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/.bnexample.json @@ -0,0 +1,16 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Interoperability", "Accessibility"], + "sharedTestDocument": true, + "dependencies": { + "@blocknote/xl-pdf-exporter": "latest", + "@blocknote/xl-multi-column": "latest", + "@blocknote/math-block": "latest", + "@blocknote/diagram-block": "latest", + "@myriaddreamin/typst.ts": "^0.8.0-rc3", + "@myriaddreamin/typst-ts-web-compiler": "^0.8.0-rc3" + }, + "pro": true +} diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/README.md b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/README.md new file mode 100644 index 0000000000..91e9df6ef2 --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/README.md @@ -0,0 +1,13 @@ +# Exporting documents to tagged PDF (PDF/UA) + +This example exports the current document to an **accessible, tagged PDF/UA-1** +file using the Typst-powered `@blocknote/xl-pdf-exporter`. Unlike a plain PDF, +a tagged PDF carries a logical structure tree (headings, paragraphs, lists, +tables, figures with alt text, links) that screen readers can navigate. + +**Try it out:** Edit the document — the PDF preview updates live. Click +"Download" to save it, then verify it with a tool like +[veraPDF](https://verapdf.org/) (`--flavour ua1`) or the Acrobat Tags panel. + +> The first export downloads the Typst compiler (wasm) and fonts, so it may take +> a moment. Images render as tagged placeholder figures for now. diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/index.html b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/index.html new file mode 100644 index 0000000000..46d4ed40aa --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/index.html @@ -0,0 +1,14 @@ + + + + + Exporting documents to tagged PDF (PDF/UA) + + + +
+ + + diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/main.tsx b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/package.json b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/package.json new file mode 100644 index 0000000000..a81fc36580 --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/package.json @@ -0,0 +1,36 @@ +{ + "name": "@blocknote/example-interoperability-converting-blocks-to-pdf-ua", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@blocknote/xl-pdf-exporter": "latest", + "@blocknote/xl-multi-column": "latest", + "@blocknote/math-block": "latest", + "@blocknote/diagram-block": "latest", + "@myriaddreamin/typst.ts": "^0.8.0-rc3", + "@myriaddreamin/typst-ts-web-compiler": "^0.8.0-rc3" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/App.tsx b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/App.tsx new file mode 100644 index 0000000000..7632d1b559 --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/App.tsx @@ -0,0 +1,272 @@ +import { testDocumentBlocks } from "./testDocumentBlocks"; +import { + Block, + BlockNoteSchema, + combineByGroup, + withPageBreak, +} from "@blocknote/core"; +import { filterSuggestionItems } from "@blocknote/core/extensions"; +import "@blocknote/core/fonts/inter.css"; +import * as locales from "@blocknote/core/locales"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; +import { diagramBlockMapping } from "@blocknote/diagram-block/typst-exporter"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; +import { + inlineMathMapping, + mathBlockMapping, +} from "@blocknote/math-block/typst-exporter"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + getPageBreakReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { + PDFExporter, + typstDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter"; +import { + getMultiColumnSlashMenuItems, + locales as multiColumnLocales, + multiColumnDropCursor, + withMultiColumn, +} from "@blocknote/xl-multi-column"; +// Bundle the Typst compiler wasm so it resolves locally (no CDN / importer). +// Fonts need no setup: the exporter's bundled defaults (Inter, Geist Mono, +// math, emoji - matching the editor) load lazily from the package. +import compilerWasmUrl from "@myriaddreamin/typst-ts-web-compiler/wasm?url"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import "./styles.css"; + +/** + * Exports the given document to a PDF/UA object URL, re-exporting whenever + * `blocks` changes. + * + * The effect-with-cleanup idiom keeps only the newest result: when a newer + * version (or unmount) invalidates the effect, the cleanup marks the running + * export stale and its result is dropped. Overlapping exports are *safe* - + * the exporter serializes its shared compile stage internally - but like any + * async calls they may complete out of call order, and which result to + * display is this component's concern, not the exporter's. + */ +function usePdfUA( + makeExporter: () => PDFExporter, + blocks: Block[], +) { + const [pdfUrl, setPdfUrl] = useState(); + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading", + ); + + useEffect(() => { + let stale = false; + setStatus("loading"); + void (async () => { + try { + const blob = await makeExporter().toBlob( + blocks, + { getModule: () => compilerWasmUrl }, + { title: "BlockNote document", lang: "en" }, + ); + if (stale) { + return; + } + setPdfUrl(URL.createObjectURL(blob)); + setStatus("ready"); + } catch (e) { + if (stale) { + return; + } + // eslint-disable-next-line no-console + console.error(e); + setStatus("error"); + } + })(); + return () => { + stale = true; + }; + }, [makeExporter, blocks]); + + // Each object URL is revoked when replaced by the next one (and the last + // one on unmount). + useEffect(() => { + return () => { + if (pdfUrl) { + URL.revokeObjectURL(pdfUrl); + } + }; + }, [pdfUrl]); + + return { pdfUrl, status }; +} + +export default function App() { + // Creates a new editor instance with support for page breaks. + const editor = useCreateBlockNote({ + // Adds support for math & diagram blocks. + schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({ + blockSpecs: { + mathBlock: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), + }, + inlineContentSpecs: { + math: createReactInlineMathSpec(), + }, + }), + dropCursor: multiColumnDropCursor, + dictionary: { + ...locales.en, + multi_column: multiColumnLocales.en, + }, + tables: { + splitCells: true, + cellBackgroundColor: true, + cellTextColor: true, + headers: true, + }, + initialContent: [ + ...testDocumentBlocks, + // The math & diagram blocks aren't part of the shared test document, + // since the exporter unit tests' schemas don't register them, so they're + // appended here instead. + { + type: "mathBlock", + content: "a^2 = \\sqrt{b^2 + c^2}", + }, + { + type: "diagram", + content: `graph TD + A[Start] --> B{Works?} + B -->|Yes| C[Ship it] + B -->|No| A`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline math: ", + styles: {}, + }, + { + type: "math", + content: "e^{i\\pi} + 1 = 0", + }, + ], + }, + ], + }); + + // Additional Slash Menu items for page breaks. + const getSlashMenuItems = useMemo( + () => async (query: string) => + filterSuggestionItems( + combineByGroup( + getDefaultReactSlashMenuItems(editor), + getPageBreakReactSlashMenuItems(editor), + getMultiColumnSlashMenuItems(editor), + ), + query, + ), + [editor], + ); + + // A fresh exporter per export: its asset registry is append-only for the + // exporter's lifetime, so reusing one across re-exports would accumulate + // every image/diagram variant it has ever rendered. + const makeExporter = useCallback( + () => + new PDFExporter(editor.schema, { + ...typstDefaultSchemaMappings, + blockMapping: { + ...typstDefaultSchemaMappings.blockMapping, + // Renders math blocks as native Typst equations, and diagrams as + // embedded images - both carrying alt text for PDF/UA. + mathBlock: mathBlockMapping, + diagram: diagramBlockMapping, + }, + inlineContentMapping: { + ...typstDefaultSchemaMappings.inlineContentMapping, + math: inlineMathMapping, + }, + }), + [editor], + ); + + // The document snapshot driving the export - the export effect depends on + // the data it exports. Updated debounced: reading `editor.document` + // converts the whole document to blocks, so it shouldn't run (and the + // export shouldn't restart) on every keystroke. + const [blocks, setBlocks] = useState(() => editor.document); + const { pdfUrl, status } = usePdfUA(makeExporter, blocks); + + const debounceTimer = useRef>(undefined); + useEffect(() => () => clearTimeout(debounceTimer.current), []); + const onChange = () => { + clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => setBlocks(editor.document), 600); + }; + + const onDownloadClick = () => { + if (!pdfUrl) { + return; + } + const link = document.createElement("a"); + link.href = pdfUrl; + link.download = "blocknote (pdf-ua).pdf"; + document.body.appendChild(link); + link.click(); + link.remove(); + }; + + const label = + status === "loading" + ? "Generating…" + : status === "error" + ? "Export failed (see console)" + : "✓ Tagged PDF/UA-1"; + + return ( +
+
+
Editor Input
+
+ + + +
+
+
+
+ {label} + +
+
+ {pdfUrl ? ( +