refactor(react): one vocabulary and simpler plumbing for portal elements - #3053
Draft
YousefED wants to merge 32 commits into
Draft
refactor(react): one vocabulary and simpler plumbing for portal elements#3053YousefED wants to merge 32 commits into
YousefED wants to merge 32 commits into
Conversation
* poc * improve typst exporter * update tests * feat(typst): math & diagram mappings, SVG diagrams, @cantoo/pdf-lib - @blocknote/math-block/typst-exporter: math renders as native Typst equations (LaTeX converted with tex2typst, KaTeX-validated) with the LaTeX source as alt text - required by PDF/UA and enforced by compiling the tests under the node compiler's pdfStandard "ua-1" validation. - @blocknote/diagram-block/typst-exporter: diagrams embed as vector SVG figures with the Mermaid source as alt text (same ua-1 test gate). - Diagrams now render labels as SVG text everywhere (htmlLabels: false in the one global initialize): Mermaid's default HTML labels live in <foreignObject>, which non-browser SVG consumers silently drop. Verified to keep wrapping, <br/> and markdown-string formatting. - Diagram labels use the document font on every surface: the editor preview reads the editor's computed style, PNG exports default to BlockNote's UI font, the Typst export uses the exporter's fontFamily - applied by rewriting the rendered SVG's font declarations (Mermaid has no per-render config API). - TypstExporter: registerImageBytes for pre-rendered assets, fontFamily accepts a fallback list for CJK (mirroring the react-pdf exporter's fonts/fontFamily options), strLit/escStr exported for mapping authors. - pdf-lib replaced with the maintained @cantoo/pdf-lib fork (2.9.1); DisplayDocTitle now set via its ViewerPreferences API. Fixed the package's vite externals so the fork isn't inlined into the bundle. - Example 11 registers math & diagram blocks with the typst mappings; renderer-2 README/status refreshed; tsgo -> tsc for TS7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(typst): review-driven hardening of the typst/pdf-ua pipeline Correctness: - Tables: colspan/rowspan support (merged cells kept their columns), all header rows in a single table.header (Typst rejects more than one), and a colspan-aware column count. - Asset paths are extension-less: Typst detects the image format from the bytes (verified against the engine); a wrongly guessed known extension was the only way to make good bytes fail. Deletes the mime/sniffing/URL machinery and the image-meta dependency. - Valid-KaTeX commands tex2typst can't translate fail the export loudly; documented in the README with a measured comparison (tex2typst 28/32 vs mitex-wasm 14/32 on a KaTeX corpus; tylax has no npm artifact). - declarePdfUA: decodes filtered XMP streams, handles self-closing rdf:Description, $-safe splicing, and throws if the identifier can't be injected; rewritten with the UA declarations documented in place. - compileBrowser: honest singleton contract (option changes throw, config documented as fixed at first compile), serialized compiles, and real browser tests of the wasm path. - Image resolve failures fail exports loudly (repo error doctrine); the CORS proxy passes data:/blob: URLs through untouched. - Email exporter: column content renders flat (no double indent, no nesting-level bump); escStr treats lone CR as a line break; numbered lists keep start: 0; font rewrites are $-safe. - Diagram labels use the exporter's full font list (CJK + emoji fallbacks). Options & API: - TypstDocumentOptions gains paper/margin; blocksToPdfUA documents the tagged-but-unclaimed composition; escStr/imageExtension/PT dropped from the public API (PIXELS_PER_POINT is per-mapping, like the pdf exporter). Test document & fixtures: - Shared doc gains an advanced table (two header rows, colspan, rowspan), a hard line break, and an empty paragraph - surfacing that odt/react-pdf/ email exporters silently drop cell merges (follow-up). - The shared test resolver serves a real decodable JPEG; typst tests are network-free; NodeCompiler test boilerplate lives in one shared helper; binary snapshots share the common util with an .actual dump on mismatch. - Generated example copies moved into src/ (the docs generator only copies src/**) and the docs site builds the pdf-ua demo (Turbopack resolveAlias + a version-derived CDN wasm stub; portable new URL font assets). Infra: - vp task input specs fixed for shared/ (root-level sources) and gen (shared test doc + generated copies) - stale cache replays clobbered fresh builds. - pdfua test gates throw when verapdf/pdftoppm are missing instead of silently passing; CI installs poppler and the official veraPDF container image, pinned by digest, via tests/scripts/install-pdf-tooling.sh. - Playground gains the typst deps + Vercel build aliases; minimum release age exclusions narrowed to the exact typst packages. - Example 11: usePdfUA hook (effect-with-cleanup idiom), font fetch retry, single object-URL lifecycle. * fix(typst): review pass-3 fixes, fully offline fonts, pinned poppler rasterizer - exporter: append-only asset registry (create a fresh exporter per export; removes the clear/fetch-cache machinery and its races), blank-line sentinel for empty paragraphs, alignment scoped to a block's own content (nested children keep theirs), core getColspan/getRowspan in the table mapping, asset-key collision throw in blocksToPdfUA - compile: actually disable CDN font assets (typst.ts force-loads its 'text' fonts from jsdelivr without the explicit opt-out marker); font compatibility check by content fingerprint (length + first 256 bytes) instead of reference identity; PDF/UA identifier spliced at the first rdf:Description open tag - fonts: bundle NewCMMath (Typst's math font) in the example and shared assets; per-file `new URL` literals (Turbopack collapses template paths to a single asset); genDocs copies binary example files byte-for-byte (UTF-8 round-trip corrupted every byte >= 0x80) - docs: serve the compiler wasm as a local static asset instead of jsdelivr; drop the dead webpack config (Next 16 builds with Turbopack) - docx/odt: hard line breaks (shift+enter) emit <w:cr/> resp. <text:line-break/> instead of raw LF that viewers ignore/collapse - tests: rasterize pdf/ua visual snapshots through a digest-pinned poppler container (byte-stable across environments; regenerate baselines once docker is available), verapdf shim zero-arg and .pdf-suffix fixes, shared isPdf/PNG test utils, NodeCompiler reuse in typstTestUtil - example: debounce before reading editor.document, diagram preview reads the editor font at render time * refactor(typst): split into xl-typst-exporter + Typst-based PDFExporter in xl-pdf-exporter - new @blocknote/xl-typst-exporter: the pure Typst layer (TypstExporter, default mappings, custom-mapping helpers) with no compile/PDF deps - the same mappings serve .typ export and PDF export - @blocknote/xl-pdf-exporter root is now the Typst-based PDFExporter (toBytes/toBlob; asset merge + collision check + compile + declare folded into toBytes, blocksToPdfUA no longer exported). declarePdfUA is a default-on opt-out (a tagged-but-unclaimed PDF is honest when a document is known not to conform); compileTypstToTaggedPdf/declarePdfUA stay exported for custom/server-side composition - the react-pdf exporter moved to the /react-pdf subpath (only exposure, so its dependency tree tree-shakes out of the root graph), with @deprecated tags on it, its mappings, and math/diagram's react-pdf mappings; the react-pdf example is marked deprecated - xl-pdf-renderer-2 removed (never published); all imports, deps, aliases, docs deps, CI tooling script updated; pdf-ua example and tests use the new PDFExporter API - build task outputs now declare types/** (a cache replay restoring only dist/ left consumers without declarations); xl-pdf-exporter tsconfig gets node types for the moved pdfua suites * docs(typst): export docs + review-driven packaging and API hardening - docs: rewrite the PDF export page for the Typst-based PDFExporter (simple-first CDN path, fonts & offline use, PDF/UA guidance, custom Typst mappings, deprecated react-pdf section), add the Typst export page, update supported formats and the math/diagram block pages - inline mappings now return Typst *markup* (leading #), so inline results compose by plain concatenation: block mappings use exporter.transformInlineContent(content).join("") - the same base Exporter seam as the other exporters - and the joinInline helper is gone (output byte-identical, verified by unchanged snapshots) - packaging (from design review): LICENSE for xl-typst-exporter; react/react-dom/@react-pdf/renderer become optional peers (react-pdf serves only the deprecated subpath); dead ./style.css export removed; xl-pdf-exporter manifest describes the actual product; stale types/ output cleaned; missing xl-multi-column test alias + stale externals fixed in xl-typst-exporter's vite config - API polish: toBytes/toBlob options optional, TypstExporterOptions exported, stale toBlob references removed from react-pdf docstrings, react-pdf example marked deprecated * fix(docs): demo page still imported the react-pdf exporter from the package root The /demo page (outside the generated example trees) kept the old root import; local builds masked it by resolving a stale workspace dist. Point it at the deprecated /react-pdf subpath - migrating the demo's PDF action to the Typst-based PDFExporter rides the examples/docs follow-up pass. * fix(ci): e2e failure artifacts were never uploaded .vitest-attachments is a dot-directory and upload-artifact excludes hidden files by default, so the failure-artifact step always warned 'No files were found' and uploaded nothing - exactly when the actual screenshots are needed to inspect (or adopt) a changed baseline. * fix: address CodeRabbit review findings - compileBrowser: compare getModule's resolved module by canonical key (a fresh `new URL(...)` per call - the docstring's own recommended pattern - spuriously failed the identity check on every compile after the first) - declarePdfUA: only accept an existing pdfuaid:part=1 claim; any other existing identifier (part 2, empty) now throws instead of being silently endorsed (+ regression tests) - typst tables: honor headerCols like the other exporters (header-column cells get the header treatment; Typst has no per-cell TH tag) - TypstExporter: default options per key so an explicitly-undefined `colors` can't crash color lookups (+ test) - pdfua visual snapshot rasterizes the *declared* PDF users receive, not the pre-declaration compile output - binaryFileSnapshotUtil: create the baseline dir before the .actual diagnostic write so it can't ENOENT-mask the mismatch error - @deprecated on mathBlockMapping (react-pdf) for parity; example download control is a real <button>; template says pnpm not npm; README typo * ci: add update-e2e-screenshots dispatch workflow Regenerates the Linux screenshot baselines in the same Playwright container the e2e jobs use and uploads them as an artifact - the push-button alternative to a local dockerized e2e:updateSnaps run. * ci: allow triggering the screenshot workflow from the feature branch * test: regenerate Linux e2e screenshot baselines Regenerated via the update-e2e-screenshots workflow in the same Playwright container CI uses. The email exports grew with the shared test document (advanced table, hard line break); the react-pdf export gained a page and its placeholder images changed color with the decodable test-resolver JPEG. Inspected page by page - layouts match the darwin baselines. * ci: drop the temporary branch trigger from the screenshot workflow The push-on-self trigger existed only because workflow_dispatch requires the file on the default branch; the regeneration is done, and after merge the workflow is dispatchable normally. * ci: pass the screenshot filter via env, not template expansion Template-level expansion of a workflow_dispatch input into run: is a code-injection vector (zizmor template-injection); the env indirection keeps the input out of the generated script. * feat(pdf): bundled default fonts, CDN-free by default; ODT cell merges; demo + example simplification - PDFExporter now ships editor-matching default fonts (Inter, Geist Mono, New Computer Modern Math, Noto Color Emoji) as lazily-imported package chunks - the react-pdf exporter's own embedding mechanism. The compile options' fonts and emojiFont default independently ('undefined' = use the default, explicit [] = none), and emojiFontFamily defaults to the bundled emoji font's family. Math fonts are required: the compiler wasm embeds no fonts at all (verified - fontless math fails to compile) - preloadDefaultFonts flips to default-false: zero-config exports make no font CDN requests (Typst's stock fonts become opt-in fallback faces) - ODT tables: colspan/rowspan support - span attributes plus the covered-table-cell placeholders ODF requires at every covered grid position, with a spanned-track-aware column count - docs /demo page PDF action migrated to the Typst PDFExporter (zero-config + bundled wasm); react-pdf fully out of the page - pdf-ua example: font bundling machinery removed (the defaults are byte-identical - verified via identical output PDF); 12MB of vendored fonts deleted; single Noto copy remains under shared/assets - vp build tasks: declare types/** as outputs across all packages (cache replays restored dist without declarations) * chore(e2e): docker-only policy for the browser suite - testing skill + CLAUDE.md: never run the browser suite natively - the screenshot matcher silently *seeds* per-platform baselines for every test that has none (passing without comparing), and several suites genuinely behave differently off-Linux (font-metric-dependent caret placement vs platform-shared JSON snapshots) - gitignore -darwin/-win32 screenshot baselines as a backstop and remove the previously committed darwin set - only -linux baselines are tracked, generated and compared inside the container - docker-run.sh: mount shared/testDocumentBlocks.ts (created when the shared test document was split; the mount whitelist lagged, breaking every dockerized run on this branch) Validated: the dockerized suite discovers the identical 150 files / 900 tests as a native run (incl. colocated packages/*/src browser tests) and passes against the Linux baselines in ~6 minutes. * chore: review cleanups - drop generated docs agent files and redundant workflow - docs/AGENTS.md + docs/CLAUDE.md are Next.js postinstall artifacts that rode in on a bulk add; removed and gitignored so they stay local - update-e2e-screenshots workflow removed: it existed for CI-side Linux baseline regeneration while local docker was unavailable; with the docker-only e2e policy, vp run e2e:updateSnaps covers it (build.yml's include-hidden-files failure-artifact fix is unaffected) * test(docx): regenerate the snapshot under main's per-list numbering The merge combined both sides' snapshot edits textually; the numbering rework from main (#2976) allocates different w:numId values, so the snapshot is regenerated under the merged code. * fix(typst): compute table header-column styling from true start tracks A row-spanning cell keeps covering its tracks in later rows, so a row's supplied cells flow past them - the per-row colspan counter mis-attributed header-column styling to whatever cell was supplied first (review finding). Start columns are now computed with row-span occupancy, header rows included since their spans reach into body rows. Regression: headerCols with a two-row span in the header column. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…placing typst.ts (#3020) * feat(pdf): own Typst wasm compiler (@blocknote/xl-typst-compiler), replacing typst.ts Compiles the official typst crates to wasm behind a minimal TypeScript API and moves the whole PDF pipeline onto it: native, validated PDF/UA-1 (tryDeclarePdfUA with typed violations), compile failures as values (compileErrors/compileWarnings), no CDN or network access anywhere, no page-level compiler singleton, and no @myriaddreamin/* or @cantoo/pdf-lib runtime dependencies. The wasm builds from rust/ via a self-provisioning build step (scripts/ensure-wasm.mjs) that covers CI and Vercel. * fix(ci): bootstrap rustup on CI runners; merge toBytes/toBlob into toPDF GitHub's ubuntu-24.04 images no longer ship Rust, so ensure-wasm now bootstraps rustup on CI like it does on Vercel (dev machines still get instructions instead of an install). PDFExporter.toPDF replaces toBytes/toBlob - one method, one result union, with the success branch carrying the PDF as both bytes and a Blob - mirroring toTypst and the other exporters' to<Format> naming. * perf(pdf): create the result Blob lazily (constructing a Blob copies the bytes) * refactor(pdf): exporter-level font/wasm config, family-consistent toPDF options Fonts (bytes, value-or-promise) and the compiler wasm move to the PDFExporter constructor - names and bytes side by side, matching how the other exporters put all configuration in the constructor - while toPDF takes a single per-export bag of document facts (plus tryDeclarePdfUA, assets, creationTimestamp), like every other exporter's export method. Also: exported spreadable default-font loaders (replacing the short-lived extraFonts), lazy result Blob, a font-name/file sync invariant test, the lang requirement failing fast, PdfExporterOptions as the full options shape with Partial at the constructor, and a leaner e2e docker context (rust/target excluded). * fix(ci): resolve the Rust toolchain from the crate dir; build wasm outside the e2e container wasm-pack now runs with the crate directory as cwd so rustup discovers rust-toolchain.toml (a bootstrapped rustup with no default toolchain found nothing to run - masked locally by an existing default). The e2e shards can't build the wasm at all (the Playwright container has no C toolchain for proc-macros), so a bare-runner job builds it once and shares pkg/dist/types as an artifact. * docs(pdf): rewrite the PDF page against the exporter-docs conventions Simple-first flow matching the DOCX template: PDF/UA confined to its own section, CDN/offline said once, internals cut, fonts split into short sections, custom mappings owned by the Typst page (the base layer never points up; the PDF page keeps its own option surfaces explicit). Typst page gains its own Customizing/assets/math-diagram/options sections; math's export section renamed Typst / PDF. DEFAULT_FONT_FAMILY / DEFAULT_MONO_FONT_FAMILY are exported so fallback lists need no hardcoded names. * docs(skill): dependency-direction, concept containment, hero-snippet, and prose-style rules from the PDF page review * test(e2e): typst PDF visual baselines; faster and reliable e2e image builds Adds the four typst-pdf-page chromium baselines (visually verified, pixel-exact via pdf.js rasterization) and gives the visual test cold-start headroom on shared runners. The e2e image install no longer re-verifies minimumReleaseAge (2000+ live registry checks - the frozen lockfile was already policy-verified by the install that produced it and by CI) and caches the pnpm store/metadata across rebuilds, taking the step from 70+ flaky minutes to ~4 (sub-minute warm). The pkg-pr-new soft release excludes xl-typst-compiler: its ~25MB wasm exceeds the service's non-whitelisted upload limit. * fix: address review quick wins (pdf-ua example status, font-less test compiler, workflow quoting) - the pdf-ua example shows a distinct status when the export is tagged but unclaimed, instead of displaying the UA-1 checkmark for a nonconforming document - compileTypstForTesting treats an explicit fontBlobs: [] as a font-less compiler rather than falling through to the bundled defaults - the pkg-pr-new publish line builds a quoted package array (SC2010/SC2046)
…d truth tests (#3021) * test(e2e): render the shared exporter document in the static-equality test The equality test (static HTML export vs live editor, one baseline) now renders the shared exporter test document instead of the basic-blocks example, so its baseline doubles as the editor ground truth the exporter visual baselines can be reviewed against - same document, live rendering. Toggle children are expanded by seeding the widgets' localStorage state (clicks scroll the page and race hydration), which requires stable block ids; the capture is full-resolution with pinned dimensions (two renders against one baseline cannot rely on measured heights) and chromium-only, since the property under test is browser-independent and full-res dimensions drift on Firefox. Measured parity: 66 mismatched pixels over the whole 1280x4400 page. screenshotFull grows an options passthrough (comparatorOptions / screenshotOptions / fixed height) for this and for the typst visual test's tightened 0.002 comparator. * fix(exporters): match the editor's rendering Fidelity fixes found by auditing the typst PDF output against a live editor render of the same shared document (the retargeted static-equality baseline), each editor-derived constant annotated with the Block.css rule it mirrors: - checkbox marker: single round-capped tick (the two rotated lines left a visible gap at the corner) on a box centred on the text's cap height; check items sit in the same marker column as their siblings (the invisible grouping list no longer adds its default body-indent) - checked items strike through their own text, like the editor - block spacing: the vertical insets wrap a parent's own body only, so closing a nested run no longer stacks bottom insets into an oversized gap; backgrounds still cover nested children - code blocks: the editor's dark scheme (#161616, 8px radius, 24px padding) with github-dark token colors via a bundled tmTheme that assetFiles always carries; ligatures off (Geist Mono renders => as an arrow glyph); inline code keeps the body color - bullet glyphs cycle like the editor's (bullet, white bullet, black small square) instead of typst's default cycle - divider: the editor's 1px rgb(125,121,122), not a fainter hairline - a media block with neither URL nor name is the editor's un-uploaded placeholder ("Add file") and now exports as nothing - in the typst, docx (previously a dead hyperlink), odt and email exporters alike * test(e2e): split exporterImages per exporter One file per exporter (emailImages / typstPdfImages / reactPdfImages) so failures and baselines attribute cleanly, with the shared invalid blocks, export frame and a deduplicated pdf.js rasterize-and-screenshot loop in exporterTestUtil. Baselines move with their tests; the typst pages are regenerated with the parity fixes. The typst compile also feeds from the pdf package's own default font loaders (not a hand-copied list, which is how a missing bold/italic face once went unnoticed) and declares the emoji font family so ZWJ sequences shape correctly. * docs: exporter-parity guard in AGENTS.md Editor styling changes must be checked against the exporter visual baselines - the static-equality baseline renders the same shared document and serves as the editor ground truth for that review. * fix(tests): supply the code-theme asset in the compiler's snapshot compile The xl-typst-compiler suite compiles the exporter's .typ snapshot with a hand-built assets map, which the preamble's new #set raw(theme:) reference broke on CI (file not found). The theme now lives as a real codeTheme.tmTheme file (imported ?raw by the exporter), so the test reads it from disk exactly like the snapshot itself - no dependency on exporter code, and a renamed path fails the compile loudly. * fix(typst): apply the empty-media rule to images A url-less, name-less image is the editor's un-uploaded placeholder ("Add image") and exports as nothing, like the other media blocks (review feedback). A *named* image without a URL still renders the labelled placeholder figure with its alt text. * docs(typst): assetFiles always carries the code theme, not only images Since the preamble references the bundled code-highlighting theme, mapping assetFiles into the compiler is required for every document - the images framing undersold it. * fix(exporters): omit url-less images in the docx, odt and email exporters Completes the empty-media rule for images beyond the typst exporter: an un-uploaded image block previously crashed the docx and odt exports (resolveFile/registerPicture on an empty URL) and rendered a broken img in email. These formats have no placeholder rendering, so any url-less image exports as nothing (typst keeps its labelled placeholder figure for named ones). The shared test document carries no url-less image, so snapshots are unchanged.
* refactor(exporters)!: drop the react-pdf math/diagram mappings The react-pdf exporter is deprecated and the math/diagram blocks are new - there is no installed base for their react-pdf mappings (review feedback on #2992). The exporter itself remains available unchanged during its deprecation window; math and diagram export continues through the typst/PDF, docx, odt and email mappings. Also removes the react-pdf e2e visual test (its purpose was exactly these mappings; the deprecated exporter keeps its unit snapshot tests), trims @react-pdf/math and the mapping-only dev/peer deps from math-block, diagram-block, docs, playground and the converting-blocks-to-pdf example, and gitignores the Next.js-postinstall-generated docs/AGENTS.md + docs/CLAUDE.md. * refactor(examples): the typst PDF example takes over the converting-blocks-to-pdf slug The new exporter's example replaces the react-pdf one at interoperability/converting-blocks-to-pdf, so existing links show the current exporter instead of a deprecated one. The react-pdf example moves to converting-blocks-to-pdf-react-pdf-deprecated (linked from the docs' deprecated-exporter section). Generated names/titles and the playground registry regenerated; docs links validated. * refactor(examples): "Exporting documents to PDF (PDF/UA)" - drop "tagged" from the title PDF/UA already implies tagged; as the canonical PDF example its title should read like what users search for. The deprecated example points to its successor by title, so its reference follows.
Reworks the portal consolidation from #3046, keeping its model — portal elements registered on the editor, themed roots for foreign targets — and changing how it is named and wired. Naming: one stem, `portalElement`, from `editor.registerPortalElement` and `mount(el, { portalElement })` through `resolvePortalElement`, `usePortalElement` and `PortalElementOverride` to the `portalElement` prop on every popover, menu and select. `portalRoot`, `portalTarget`, `portalContext` and `editorPortal` are gone. Where a forwarded prop and the ambient element coexist they are `portalElementProp` and `portalElement`. Theming: `ThemedRootProps` is replaced by a single `applyThemedRoot(element)` on `BlockNoteViewContext`, composed from the base classes and whatever the UI library adds. The mantine wrapper keeps base's `applyThemeVariables` ref for the editor container and passes the same function down for portal roots, so `BlockNoteTheme.ts` is untouched. Plumbing: the default portal element is derived in `usePortalElement` from the editor's own container, so `BlockNoteViewContainer` needs no state, no merged refs and no provider wrapper. `PortalElementOverride` creates its themed root directly and mounts it in a layout effect, replacing a `createPortal` of an empty div, a ref/state round-trip and a `closest(".bn-root")` read during render.
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
@blocknote/ariakit
@blocknote/code-block
@blocknote/core
@blocknote/diagram-block
@blocknote/mantine
@blocknote/math-block
@blocknote/react
@blocknote/server-util
@blocknote/shadcn
@blocknote/xl-ai
@blocknote/xl-docx-exporter
@blocknote/xl-email-exporter
@blocknote/xl-multi-column
@blocknote/xl-odt-exporter
@blocknote/xl-pdf-exporter
@blocknote/xl-typst-exporter
commit: |
|
…aries Inside a component that receives a `portalElement` prop, `portalElement` now means that prop, and the surrounding default from `usePortalElement` takes the qualified name. Reads more directly, and keeps the props destructures on one line as they are on the base branch.
BREAKING: `editor.mount(element, { portalTarget })` loses its options
argument. The option decided where `editor.portalElement` was appended, and
that element no longer exists; it had already been reduced to an alias for
`registerPortalElement`, which callers can call directly and explicitly:
editor.mount(element);
editor.registerPortalElement(someContainer);
Only needed when floating UI renders outside the editor's DOM tree — UI next
to the contenteditable already counts as within the editor.
`portalRoot` is now required on `Menu.Root`, `Popover.Root` and the toolbar select. The UI-library adapters never read the editor's context, so they cannot fall back to it themselves; making the prop required lets the compiler enforce what was a convention. Call sites pass the ambient element as-is — `null` before the editor has mounted — instead of coercing it to `undefined`. What each adapter does with `null` follows its library, so none of them portal to the document body: - shadcn passes it through: Base UI waits for a container. - ariakit toggles `portal` off: it renders inline until there is one (Ariakit appends a fresh div to the body for null and undefined alike). - mantine already rendered inline on a falsy value. The shadcn tooltips (`ToolbarButton`, `Badge`) keep reading the ambient element through `useEditorPortalElement`. That is the one documented exception: mantine and ariakit tooltips render inline and would ignore a passed element, so a prop would buy nothing there.
# Conflicts: # packages/ariakit/src/menu/Menu.tsx # packages/ariakit/src/popover/Popover.tsx # packages/ariakit/src/toolbar/ToolbarSelect.tsx # packages/mantine/src/menu/Menu.tsx # packages/mantine/src/popover/Popover.tsx # packages/mantine/src/toolbar/ToolbarSelect.tsx # packages/react/src/components/Comments/EmojiPicker.tsx # packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx # packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx # packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx # packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx # packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx # packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx # packages/react/src/editor/ComponentsContext.tsx # packages/shadcn/src/badge/Badge.tsx # packages/shadcn/src/menu/Menu.tsx # packages/shadcn/src/popover/popover.tsx # packages/shadcn/src/toolbar/Toolbar.tsx
`vp run build` typechecks example projects that lint doesn't cover: the three `SettingsSelect` copies render a `Toolbar.Select` without the now required prop. They sit inside `BlockNoteView`, so they read the ambient element and pass it. Also drops the last `?? undefined` on a portal value: the shadcn `Badge` tooltip passes the element as-is, like `ToolbarButton` already does, so nothing falls back to the body.
Brings in Matthew's portal-elements test. Two of its cases are adapted to this branch's contract: a per-element selector target gets a themed root rendered inside it rather than being reused as one (no "already a .bn-root" detection here), and `document.body` is passed explicitly, since `null` is no longer a portal element value.
Portal setups — a `portalElements` default, a per-element target, a controller's `portalElement` — must add no React commits or child renders at mount, and cost the same as the default setup on an unrelated parent re-render, with and without StrictMode. Counts are relative to the default setup measured in the same run, so unrelated editor render changes don't break them. Colocated browser-mode test rather than end-to-end: it needs a real browser (jsdom schedules commits differently) but nothing from the e2e harness, and renders with `createRoot` + `act` since `vitest-browser-react` only exists in the tests package. Red on the previous portal implementation (5 commits vs 3 at mount with an external default target), green here and on the base branch.
Menus now portal out of the toolbar, side menu and table handle that open them, so rules scoped on those ancestors (`.bn-toolbar .mantine-Menu-item`, `.bn-side-menu .mantine-Menu-dropdown`, ...) stopped applying: 14px items, a missing min-width, an ariakit gap on every popover. Scope on the dropdowns' own classes instead, and keep the ariakit gap for form popovers only. The e2e drag-handle menu selector no longer assumes nesting either.
Every menu, popover and form a floating component (toolbar, side menu, table handle, ...) opens now portals into a zero-size anchor next to that component, inside the wrapper floating-ui positions. So they share its stacking context and visibility (the ariakit colors submenu paints above the drag handle without a z-index override; ariakit and shadcn dropdowns hide with their toolbar instead of staying orphaned when it scrolls away), follow it when `portalElements` relocates it, and, for the mobile toolbar, sit outside its scroll strip, which iOS WebKit would otherwise not paint. `GenericPopover`'s closing snapshot must ignore the anchor's holder, or a popover whose children are already gone would snapshot an empty wrapper and vanish instead of fading out. The adapter-private contexts in ariakit and shadcn are renamed so they no longer share a name with the react package's context.
Mantine's `useFocusTrap`, armed once focus was within the toolbar, moved focus back into the toolbar a tick after a menu or form opened. With those now portalled next to the toolbar rather than inside it, that stole focus from the link form's URL field (0 ms clicks in e2e, and always for the link toolbar's Edit button). Tab now moves through the buttons and on, as in the other skins. The color menu's deferred `editor.focus()` existed only to work around the trap.
Mounting schedules an update from an effect that can commit after `act` has returned. On Linux WebKit whether it does varies from mount to mount, so the setup measured second sometimes counted one commit fewer or more than the baseline and the comparison failed (CI's webkit shard, 1 of 3 runs locally in Docker). Wait until no commit has landed for 50 ms before reading the counts, at mount and after the parent re-render, so every mount is measured once things have settled.
`usePortalElement` fell back to the editor's `bn-container`, which is the editor element's parent in the default layout but not when a layout renders `BlockNoteViewEditor` itself: there the container may also hold a sidebar, and the table's extend button, sized to the table's full width, escaped the editor's scrolling pane and painted over the sidebar while the table stayed clipped. Fall back to the editor element's parent, as `mount()` did before: floating UI clips and scrolls with the editor, and `portalElements` remains the way to escape.
`PortalElement` is `HTMLElement | string` and `PortalElementOverride` takes `HTMLElement | undefined`, but two doc comments still promised that `null` means `document.body`. Nothing accepts it any more.
Each `*` in `allowedDevOrigins` matches one address segment, so `172.*.*.*` admitted all of 172/8 rather than the private range the comment describes. Spell out the sixteen second segments of 172.16.0.0/12 instead.
…opment only The check for `interactive-widget=resizes-content` moves out of `useVirtualKeyboard` into `BlockNoteViewEditor`'s mount effect, so it reaches developers on desktop and with a custom formatting toolbar alike. It lives in its own module (not re-exported from the package) and returns early when `process.env.NODE_ENV` is "production": the library build leaves that expression in place for the consumer's bundler, so production builds stay silent and a page without a bundler still gets the hint.
The playground already puts `bn-scroll-container` on its root, so the example's own container is nested inside it, and its "scrolling document" switch has to turn the page's container off. Both are CSS in the example: a nested container defers to the outer one, and a body class set by the switch neutralises any container on the page. The library stylesheet and the playground stay as they are.
A view rendered inside another view's floating UI (the comments composer, an editor in a custom block's popover) inherited the outer view's portal anchor or override through context. That element is registered with the outer editor only, so the nested editor's own menus and popovers counted as outside it for isWithinEditor and the focus tracking built on it. BlockNoteViewContainer now resets the portal element for its subtree to the editor's own default.
When set, the UI library must not move focus into the surface when it opens; an input inside that asks for focus itself still gets it.
The link form tests now run for mantine, ariakit and shadcn: each skin's popover decides on its own how the URL field takes focus. The per-skin screenshot tests also wait for the link toolbar before comparing, since a missing toolbar stays within the 2% screenshot tolerance.
# Conflicts: # packages/react/src/editor/BlockNoteView.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A rework of #3046, opened against
portals-contextso the diff is only the delta: 42 files, +503 / −389. (#3052 is the same branch opened againstmain, where the diff also contains the mobile-toolbar stack underneath.)It keeps #3046's model — portal elements registered on the editor, themed roots for foreign targets,
portalElementsreaching every UI element — and changes how it is named and wired.Rationale
Two things came out of reviewing #3046. The vocabulary had four stems for what are really two concepts — portal element / portal root / portal context / editor portal. And the mounting logic was doing more React work than it needed to, in a place where re-render and ordering bugs are expensive and hard to spot.
Changes
One vocabulary, two concepts.
portalElementeverywhere:PortalElement,portalElements,resolvePortalElement,usePortalElement,PortalElementOverride,editor.registerPortalElement/unregisterPortalElement,mount(el, { portalElement }), and theportalElementprop on every popover, menu and select (wasportalRoot). Where a forwarded prop and the ambient element coexist in one scope they areportalElementPropandportalElement..bn-root) becomes a single function rather than a props object:applyThemedRoot(element)onBlockNoteViewContext.ThemedRootPropsis removed.Theming goes back to where it was.
BlockNoteTheme.tsis restored byte-identical tomobile-toolbar-demo—applyBlockNoteCSSVariablesFromThemeandremoveBlockNoteCSSVariablesuntouched, including their set/unset behaviour. The mantine view keeps its existingref={applyThemeVariables}for the container and passes the same logic down asapplyThemedRootfor portal roots, replacing theuseEffectthat themededitor.portalElement. Net: base plus 12 lines, instead of a rewrite.Simpler plumbing.
usePortalElementderives the default from the editor's own container, soBlockNoteViewContainerreturns to its original shape — nouseState, no merged refs, no provider wrapper, nouseEditorDOMElementcoupling inBlockNoteView.PortalElementOverridecreates its themed root directly and mounts it in a layout effect: three plain effects (append, theme, register). That removes thecreatePortalof an empty div, the ref/state round-trip, theneedsContainerstate, and theclosest(".bn-root")DOM read during render.Impact
Behavioural improvement: the default portal element now resolves to the editor's
.bn-containerrather than the contenteditable'sparentElement. UnderrenderEditor={false}those differ — the app's own wrapper is the parent — so floating UI could otherwise be clipped by that wrapper'soverflow.Testing
Measured in browser mode (chromium):
bn-root light bn-mantine→bn-root dark bn-mantine,data-color-schemeanddata-mantine-color-schemeboth following a live toggle — the bug feat: Portals cleanup #3046 set out to fix, preserved..bn-containerin the plain case, in therenderEditor={false}+ wrapper case, and yields to an explicit override.null→ attached), against three on this base.mobile-toolbar-demorenders once but hands over a detached element, with no notification when it attaches.E2E chromium: 294 passed, 1 failed —
static.test.tsxtiming out at 90s. It passes standalone in 2.4s and in CI, which shards chromium into halves; it only times out when all 48 files share one local container.Not yet run: firefox/webkit, and the mobile end-to-end suite, which lives above this layer.
Breaking change
editor.mount(element, { portalTarget })loses its options argument. The option decided whereeditor.portalElementgot appended, and that element is removed by this work; it had already become a one-line alias forregisterPortalElement. Callers do it explicitly instead:Only needed when floating UI renders outside the editor's DOM tree — UI next to the contenteditable already counts as within the editor. No in-repo caller used it except one test; the docs never mentioned it.
Open question
The default portal element costs consumers one extra render at mount. Making it an imperatively created div inside the container removes that (measured: one render, never null, attached before paint) at the cost of one empty DOM node — the node
mobile-toolbar-demoalready had. About 15 lines if we want that trade.