From de43ea5a4dd8a8b4e3e6a07de0b290b647b2db13 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 5 Oct 2026 10:22:53 +0530 Subject: [PATCH 1/2] feat(editor): prepare preview documents before mounting --- .../editor-preview-document-preparation.md | 12 ++ packages/editor/README.md | 37 +++++ packages/editor/src/canvas/email-frame.tsx | 44 +++--- .../editor/src/canvas/preview-document.ts | 16 ++ packages/editor/src/shell.ts | 1 + packages/editor/src/state/provider.tsx | 12 +- .../editor/tests/preview-document.test.tsx | 141 ++++++++++++++++++ 7 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 .tegami/editor-preview-document-preparation.md create mode 100644 packages/editor/src/canvas/preview-document.ts create mode 100644 packages/editor/tests/preview-document.test.tsx diff --git a/.tegami/editor-preview-document-preparation.md b/.tegami/editor-preview-document-preparation.md new file mode 100644 index 0000000..256ef16 --- /dev/null +++ b/.tegami/editor-preview-document-preparation.md @@ -0,0 +1,12 @@ +--- +packages: + npm:@samva/editor: + type: minor +--- + +## Hosts can prepare preview documents before parsing + +`EditorProvider` accepts `preparePreviewDocument`, and `@samva/editor/shell` exports +`PreparedPreviewDocument`. Canvas and Preview write the prepared HTML, then mount host resources +before measurement and paint. Resources are cleaned up for their exact document on replacement, +unmount, and StrictMode replay. Replacing the preparation callback replaces the preview frames. diff --git a/packages/editor/README.md b/packages/editor/README.md index fd070b2..c6654f2 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -73,6 +73,43 @@ exact source replacement saved like any other edit. Expressions, conditionals an inside a body are locked segments, so a form edit cannot rewrite them, and an edit that would leave the static profile is refused with its reason. +## Preparing preview documents + +`EditorProvider` accepts `preparePreviewDocument?: (html: string) => PreparedPreviewDocument`. +Both the editing canvas and Preview call it with the original host HTML before writing to the +attached iframe. Only the returned `html` reaches that parser. Preview applies its forced +light/dark scheme to the prepared HTML. + +```tsx +import { EditorProvider, EditorShell, type PreparedPreviewDocument } from "@samva/editor/shell"; + +const preparePreviewDocument = (html: string): PreparedPreviewDocument => ({ + html, + mount(document) { + const style = document.createElement("style"); + style.textContent = "body { min-height: 200px; }"; + document.head.appendChild(style); + return () => style.remove(); + }, +}); + + + +; +``` + +Preparation must be pure and synchronous: React can repeat or discard it during render. +Install document resources in `mount`, which runs synchronously after `document.close()` and +before measurement, canvas overlays, or paint. Its returned cleanup belongs to that exact +document and runs on replacement, unmount, and StrictMode effect replay. Mount and cleanup must +support that replay. Asynchronous resource work remains the host's responsibility; the editor +does not wait for it before measuring or painting. + +Keep the preparation callback stable while its policy stays the same. Changing its identity +replaces the frames and their resources without resetting the editor session. Without the prop, +the editor writes the host HTML as supplied (with Preview's existing scheme simulation). +Preparation affects iframe previews only; exported HTML and the HTML view retain host output. + ## Host contract Every host provides a scoped `DocumentReader`. Editable hosts additionally diff --git a/packages/editor/src/canvas/email-frame.tsx b/packages/editor/src/canvas/email-frame.tsx index 2f84546..5e87764 100644 --- a/packages/editor/src/canvas/email-frame.tsx +++ b/packages/editor/src/canvas/email-frame.tsx @@ -1,14 +1,15 @@ -import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { type ReactNode, useContext, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { isCanvasActivationTarget, resolveInstancePath } from "./instance-dom"; +import { PreviewDocumentContext, type PreparedPreviewDocument } from "./preview-document"; /** - * The rendered email, mounted as-is in a same-origin iframe. + * The rendered email, mounted in a same-origin iframe. * * The host renders the template and hands back one HTML document; nothing here - * compiles or rewrites it. The frame owns three things the document itself does - * not: it sizes to the content, it resolves a click to the `data-samva-instance` + * compiles it. Optional host preparation runs before parsing. The frame sizes + * to content, resolves a click to the `data-samva-instance` * the renderer stamped on every element, and it hosts chrome (selection outline, * label chip) as a React portal above the document. */ @@ -117,6 +118,7 @@ export interface EmailFrameProps { */ const FrameDocument = ({ document_, + mount, width, dark, overlay, @@ -125,6 +127,7 @@ const FrameDocument = ({ onContextMenu, }: { readonly document_: string; + readonly mount: PreparedPreviewDocument["mount"] | undefined; readonly width: number; readonly dark: boolean; readonly overlay?: ReactNode | undefined; @@ -146,6 +149,9 @@ const FrameDocument = ({ useLayoutEffect(() => { const iframe = ref.current; if (iframe === null) return; + const idoc = iframe.contentDocument; + if (idoc === null) return; + let cleanupDocument: (() => void) | undefined; let fitFrame: number | null = null; const scheduleFit = () => { @@ -163,8 +169,6 @@ const FrameDocument = ({ const mutations = new MutationObserver(scheduleFit); const updateChrome = () => { - const idoc = iframe.contentDocument; - if (idoc === null) return; let chrome = idoc.head.querySelector("style[data-samva-editor-chrome]"); if (chrome === null) { chrome = idoc.createElement("style"); @@ -175,14 +179,13 @@ const FrameDocument = ({ }; const wire = () => { - const idoc = iframe.contentDocument; - if (idoc === null) return; // A src-less same-origin iframe exposes its document synchronously, so the // rendered email is installed by writing it rather than through `srcdoc`, // whose load is asynchronous and would leave one empty frame on screen. idoc.open(); idoc.write(document_); idoc.close(); + cleanupDocument = mount?.(idoc); updateChrome(); setBody(idoc.body); observer.disconnect(); @@ -219,16 +222,12 @@ const FrameDocument = ({ }; const attach = () => { - const idoc = iframe.contentDocument; - if (idoc === null) return; idoc.addEventListener("click", onClick); idoc.addEventListener("mousemove", onMove); idoc.addEventListener("mouseleave", onLeave); idoc.addEventListener("contextmenu", onContext); }; const detach = () => { - const idoc = iframe.contentDocument; - if (idoc === null) return; idoc.removeEventListener("click", onClick); idoc.removeEventListener("mousemove", onMove); idoc.removeEventListener("mouseleave", onLeave); @@ -254,10 +253,11 @@ const FrameDocument = ({ mutations.disconnect(); themeObserver.disconnect(); if (fitFrame !== null) cancelAnimationFrame(fitFrame); + cleanupDocument?.(); }; - // `document_` is this frame's React key, so it is fixed for the frame's whole - // life: the effect installs the document once and never rewrites it. - }, [document_]); + // The frame key includes source, scheme, and preparation identity so a live + // portal never survives replacement of its document. + }, [document_, mount]); return (