@the-portland-company/canvas-editor
v0.4.2
Published
Shared VRM canvas editor substrate — block/layout document model, block registry, HTML-email renderer, and the drag-and-drop editor surface for Politogy VRM apps. Framework-agnostic: styling via brand CSS vars, data/persistence/media injected by the app t
Readme
@the-portland-company/canvas-editor
The shared VRM Canvas Editor substrate for Politogy VRM apps: one block/layout document model, one block registry, one HTML renderer, and one drag-and-drop editor surface. Every module that needs canvas editing (Email, and later Forms, Surveys, CTAs, landing pages) binds to this package and registers its own blocks — nobody rebuilds the editor.
Framework-agnostic by construction: the package never imports next/*,
@supabase/*, @the-portland-company/shell, or any app code. All persistence,
media, templates, brand tokens, and merge-field resolution are injected by the
host through a single CanvasHostAdapter. It runs in a Next.js (React 19) app
and a Vite (React 18) app alike.
Extracted verbatim from the V1 email canvas (
politogy-email) so behavior is identical. See the audit inpolitogy-email/CANVAS_EDITOR_AUDIT.md.
Entry points
| Import | Contents | Environment |
| --- | --- | --- |
| @the-portland-company/canvas-editor | Document model, block registry, tree ops, HTML renderer, adapter types | Server-safe — pure logic, no React, no @dnd-kit. Import from Server Components / server actions / API routes. |
| @the-portland-company/canvas-editor/editor | <CanvasEditor> + hooks | "use client" — pulls @dnd-kit. Client only. |
| @the-portland-company/canvas-editor/renderer | renderEmailHtml / renderEmailText / pinBlockSchemaVersion | Server-safe. |
| @the-portland-company/canvas-editor/skeletons | Skeleton / SkeletonBar (self-contained, token-driven) | Server-renderable. |
| @the-portland-company/canvas-editor/css | Primitive component classes (.card/.input/.btn/.skeleton…) | Import once in the app. |
Peer dependencies (install alongside)
React 18 || 19, @dnd-kit/{core,sortable,utilities}, and — since 0.3.0, for the
tiptap rich-text editor — @tiptap/core @tiptap/react @tiptap/starter-kit
@tiptap/extension-underline @tiptap/extension-text-align @tiptap/extension-link
@tiptap/extension-text-style @tiptap/extension-color @tiptap/extension-font-family
(^3.27) and emoji-picker-react (^4.19). The package externalizes all of them.
Never import /editor from a server module — @dnd-kit evaluates
React.createContext at module load and will crash in an RSC / server-action
context. Server code that needs block logic imports the root (.) only.
Consuming it
Install from the registry (never
npm link; usenpm packtarballs for local dev against an unpublished change):npm i @the-portland-company/canvas-editorTailwind
@source— the editor ships as Tailwind utility classes indist, and Tailwind v4 does not scannode_modules. Add to yourapp/globals.cssor it renders unstyled (silent — builds green, diagnose against the live DOM):@source "../node_modules/@the-portland-company/canvas-editor/dist";CSS + brand tokens — import the primitive CSS, and make sure your app already loads the runtime brand-tokens
<link>(shell requirement). The package paints only throughvar(--token, fallback); colors and fonts come from the app's runtime tokens, and dark mode flips automatically under your app's.darkancestor. The package defines no tokens.import "@the-portland-company/canvas-editor/css";Implement the adapter and render the editor:
"use client"; import { CanvasEditor } from "@the-portland-company/canvas-editor/editor"; import type { CanvasHostAdapter } from "@the-portland-company/canvas-editor"; const adapter: CanvasHostAdapter = { save: (id, doc, expectedUpdatedAt) => myServerActions.save(id, doc, expectedUpdatedAt), renderPreview: (id, doc, opts) => myServerActions.renderPreview(id, doc, opts), sendTest: (id, addrs) => myServerActions.sendTest(id, addrs), templates: { list, loadInto, saveAs, resetToBlank }, media: { list, upload, updateAltText, delete: del, usageCount, trackUsage }, personalizationTokens: () => myServerActions.tokens(), brand: () => myServerActions.brand(), }; <CanvasEditor adapter={adapter} documentId={id} initialDocument={{ name, blocks, settings, meta: { subject, preheader } }} initialUpdatedAt={updatedAt} nextAction={{ label: "Next: Sender →", href: senderHref }} metaPanel={({ doc, setMeta }) => <MyEmailMetaPanel doc={doc} setMeta={setMeta} />} insertMode="center" // initial only; users switch Side/Center in the block tray (remembered per browser) />brand()may also returnorg: { companyName, address, websiteUrl, privacyUrl, contactUrl, logoUrl }so the Footer block derives its defaults from the host.
The boundary
- The package owns: the document model (
CanvasDocument=name+blockssettings+ opaquemeta), the block registry (core blocks pre-registered), the editor UI (palette, canvas, selection, inline toolbar, insert model, properties panel, mobile overrides, preview modes, autosave), and the renderer.
- The host supplies (via
CanvasHostAdapter): persistence, server-rendered preview, send-test, templates, media library, personalization tokens, and brand info. Host-specific fields (e.g. an email's subject/preheader and its deliverability panel) live in the host and mount into the editor through themetaPanelslot — they are carried in the document's opaquemetabag, which the package never interprets. - Merge-field / UVP resolution stays in the host adapter — provenance and exposure are enforced at the host's data layer, never in this package.
Development
npm run type-check # tsc --noEmit
npm test # vitest (CORE parity tests ported from the email app)
npm run build # tsup dual-build + re-assert "use client" on the editor entryPublishing is a tag-driven CI lane (v* tag → npm). prepublishOnly runs
type-check + npm audit --audit-level=high + tests + build.
