@standhigher/react-email-editor 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,10 +28,6 @@ export function EmailPage() {
28
28
  }
29
29
  ```
30
30
 
31
- With Next.js App Router, add `"use client"` to the integration component and import CSS from an allowed global stylesheet entry. Your app implements persistence, resource uploads and email delivery.
32
-
33
- Custom blocks that omit `icon` use the first character of their trimmed `title` as the icon: `Coupon` displays `C` and `优惠券` displays `优`. An explicit `icon` takes precedence.
34
-
35
31
  ## Run locally
36
32
 
37
33
  Use Node.js 24 LTS (`.nvmrc`; at least 22.18 for the complete test suite):
package/README.zh-CN.md CHANGED
@@ -28,10 +28,6 @@ export function EmailPage() {
28
28
  }
29
29
  ```
30
30
 
31
- Next.js App Router 的接入组件需声明 `"use client"`;样式在应用允许的全局样式入口引入。保存、上传资源与发送邮件由你的应用实现。
32
-
33
- 自定义区块省略 `icon` 时,默认使用 `title` 去除首尾空白后的第一个字符作为图标,例如 `Coupon` 显示 `C`、`优惠券` 显示 `优`;传入的 `icon` 优先使用。
34
-
35
31
  ## 本地启动
36
32
 
37
33
  推荐 Node.js 24 LTS(`.nvmrc`;完整测试最低需要 22.18):
package/dist/index.js CHANGED
@@ -44,7 +44,7 @@ function usePluginEditor() {
44
44
  }
45
45
 
46
46
  // src/shell/EmailEditorShell.tsx
47
- import { useState as useState18 } from "react";
47
+ import { useState as useState20 } from "react";
48
48
 
49
49
  // src/editor-dnd/EditorDnd.tsx
50
50
  import { useLayoutEffect as useLayoutEffect5, useMemo, useRef as useRef7, useState as useState7 } from "react";
@@ -4615,14 +4615,10 @@ function Inspector({
4615
4615
  }
4616
4616
 
4617
4617
  // src/stage/Stage.tsx
4618
- import { useMemo as useMemo5 } from "react";
4618
+ import { useMemo as useMemo4, useState as useState18 } from "react";
4619
4619
 
4620
- // src/stage/canvas/Canvas.tsx
4621
- import { useDroppable } from "@dnd-kit/core";
4622
- import {
4623
- SortableContext,
4624
- verticalListSortingStrategy
4625
- } from "@dnd-kit/sortable";
4620
+ // src/stage/preview/usePreviewBuffer.ts
4621
+ import { useCallback, useEffect as useEffect6, useLayoutEffect as useLayoutEffect9, useReducer, useRef as useRef13, useState as useState16 } from "react";
4626
4622
 
4627
4623
  // src/render/layout.ts
4628
4624
  function surfaceStyle(body) {
@@ -4639,8 +4635,126 @@ function viewportWidth(device, body) {
4639
4635
  return `${emailViewportWidth(device, body.contentWidth)}px`;
4640
4636
  }
4641
4637
 
4638
+ // src/stage/preview/buffer.ts
4639
+ function createPreviewBuffer() {
4640
+ return { frames: [null, null], front: null, failure: null };
4641
+ }
4642
+ function previewBufferReducer(state, action) {
4643
+ var _a, _b, _c;
4644
+ switch (action.type) {
4645
+ case "generated": {
4646
+ const match = action.force ? -1 : state.frames.findIndex((frame) => frame && frame.status !== "failed" && frame.html === action.snapshot.html && frame.width === action.snapshot.width);
4647
+ const target = match < 0 ? state.front === 0 ? 1 : 0 : match;
4648
+ const frames = [...state.frames];
4649
+ frames[target] = match < 0 ? action.snapshot : __spreadProps(__spreadValues({}, frames[target]), { source: action.snapshot.source });
4650
+ const ready = ((_a = frames[target]) == null ? void 0 : _a.status) === "ready";
4651
+ return { frames, front: ready ? target : state.front, failure: ready ? null : state.failure };
4652
+ }
4653
+ case "ready": {
4654
+ const index = state.frames.findIndex((frame) => frame && frame.id === action.id && frame.status === "loading");
4655
+ if (index < 0) return state;
4656
+ const frames = [...state.frames];
4657
+ frames[index] = __spreadProps(__spreadValues({}, frames[index]), { status: "ready" });
4658
+ const current = ((_b = frames[index]) == null ? void 0 : _b.source) === action.source;
4659
+ return { frames, front: current ? index : state.front, failure: current ? null : state.failure };
4660
+ }
4661
+ case "failed": {
4662
+ if (action.id === void 0) {
4663
+ const frames2 = [state.frames[0], state.frames[1]];
4664
+ for (const index2 of [0, 1]) {
4665
+ const frame = frames2[index2];
4666
+ if ((frame == null ? void 0 : frame.status) === "loading") frames2[index2] = __spreadProps(__spreadValues({}, frame), { status: "failed" });
4667
+ }
4668
+ return __spreadProps(__spreadValues({}, state), { frames: frames2, failure: action.failure });
4669
+ }
4670
+ const index = state.frames.findIndex((frame) => frame && frame.id === action.id && frame.status === "loading");
4671
+ if (index < 0) return state;
4672
+ const frames = [...state.frames];
4673
+ frames[index] = __spreadProps(__spreadValues({}, frames[index]), { status: "failed" });
4674
+ return __spreadProps(__spreadValues({}, state), { frames, failure: ((_c = frames[index]) == null ? void 0 : _c.source) === action.failure.source ? action.failure : state.failure });
4675
+ }
4676
+ }
4677
+ }
4678
+
4679
+ // src/stage/preview/constants.ts
4680
+ var PREVIEW_DEBOUNCE_MS = 400;
4681
+ var PREVIEW_MAX_WAIT_MS = 2e3;
4682
+ var PREVIEW_LOAD_TIMEOUT_MS = 15e3;
4683
+ var PREVIEW_BUFFER_SLOTS = [0, 1];
4684
+
4685
+ // src/stage/preview/usePreviewBuffer.ts
4686
+ function usePreviewBuffer(source, requested) {
4687
+ var _a;
4688
+ const [state, dispatch] = useReducer(previewBufferReducer, void 0, createPreviewBuffer);
4689
+ const [request, setRequest] = useState16({ key: requested ? 1 : 0, force: false });
4690
+ const previousRequested = useRef13(requested);
4691
+ const sourceRef = useRef13(source);
4692
+ const pendingSince = useRef13(null);
4693
+ const attempted = useRef13(null);
4694
+ const revision = useRef13(0);
4695
+ useLayoutEffect9(() => {
4696
+ sourceRef.current = source;
4697
+ }, [source]);
4698
+ const flush = useCallback(() => setRequest((value) => __spreadProps(__spreadValues({}, value), { key: value.key + 1, force: false })), []);
4699
+ useEffect6(() => {
4700
+ var _a2, _b, _c;
4701
+ const enteringPreview = requested && !previousRequested.current;
4702
+ previousRequested.current = requested;
4703
+ const newRequest = request.key > 0 && ((_a2 = attempted.current) == null ? void 0 : _a2.requestKey) !== request.key;
4704
+ const force = newRequest && request.force;
4705
+ if (((_b = attempted.current) == null ? void 0 : _b.source) === source && !force) {
4706
+ attempted.current.requestKey = request.key;
4707
+ return;
4708
+ }
4709
+ const immediate = enteringPreview || newRequest;
4710
+ (_c = pendingSince.current) != null ? _c : pendingSince.current = Date.now();
4711
+ const remaining = Math.max(0, PREVIEW_MAX_WAIT_MS - (Date.now() - pendingSince.current));
4712
+ let idle;
4713
+ let cancelled = false;
4714
+ const generate = () => {
4715
+ if (cancelled || sourceRef.current !== source) return;
4716
+ attempted.current = { source, requestKey: request.key };
4717
+ pendingSince.current = null;
4718
+ try {
4719
+ const html = source.config.toHtml(source.document, { resolveHtmlTemplates: true, variables: source.variables });
4720
+ dispatch({ type: "generated", snapshot: { id: ++revision.current, source, html, width: viewportWidth(source.device, source.document.body), status: "loading" }, force });
4721
+ } catch (error) {
4722
+ dispatch({ type: "failed", failure: { source, message: error instanceof Error ? error.message : String(error) || "Email preview failed." } });
4723
+ }
4724
+ };
4725
+ const delay = immediate ? 0 : Math.min(PREVIEW_DEBOUNCE_MS, remaining);
4726
+ const timer = setTimeout(() => {
4727
+ if (immediate || typeof requestIdleCallback !== "function") generate();
4728
+ else idle = requestIdleCallback(generate, { timeout: Math.max(1, remaining - delay) });
4729
+ }, delay);
4730
+ return () => {
4731
+ cancelled = true;
4732
+ clearTimeout(timer);
4733
+ if (idle !== void 0) cancelIdleCallback(idle);
4734
+ };
4735
+ }, [source, requested, request]);
4736
+ const markReady = useCallback((id) => {
4737
+ dispatch({ type: "ready", id, source: sourceRef.current });
4738
+ }, []);
4739
+ const markFailed = useCallback((id, message) => {
4740
+ dispatch({ type: "failed", id, failure: { source: sourceRef.current, message } });
4741
+ }, []);
4742
+ const retry = useCallback(() => setRequest((value) => __spreadProps(__spreadValues({}, value), { key: value.key + 1, force: true })), []);
4743
+ const front = state.front === null ? null : state.frames[state.front];
4744
+ const failure = ((_a = state.failure) == null ? void 0 : _a.source) === source ? state.failure : null;
4745
+ const displayFailure = failure != null ? failure : state.front === null ? state.failure : null;
4746
+ return __spreadProps(__spreadValues({}, state), { failure, displayFailure, ready: (front == null ? void 0 : front.source) === source, markReady, markFailed, flush, retry });
4747
+ }
4748
+
4749
+ // src/stage/canvas/Canvas.tsx
4750
+ import { useDroppable } from "@dnd-kit/core";
4751
+ import {
4752
+ SortableContext,
4753
+ verticalListSortingStrategy
4754
+ } from "@dnd-kit/sortable";
4755
+
4642
4756
  // src/stage/canvas/CanvasBlock.tsx
4643
- import { useId as useId13, useMemo as useMemo3, useState as useState16 } from "react";
4757
+ import { useId as useId13, useMemo as useMemo3, useState as useState17 } from "react";
4644
4758
  import { useSortable } from "@dnd-kit/sortable";
4645
4759
  import { CSS } from "@dnd-kit/utilities";
4646
4760
 
@@ -4996,7 +5110,7 @@ function blockToHtml(node, definitions = DEFAULT_BLOCKS, mode = "email", options
4996
5110
  }
4997
5111
 
4998
5112
  // src/stage/canvas/components/HtmlCanvas.tsx
4999
- import { useCallback, useEffect as useEffect6, useRef as useRef13 } from "react";
5113
+ import { useCallback as useCallback2, useEffect as useEffect7, useRef as useRef14 } from "react";
5000
5114
 
5001
5115
  // src/render/head-styles.ts
5002
5116
  function extractHeadStyles(html) {
@@ -5017,10 +5131,10 @@ function frameHtml(html, baseCss) {
5017
5131
  }
5018
5132
  function HtmlCanvas({ html, baseCss = "", onSelect }) {
5019
5133
  const hasHtmlTag = /<\/?[a-z][\w:-]*(?:\s[^>]*)?>/i.test(html);
5020
- const containerRef = useRef13(null);
5021
- const frameRef = useRef13(null);
5022
- const cleanupRef = useRef13();
5023
- const observeContent = useCallback(() => {
5134
+ const containerRef = useRef14(null);
5135
+ const frameRef = useRef14(null);
5136
+ const cleanupRef = useRef14();
5137
+ const observeContent = useCallback2(() => {
5024
5138
  var _a;
5025
5139
  (_a = cleanupRef.current) == null ? void 0 : _a.call(cleanupRef);
5026
5140
  const container = containerRef.current;
@@ -5061,7 +5175,7 @@ function HtmlCanvas({ html, baseCss = "", onSelect }) {
5061
5175
  window2.cancelAnimationFrame(animationFrame);
5062
5176
  };
5063
5177
  }, []);
5064
- useEffect6(() => () => {
5178
+ useEffect7(() => () => {
5065
5179
  var _a;
5066
5180
  return (_a = cleanupRef.current) == null ? void 0 : _a.call(cleanupRef);
5067
5181
  }, [hasHtmlTag, html]);
@@ -5233,6 +5347,7 @@ function canvasBlockStyle(block, body) {
5233
5347
  // src/stage/canvas/CanvasBlock.tsx
5234
5348
  import { jsx as jsx51, jsxs as jsxs40 } from "react/jsx-runtime";
5235
5349
  function CanvasBlock({
5350
+ active = true,
5236
5351
  block: sourceBlock,
5237
5352
  body,
5238
5353
  definition,
@@ -5251,9 +5366,9 @@ function CanvasBlock({
5251
5366
  const block = useMemo3(() => __spreadProps(__spreadValues({}, sourceBlock), { style: resolveBlockStyle(sourceBlock.style, definition) }), [sourceBlock, definition]);
5252
5367
  const renderKey = useMemo3(() => ({ definition, templateEngine, variables }), [definition, templateEngine, variables]);
5253
5368
  const toolbarId = useId13();
5254
- const [toolbarCollapsed, setToolbarCollapsed] = useState16(false);
5369
+ const [toolbarCollapsed, setToolbarCollapsed] = useState17(false);
5255
5370
  const dragData = { kind: "canvas" };
5256
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: block.id, data: dragData });
5371
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: block.id, data: dragData, disabled: !active });
5257
5372
  const select = (event) => {
5258
5373
  event == null ? void 0 : event.stopPropagation();
5259
5374
  onSelect();
@@ -5323,6 +5438,7 @@ function CanvasBlock({
5323
5438
  // src/stage/canvas/Canvas.tsx
5324
5439
  import { jsx as jsx52 } from "react/jsx-runtime";
5325
5440
  function Canvas({
5441
+ active = true,
5326
5442
  blocks,
5327
5443
  body,
5328
5444
  definitions,
@@ -5341,6 +5457,7 @@ function Canvas({
5341
5457
  }) {
5342
5458
  const { setNodeRef, isOver } = useDroppable({
5343
5459
  id: CANVAS_DROP_ID,
5460
+ disabled: !active,
5344
5461
  data: { kind: "canvas" }
5345
5462
  });
5346
5463
  return /* @__PURE__ */ jsx52(
@@ -5355,9 +5472,9 @@ function Canvas({
5355
5472
  if (!ownerWindow || !(target instanceof ownerWindow.Element)) return;
5356
5473
  if (target.closest(".ree-block")) return;
5357
5474
  onSelect(null);
5358
- const active = ownerDocument.activeElement;
5359
- if (active instanceof ownerWindow.HTMLElement && active.closest(".ree-rte")) {
5360
- active.blur();
5475
+ const active2 = ownerDocument.activeElement;
5476
+ if (active2 instanceof ownerWindow.HTMLElement && active2.closest(".ree-rte")) {
5477
+ active2.blur();
5361
5478
  }
5362
5479
  },
5363
5480
  children: /* @__PURE__ */ jsx52(
@@ -5392,6 +5509,7 @@ function Canvas({
5392
5509
  CanvasBlock,
5393
5510
  {
5394
5511
  block,
5512
+ active,
5395
5513
  body,
5396
5514
  definition: getBlockDefinition(block.type, definitions),
5397
5515
  selected: block.id === selectedId,
@@ -5420,84 +5538,134 @@ function Canvas({
5420
5538
  }
5421
5539
 
5422
5540
  // src/stage/preview/Preview.tsx
5423
- import { useCallback as useCallback2, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef14 } from "react";
5541
+ import { useRef as useRef16 } from "react";
5542
+
5543
+ // src/stage/preview/PreviewFrame.tsx
5544
+ import { useLayoutEffect as useLayoutEffect10, useRef as useRef15 } from "react";
5424
5545
  import { jsx as jsx53 } from "react/jsx-runtime";
5425
- function Preview({ document, config, variables, device }) {
5426
- const frameRef = useRef14(null);
5427
- const cleanupRef = useRef14();
5428
- const html = useMemo4(
5429
- () => config.toHtml(document, {
5430
- resolveHtmlTemplates: true,
5431
- variables
5432
- }),
5433
- [document, config, variables]
5434
- );
5435
- const observeContent = useCallback2(() => {
5436
- var _a;
5437
- (_a = cleanupRef.current) == null ? void 0 : _a.call(cleanupRef);
5546
+ function PreviewFrame({ snapshot, onReady, onFailed }) {
5547
+ const frameRef = useRef15(null);
5548
+ const { id, html } = snapshot;
5549
+ useLayoutEffect10(() => {
5438
5550
  const frame = frameRef.current;
5439
- const frameDocument = frame == null ? void 0 : frame.contentDocument;
5440
- const frameWindow = frameDocument == null ? void 0 : frameDocument.defaultView;
5441
- if (!frame || !(frameDocument == null ? void 0 : frameDocument.body) || !frameWindow) return;
5442
- let animationFrame = 0;
5443
- const fit = () => {
5444
- frame.style.height = `${frameDocument.body.getBoundingClientRect().height}px`;
5551
+ const ownerWindow = frame == null ? void 0 : frame.ownerDocument.defaultView;
5552
+ if (!frame || !ownerWindow) return;
5553
+ let status = "loading";
5554
+ let stopObserving;
5555
+ const fail = (message) => {
5556
+ if (status !== "loading") return;
5557
+ status = "failed";
5558
+ clearTimeout(timer);
5559
+ stopObserving == null ? void 0 : stopObserving();
5560
+ stopObserving = void 0;
5561
+ onFailed(id, message);
5445
5562
  };
5446
- const scheduleFit = () => {
5447
- frameWindow.cancelAnimationFrame(animationFrame);
5448
- animationFrame = frameWindow.requestAnimationFrame(fit);
5563
+ const timer = setTimeout(() => fail("Email preview timed out. Please retry."), PREVIEW_LOAD_TIMEOUT_MS);
5564
+ const observeContent = () => {
5565
+ if (status !== "loading") return;
5566
+ stopObserving == null ? void 0 : stopObserving();
5567
+ stopObserving = void 0;
5568
+ try {
5569
+ const frameDocument = frame.contentDocument;
5570
+ const frameWindow = frameDocument == null ? void 0 : frameDocument.defaultView;
5571
+ if (!(frameDocument == null ? void 0 : frameDocument.body) || !frameWindow) throw new Error("Email preview could not be measured. Please retry.");
5572
+ let animationFrame = 0;
5573
+ let readyFrame = 0;
5574
+ const fit = () => {
5575
+ try {
5576
+ const height = frameDocument.body.getBoundingClientRect().height;
5577
+ if (!Number.isFinite(height) || height < 0) throw new Error("Email preview returned an invalid height. Please retry.");
5578
+ frame.style.height = `${Math.max(1, height)}px`;
5579
+ return true;
5580
+ } catch (error) {
5581
+ fail(error instanceof Error ? error.message : "Email preview could not be measured. Please retry.");
5582
+ return false;
5583
+ }
5584
+ };
5585
+ const scheduleFit = () => {
5586
+ ownerWindow.cancelAnimationFrame(animationFrame);
5587
+ animationFrame = ownerWindow.requestAnimationFrame(fit);
5588
+ };
5589
+ const observer = new frameWindow.ResizeObserver(scheduleFit);
5590
+ stopObserving = () => {
5591
+ observer.disconnect();
5592
+ frameDocument.removeEventListener("load", scheduleFit, true);
5593
+ ownerWindow.cancelAnimationFrame(animationFrame);
5594
+ ownerWindow.cancelAnimationFrame(readyFrame);
5595
+ };
5596
+ observer.observe(frameDocument.body);
5597
+ frameDocument.addEventListener("load", scheduleFit, true);
5598
+ if (!fit()) return;
5599
+ readyFrame = ownerWindow.requestAnimationFrame(() => {
5600
+ if (status !== "loading" || !fit()) return;
5601
+ status = "ready";
5602
+ clearTimeout(timer);
5603
+ onReady(id);
5604
+ });
5605
+ } catch (error) {
5606
+ fail(error instanceof Error ? error.message : "Email preview could not be loaded. Please retry.");
5607
+ }
5449
5608
  };
5450
- const observer = new frameWindow.ResizeObserver(scheduleFit);
5451
- observer.observe(frameDocument.body);
5452
- frameDocument.addEventListener("load", scheduleFit, true);
5453
- fit();
5454
- cleanupRef.current = () => {
5455
- observer.disconnect();
5456
- frameDocument.removeEventListener("load", scheduleFit, true);
5457
- frameWindow.cancelAnimationFrame(animationFrame);
5609
+ const loadError = () => fail("Email preview could not be loaded. Please retry.");
5610
+ frame.addEventListener("load", observeContent);
5611
+ frame.addEventListener("error", loadError);
5612
+ return () => {
5613
+ status = "disposed";
5614
+ clearTimeout(timer);
5615
+ stopObserving == null ? void 0 : stopObserving();
5616
+ frame.removeEventListener("load", observeContent);
5617
+ frame.removeEventListener("error", loadError);
5458
5618
  };
5459
- }, []);
5460
- useEffect7(() => () => {
5461
- var _a;
5462
- return (_a = cleanupRef.current) == null ? void 0 : _a.call(cleanupRef);
5463
- }, [html]);
5464
- return /* @__PURE__ */ jsx53("div", { className: "ree-preview-stage", children: /* @__PURE__ */ jsx53(
5465
- "div",
5466
- {
5467
- className: "ree-email-viewport",
5468
- style: {
5469
- width: viewportWidth(device, document.body),
5470
- marginLeft: "auto",
5471
- marginRight: "auto"
5472
- },
5473
- children: /* @__PURE__ */ jsx53(
5474
- "iframe",
5475
- {
5476
- ref: frameRef,
5477
- className: "ree-preview-frame",
5478
- title: "Email preview",
5479
- srcDoc: html,
5480
- onLoad: observeContent,
5481
- style: {
5482
- backgroundColor: "#ffffff"
5483
- }
5484
- }
5485
- )
5486
- }
5487
- ) });
5619
+ }, [id, onReady, onFailed]);
5620
+ return /* @__PURE__ */ jsx53("iframe", { ref: frameRef, className: "ree-preview-frame", title: "Email preview", sandbox: "allow-same-origin", srcDoc: html });
5488
5621
  }
5489
5622
 
5490
- // src/stage/StageHeader.tsx
5623
+ // src/stage/preview/Preview.tsx
5491
5624
  import { jsx as jsx54, jsxs as jsxs41 } from "react/jsx-runtime";
5625
+ function Preview({ frames, front, failure, onReady, onFailed, onRetry }) {
5626
+ const scrollRef = useRef16({ top: 0, left: 0 });
5627
+ return /* @__PURE__ */ jsxs41("div", { className: "ree-preview-buffer", children: [
5628
+ PREVIEW_BUFFER_SLOTS.map((index) => {
5629
+ const snapshot = frames[index];
5630
+ const visible = front === index;
5631
+ return /* @__PURE__ */ jsx54(
5632
+ "div",
5633
+ __spreadProps(__spreadValues({
5634
+ className: `ree-preview-stage${visible ? "" : " is-buffered"}`,
5635
+ "aria-hidden": !visible
5636
+ }, !visible ? { inert: "" } : {}), {
5637
+ ref: (node) => {
5638
+ if (node && visible) {
5639
+ node.scrollTop = scrollRef.current.top;
5640
+ node.scrollLeft = scrollRef.current.left;
5641
+ }
5642
+ },
5643
+ onScroll: (event) => {
5644
+ if (visible) scrollRef.current = { top: event.currentTarget.scrollTop, left: event.currentTarget.scrollLeft };
5645
+ },
5646
+ children: /* @__PURE__ */ jsx54("div", { className: "ree-email-viewport", style: { width: snapshot == null ? void 0 : snapshot.width, marginLeft: "auto", marginRight: "auto" }, children: snapshot ? /* @__PURE__ */ jsx54(PreviewFrame, { snapshot, onReady, onFailed }, snapshot.id) : null })
5647
+ }),
5648
+ index
5649
+ );
5650
+ }),
5651
+ failure ? /* @__PURE__ */ jsxs41("div", { className: "ree-plugin-error ree-preview-error", role: "alert", children: [
5652
+ /* @__PURE__ */ jsx54("p", { children: failure.message }),
5653
+ /* @__PURE__ */ jsx54(Button, { onClick: onRetry, children: "Retry" })
5654
+ ] }) : null
5655
+ ] });
5656
+ }
5657
+
5658
+ // src/stage/StageHeader.tsx
5659
+ import { jsx as jsx55, jsxs as jsxs42 } from "react/jsx-runtime";
5492
5660
  function StageHeader({
5493
5661
  viewMode,
5494
5662
  device,
5495
5663
  onViewModeChange,
5496
5664
  onDeviceChange
5497
5665
  }) {
5498
- return /* @__PURE__ */ jsxs41("div", { className: "ree-col-head ree-stage-head", children: [
5499
- /* @__PURE__ */ jsxs41("div", { className: "ree-segment", role: "group", "aria-label": "Editor mode", children: [
5500
- /* @__PURE__ */ jsx54(
5666
+ return /* @__PURE__ */ jsxs42("div", { className: "ree-col-head ree-stage-head", children: [
5667
+ /* @__PURE__ */ jsxs42("div", { className: "ree-segment", role: "group", "aria-label": "Editor mode", children: [
5668
+ /* @__PURE__ */ jsx55(
5501
5669
  "button",
5502
5670
  {
5503
5671
  type: "button",
@@ -5506,14 +5674,14 @@ function StageHeader({
5506
5674
  "aria-label": "Edit",
5507
5675
  title: "Edit",
5508
5676
  onClick: () => onViewModeChange("edit"),
5509
- children: /* @__PURE__ */ jsxs41("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5510
- /* @__PURE__ */ jsx54("path", { d: "M12 20h9" }),
5511
- /* @__PURE__ */ jsx54("path", { d: "M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4 11.5-11.5Z" })
5677
+ children: /* @__PURE__ */ jsxs42("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5678
+ /* @__PURE__ */ jsx55("path", { d: "M12 20h9" }),
5679
+ /* @__PURE__ */ jsx55("path", { d: "M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4 11.5-11.5Z" })
5512
5680
  ] })
5513
5681
  }
5514
5682
  ),
5515
- /* @__PURE__ */ jsx54("span", { className: "ree-segment-divider", "aria-hidden": "true" }),
5516
- /* @__PURE__ */ jsx54(
5683
+ /* @__PURE__ */ jsx55("span", { className: "ree-segment-divider", "aria-hidden": "true" }),
5684
+ /* @__PURE__ */ jsx55(
5517
5685
  "button",
5518
5686
  {
5519
5687
  type: "button",
@@ -5522,15 +5690,15 @@ function StageHeader({
5522
5690
  "aria-label": "Preview",
5523
5691
  title: "Preview",
5524
5692
  onClick: () => onViewModeChange("preview"),
5525
- children: /* @__PURE__ */ jsxs41("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5526
- /* @__PURE__ */ jsx54("path", { d: "M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" }),
5527
- /* @__PURE__ */ jsx54("circle", { cx: "12", cy: "12", r: "2.5" })
5693
+ children: /* @__PURE__ */ jsxs42("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5694
+ /* @__PURE__ */ jsx55("path", { d: "M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" }),
5695
+ /* @__PURE__ */ jsx55("circle", { cx: "12", cy: "12", r: "2.5" })
5528
5696
  ] })
5529
5697
  }
5530
5698
  )
5531
5699
  ] }),
5532
- /* @__PURE__ */ jsxs41("div", { className: "ree-segment", role: "group", "aria-label": "Canvas device", children: [
5533
- /* @__PURE__ */ jsx54(
5700
+ /* @__PURE__ */ jsxs42("div", { className: "ree-segment", role: "group", "aria-label": "Canvas device", children: [
5701
+ /* @__PURE__ */ jsx55(
5534
5702
  "button",
5535
5703
  {
5536
5704
  type: "button",
@@ -5539,15 +5707,15 @@ function StageHeader({
5539
5707
  "aria-label": "Desktop",
5540
5708
  title: "Desktop",
5541
5709
  onClick: () => onDeviceChange("desktop"),
5542
- children: /* @__PURE__ */ jsxs41("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5543
- /* @__PURE__ */ jsx54("rect", { x: "3", y: "4", width: "18", height: "12", rx: "1.5" }),
5544
- /* @__PURE__ */ jsx54("path", { d: "M8 20h8" }),
5545
- /* @__PURE__ */ jsx54("path", { d: "M12 16v4" })
5710
+ children: /* @__PURE__ */ jsxs42("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5711
+ /* @__PURE__ */ jsx55("rect", { x: "3", y: "4", width: "18", height: "12", rx: "1.5" }),
5712
+ /* @__PURE__ */ jsx55("path", { d: "M8 20h8" }),
5713
+ /* @__PURE__ */ jsx55("path", { d: "M12 16v4" })
5546
5714
  ] })
5547
5715
  }
5548
5716
  ),
5549
- /* @__PURE__ */ jsx54("span", { className: "ree-segment-divider", "aria-hidden": "true" }),
5550
- /* @__PURE__ */ jsx54(
5717
+ /* @__PURE__ */ jsx55("span", { className: "ree-segment-divider", "aria-hidden": "true" }),
5718
+ /* @__PURE__ */ jsx55(
5551
5719
  "button",
5552
5720
  {
5553
5721
  type: "button",
@@ -5556,9 +5724,9 @@ function StageHeader({
5556
5724
  "aria-label": "Mobile",
5557
5725
  title: "Mobile",
5558
5726
  onClick: () => onDeviceChange("mobile"),
5559
- children: /* @__PURE__ */ jsxs41("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5560
- /* @__PURE__ */ jsx54("rect", { x: "7", y: "2.5", width: "10", height: "19", rx: "2" }),
5561
- /* @__PURE__ */ jsx54("path", { d: "M11 18.5h2" })
5727
+ children: /* @__PURE__ */ jsxs42("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
5728
+ /* @__PURE__ */ jsx55("rect", { x: "7", y: "2.5", width: "10", height: "19", rx: "2" }),
5729
+ /* @__PURE__ */ jsx55("path", { d: "M11 18.5h2" })
5562
5730
  ] })
5563
5731
  }
5564
5732
  )
@@ -5567,7 +5735,7 @@ function StageHeader({
5567
5735
  }
5568
5736
 
5569
5737
  // src/stage/Stage.tsx
5570
- import { jsx as jsx55, jsxs as jsxs42 } from "react/jsx-runtime";
5738
+ import { jsx as jsx56, jsxs as jsxs43 } from "react/jsx-runtime";
5571
5739
  function Stage({
5572
5740
  session,
5573
5741
  viewMode,
@@ -5576,64 +5744,70 @@ function Stage({
5576
5744
  onDeviceChange,
5577
5745
  onSelectBlock
5578
5746
  }) {
5579
- const previewKey = useMemo5(() => ({ config: session.config, variables: session.variables }), [session.config, session.variables]);
5747
+ const source = useMemo4(() => ({ document: session.document, config: session.config, variables: session.variables, device }), [session.document, session.config, session.variables, device]);
5748
+ const requested = viewMode === "preview";
5749
+ const preview = usePreviewBuffer(source, requested);
5750
+ const [previewVisible, setPreviewVisible] = useState18(false);
5751
+ if (!requested && previewVisible) setPreviewVisible(false);
5752
+ if (requested && !previewVisible && (preview.ready || preview.failure)) setPreviewVisible(true);
5753
+ const editing = !requested;
5580
5754
  const { targetId, dropEdge } = useEditorDragState();
5581
- return /* @__PURE__ */ jsxs42("div", { className: "ree-col ree-col-center", children: [
5582
- /* @__PURE__ */ jsx55(
5755
+ return /* @__PURE__ */ jsxs43("div", { className: "ree-col ree-col-center", children: [
5756
+ /* @__PURE__ */ jsx56(
5583
5757
  StageHeader,
5584
5758
  {
5585
5759
  viewMode,
5586
5760
  device,
5587
- onViewModeChange,
5761
+ onViewModeChange: (mode) => {
5762
+ if (mode === "preview") preview.flush();
5763
+ onViewModeChange(mode);
5764
+ },
5588
5765
  onDeviceChange
5589
5766
  }
5590
5767
  ),
5591
- viewMode === "edit" ? /* @__PURE__ */ jsx55("div", { className: "ree-stage-view", children: /* @__PURE__ */ jsx55(
5592
- Canvas,
5593
- {
5594
- blocks: session.document.blocks,
5595
- body: session.document.body,
5596
- definitions: session.definitions,
5597
- selectedId: session.selectedId,
5598
- dropTargetId: targetId,
5599
- dropEdge,
5600
- variables: session.variables,
5601
- templateEngine: session.config.templateEngine,
5602
- device,
5603
- onSelect: onSelectBlock,
5604
- onCommitControl: session.updateControl,
5605
- onCommitControls: session.updateControls,
5606
- onCommitStyle: session.updateStyle,
5607
- onDuplicate: session.duplicateBlock,
5608
- onRemove: session.removeBlock
5609
- }
5610
- ) }) : /* @__PURE__ */ jsx55("div", { className: "ree-stage-view", children: /* @__PURE__ */ jsx55(BlockErrorBoundary, { identity: session.document, resetKey: previewKey, children: /* @__PURE__ */ jsx55(
5611
- Preview,
5612
- {
5613
- document: session.document,
5614
- config: session.config,
5615
- variables: session.variables,
5616
- device
5617
- }
5618
- ) }) })
5768
+ /* @__PURE__ */ jsxs43("div", { className: "ree-stage-content", "aria-busy": requested && !preview.ready && !preview.failure, children: [
5769
+ /* @__PURE__ */ jsx56("div", __spreadProps(__spreadValues({ className: `ree-stage-view${previewVisible ? " is-buffered" : ""}`, "aria-hidden": previewVisible }, !editing ? { inert: "" } : {}), { children: /* @__PURE__ */ jsx56(
5770
+ Canvas,
5771
+ {
5772
+ blocks: session.document.blocks,
5773
+ body: session.document.body,
5774
+ definitions: session.definitions,
5775
+ active: editing,
5776
+ selectedId: editing ? session.selectedId : null,
5777
+ dropTargetId: targetId,
5778
+ dropEdge,
5779
+ variables: session.variables,
5780
+ templateEngine: session.config.templateEngine,
5781
+ device,
5782
+ onSelect: onSelectBlock,
5783
+ onCommitControl: session.updateControl,
5784
+ onCommitControls: session.updateControls,
5785
+ onCommitStyle: session.updateStyle,
5786
+ onDuplicate: session.duplicateBlock,
5787
+ onRemove: session.removeBlock
5788
+ }
5789
+ ) })),
5790
+ /* @__PURE__ */ jsx56("div", __spreadProps(__spreadValues({ className: `ree-stage-view${previewVisible ? "" : " is-buffered"}`, "aria-hidden": !previewVisible }, !previewVisible ? { inert: "" } : {}), { children: /* @__PURE__ */ jsx56(Preview, { frames: preview.frames, front: preview.front, failure: preview.displayFailure, onReady: preview.markReady, onFailed: preview.markFailed, onRetry: preview.retry }) })),
5791
+ requested && !preview.ready && !preview.failure ? /* @__PURE__ */ jsx56("div", { className: "ree-preview-pending", role: "status", children: "Preparing preview\u2026" }) : null
5792
+ ] })
5619
5793
  ] });
5620
5794
  }
5621
5795
 
5622
5796
  // src/shell/SubjectBar.tsx
5623
- import { useId as useId14, useRef as useRef15 } from "react";
5797
+ import { useId as useId14, useRef as useRef17 } from "react";
5624
5798
 
5625
5799
  // src/shell/constants.ts
5626
5800
  var EMPTY_VARIABLES = [];
5627
5801
  var PERSONALIZATION_HINT = "Personalization fields will appear blank if no data is available.";
5628
5802
 
5629
5803
  // src/shell/SubjectBar.tsx
5630
- import { Fragment as Fragment5, jsx as jsx56, jsxs as jsxs43 } from "react/jsx-runtime";
5804
+ import { Fragment as Fragment5, jsx as jsx57, jsxs as jsxs44 } from "react/jsx-runtime";
5631
5805
  function SubjectBar({
5632
5806
  subject,
5633
5807
  variables,
5634
5808
  onSubjectChange
5635
5809
  }) {
5636
- const inputRef = useRef15(null);
5810
+ const inputRef = useRef17(null);
5637
5811
  const subjectId = useId14();
5638
5812
  const countId = useId14();
5639
5813
  const insertVariable = (token) => {
@@ -5653,10 +5827,10 @@ function SubjectBar({
5653
5827
  (_b2 = inputRef.current) == null ? void 0 : _b2.setSelectionRange(cursor, cursor);
5654
5828
  });
5655
5829
  };
5656
- return /* @__PURE__ */ jsxs43("div", { className: "ree-subject-bar", children: [
5657
- /* @__PURE__ */ jsx56("label", { className: "ree-subject-label", htmlFor: subjectId, children: "Email subject" }),
5658
- /* @__PURE__ */ jsxs43("div", { className: "ree-subject-field", children: [
5659
- /* @__PURE__ */ jsx56(
5830
+ return /* @__PURE__ */ jsxs44("div", { className: "ree-subject-bar", children: [
5831
+ /* @__PURE__ */ jsx57("label", { className: "ree-subject-label", htmlFor: subjectId, children: "Email subject" }),
5832
+ /* @__PURE__ */ jsxs44("div", { className: "ree-subject-field", children: [
5833
+ /* @__PURE__ */ jsx57(
5660
5834
  TextInput,
5661
5835
  {
5662
5836
  id: subjectId,
@@ -5668,21 +5842,21 @@ function SubjectBar({
5668
5842
  onChange: (event) => onSubjectChange(event.target.value)
5669
5843
  }
5670
5844
  ),
5671
- /* @__PURE__ */ jsxs43("span", { id: countId, className: "ree-subject-count", children: [
5845
+ /* @__PURE__ */ jsxs44("span", { id: countId, className: "ree-subject-count", children: [
5672
5846
  subject.length,
5673
5847
  "/",
5674
5848
  SUBJECT_MAX_LENGTH
5675
5849
  ] })
5676
5850
  ] }),
5677
- variables.length > 0 ? /* @__PURE__ */ jsxs43(Fragment5, { children: [
5678
- /* @__PURE__ */ jsx56(PersonalizationMenu, { variables, onInsert: insertVariable }),
5679
- /* @__PURE__ */ jsx56("p", { className: "ree-subject-hint", children: PERSONALIZATION_HINT })
5851
+ variables.length > 0 ? /* @__PURE__ */ jsxs44(Fragment5, { children: [
5852
+ /* @__PURE__ */ jsx57(PersonalizationMenu, { variables, onInsert: insertVariable }),
5853
+ /* @__PURE__ */ jsx57("p", { className: "ree-subject-hint", children: PERSONALIZATION_HINT })
5680
5854
  ] }) : null
5681
5855
  ] });
5682
5856
  }
5683
5857
 
5684
5858
  // src/shell/useEditorSession.ts
5685
- import { useCallback as useCallback3, useState as useState17, useSyncExternalStore } from "react";
5859
+ import { useCallback as useCallback3, useState as useState19, useSyncExternalStore } from "react";
5686
5860
 
5687
5861
  // src/utils/id.ts
5688
5862
  function generateId(prefix = "block") {
@@ -5997,8 +6171,8 @@ function useEditorSession(editor) {
5997
6171
  const store = getEditorStore(editor);
5998
6172
  const { document, config, variables, resetKey } = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
5999
6173
  const definitions = config.definitions;
6000
- const [selectedId, setSelectedId] = useState17(null);
6001
- const [previousReset, setPreviousReset] = useState17({ editor, resetKey });
6174
+ const [selectedId, setSelectedId] = useState19(null);
6175
+ const [previousReset, setPreviousReset] = useState19({ editor, resetKey });
6002
6176
  if (previousReset.editor !== editor || previousReset.resetKey !== resetKey) {
6003
6177
  setPreviousReset({ editor, resetKey });
6004
6178
  setSelectedId(null);
@@ -6099,21 +6273,21 @@ function useEditorSession(editor) {
6099
6273
  }
6100
6274
 
6101
6275
  // src/shell/EmailEditorShell.tsx
6102
- import { jsx as jsx57, jsxs as jsxs44 } from "react/jsx-runtime";
6276
+ import { jsx as jsx58, jsxs as jsxs45 } from "react/jsx-runtime";
6103
6277
  function EmailEditorShell({ editor, height = "100%" }) {
6104
6278
  var _a;
6105
6279
  const session = useEditorSession(editor);
6106
- const [activeRichText, setActiveRichText] = useState18(null);
6107
- const [viewMode, setViewMode] = useState18("edit");
6108
- const [device, setDevice] = useState18("desktop");
6109
- const [inspectorPanel, setInspectorPanel] = useState18("body");
6110
- const [previousReset, setPreviousReset] = useState18({ editor, key: session.resetKey });
6280
+ const [activeRichText, setActiveRichText] = useState20(null);
6281
+ const [viewMode, setViewMode] = useState20("edit");
6282
+ const [device, setDevice] = useState20("desktop");
6283
+ const [inspectorPanel, setInspectorPanel] = useState20("body");
6284
+ const [previousReset, setPreviousReset] = useState20({ editor, key: session.resetKey });
6111
6285
  if (previousReset.editor !== editor || previousReset.key !== session.resetKey) {
6112
6286
  setPreviousReset({ editor, key: session.resetKey });
6113
6287
  setActiveRichText(null);
6114
6288
  setInspectorPanel("body");
6115
6289
  }
6116
- const [previousSelectedId, setPreviousSelectedId] = useState18(session.selectedId);
6290
+ const [previousSelectedId, setPreviousSelectedId] = useState20(session.selectedId);
6117
6291
  if (previousSelectedId !== session.selectedId) {
6118
6292
  setPreviousSelectedId(session.selectedId);
6119
6293
  if (!session.selectedId) setInspectorPanel("body");
@@ -6137,8 +6311,8 @@ function EmailEditorShell({ editor, height = "100%" }) {
6137
6311
  selectBlock(null);
6138
6312
  };
6139
6313
  const showCanvas = () => setViewMode("edit");
6140
- return /* @__PURE__ */ jsx57(EditorConfigContext.Provider, { value: session.config, children: /* @__PURE__ */ jsx57(RichTextFocusContext.Provider, { value: { active: activeRichText, activate: setActiveRichText }, children: /* @__PURE__ */ jsxs44("div", { className: "ree-root", style: { height }, onKeyDown: handleKeyDown, children: [
6141
- /* @__PURE__ */ jsx57(
6314
+ return /* @__PURE__ */ jsx58(EditorConfigContext.Provider, { value: session.config, children: /* @__PURE__ */ jsx58(RichTextFocusContext.Provider, { value: { active: activeRichText, activate: setActiveRichText }, children: /* @__PURE__ */ jsxs45("div", { className: "ree-root", style: { height }, onKeyDown: handleKeyDown, children: [
6315
+ /* @__PURE__ */ jsx58(
6142
6316
  SubjectBar,
6143
6317
  {
6144
6318
  subject: (_a = session.document.subject) != null ? _a : "",
@@ -6146,8 +6320,8 @@ function EmailEditorShell({ editor, height = "100%" }) {
6146
6320
  onSubjectChange: session.setSubject
6147
6321
  }
6148
6322
  ),
6149
- /* @__PURE__ */ jsxs44("div", { className: "ree-body", children: [
6150
- /* @__PURE__ */ jsxs44(
6323
+ /* @__PURE__ */ jsxs45("div", { className: "ree-body", children: [
6324
+ /* @__PURE__ */ jsxs45(
6151
6325
  EditorDnd,
6152
6326
  {
6153
6327
  blocks: session.document.blocks,
@@ -6159,7 +6333,7 @@ function EmailEditorShell({ editor, height = "100%" }) {
6159
6333
  onReorder: session.reorderBlocks,
6160
6334
  onLibraryDragStart: showCanvas,
6161
6335
  children: [
6162
- /* @__PURE__ */ jsx57(
6336
+ /* @__PURE__ */ jsx58(
6163
6337
  BlockLibrary,
6164
6338
  {
6165
6339
  definitions: session.config.library,
@@ -6169,7 +6343,7 @@ function EmailEditorShell({ editor, height = "100%" }) {
6169
6343
  }
6170
6344
  }
6171
6345
  ),
6172
- /* @__PURE__ */ jsx57(
6346
+ /* @__PURE__ */ jsx58(
6173
6347
  Stage,
6174
6348
  {
6175
6349
  session,
@@ -6183,7 +6357,7 @@ function EmailEditorShell({ editor, height = "100%" }) {
6183
6357
  ]
6184
6358
  }
6185
6359
  ),
6186
- /* @__PURE__ */ jsx57(
6360
+ /* @__PURE__ */ jsx58(
6187
6361
  Inspector,
6188
6362
  {
6189
6363
  panel: inspectorPanel,
@@ -6203,23 +6377,23 @@ function EmailEditorShell({ editor, height = "100%" }) {
6203
6377
  }
6204
6378
 
6205
6379
  // src/EmailEditor.tsx
6206
- import { jsx as jsx58 } from "react/jsx-runtime";
6380
+ import { jsx as jsx59 } from "react/jsx-runtime";
6207
6381
  function EmailEditor(props) {
6208
- return /* @__PURE__ */ jsx58(EmailEditorShell, __spreadValues({}, props));
6382
+ return /* @__PURE__ */ jsx59(EmailEditorShell, __spreadValues({}, props));
6209
6383
  }
6210
6384
 
6211
6385
  // src/plugins/useEmailEditorConfig.ts
6212
- import { useMemo as useMemo6 } from "react";
6386
+ import { useMemo as useMemo5 } from "react";
6213
6387
  function useEmailEditorConfig(options = {}) {
6214
6388
  const { plugins, builtins, pickImage, templateEngine } = options;
6215
- return useMemo6(() => buildEmailEditorConfig({ plugins, builtins, pickImage, templateEngine }), [plugins, builtins, pickImage, templateEngine]);
6389
+ return useMemo5(() => buildEmailEditorConfig({ plugins, builtins, pickImage, templateEngine }), [plugins, builtins, pickImage, templateEngine]);
6216
6390
  }
6217
6391
 
6218
6392
  // src/shell/useEmailEditor.ts
6219
- import { useEffect as useEffect8, useInsertionEffect, useLayoutEffect as useLayoutEffect9, useState as useState19 } from "react";
6220
- var useOptionsEffect = typeof window === "undefined" ? useEffect8 : useLayoutEffect9;
6393
+ import { useEffect as useEffect8, useInsertionEffect, useLayoutEffect as useLayoutEffect11, useState as useState21 } from "react";
6394
+ var useOptionsEffect = typeof window === "undefined" ? useEffect8 : useLayoutEffect11;
6221
6395
  function useEmailEditor(options = {}) {
6222
- const [store] = useState19(() => createEditorStore(options));
6396
+ const [store] = useState21(() => createEditorStore(options));
6223
6397
  useInsertionEffect(() => {
6224
6398
  store.updateOptions(options);
6225
6399
  });
@@ -6235,12 +6409,12 @@ function useEmailEditorState(editor) {
6235
6409
  }
6236
6410
 
6237
6411
  // src/plugins/components/ControlEditor.tsx
6238
- import { jsx as jsx59 } from "react/jsx-runtime";
6412
+ import { jsx as jsx60 } from "react/jsx-runtime";
6239
6413
  function ControlEditor({ control }) {
6240
6414
  const context = usePluginEditor();
6241
6415
  const definition = context.controlDefinitions.find((item) => item.name === control);
6242
6416
  if (!definition) throw new Error(`Unknown plugin control: ${control}`);
6243
- return /* @__PURE__ */ jsx59(ControlRenderer, { control: definition, value: context.controls[control], body: context.body, variables: context.variables, onChange: (value) => context.setControl(control, value) });
6417
+ return /* @__PURE__ */ jsx60(ControlRenderer, { control: definition, value: context.controls[control], body: context.body, variables: context.variables, onChange: (value) => context.setControl(control, value) });
6244
6418
  }
6245
6419
  export {
6246
6420
  BLOCK_TYPES,
package/dist/styles.css CHANGED
@@ -1007,6 +1007,38 @@
1007
1007
 
1008
1008
  /* ---------- 画布 ---------- */
1009
1009
 
1010
+ .ree-stage-content,
1011
+ .ree-preview-buffer {
1012
+ position: relative;
1013
+ display: flex;
1014
+ flex: 1;
1015
+ min-width: 0;
1016
+ min-height: 0;
1017
+ isolation: isolate;
1018
+ }
1019
+
1020
+ .ree-stage-view.is-buffered,
1021
+ .ree-preview-stage.is-buffered {
1022
+ position: absolute;
1023
+ inset: 0;
1024
+ visibility: hidden;
1025
+ pointer-events: none;
1026
+ }
1027
+
1028
+ .ree-preview-pending {
1029
+ position: absolute;
1030
+ top: 12px;
1031
+ left: 50%;
1032
+ transform: translateX(-50%);
1033
+ padding: 6px 12px;
1034
+ border: 1px solid var(--ree-border);
1035
+ border-radius: 6px;
1036
+ background: var(--ree-panel);
1037
+ color: var(--ree-muted);
1038
+ font-size: 12px;
1039
+ pointer-events: none;
1040
+ }
1041
+
1010
1042
  .ree-stage-view {
1011
1043
  display: flex;
1012
1044
  flex: 1;
@@ -1090,6 +1122,18 @@
1090
1122
  box-shadow: var(--ree-shadow-md);
1091
1123
  }
1092
1124
 
1125
+ .ree-preview-error {
1126
+ position: absolute;
1127
+ top: 12px;
1128
+ left: 50%;
1129
+ transform: translateX(-50%);
1130
+ z-index: 1;
1131
+ max-width: calc(100% - 24px);
1132
+ border: 1px solid var(--ree-border);
1133
+ border-radius: 6px;
1134
+ background: var(--ree-panel);
1135
+ }
1136
+
1093
1137
  /* ---------- 区块 ---------- */
1094
1138
 
1095
1139
  .ree-block {
package/docs/changelog.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  English | [简体中文](./changelog.zh-CN.md)
4
4
 
5
+ ## 0.5.2 — 2026-10-29
6
+
7
+ ### Fixes and improvements
8
+
9
+ - Fix canvas recreation and flicker when switching between editing and preview. Keep the editing canvas mounted and swap two iframe buffers after standby loading and height measurement, preserving separate editing and preview scroll positions. Exclude the hidden canvas from focus, keyboard and drag interactions while previewing.
10
+ - Debounce document, configuration, variable and device-width changes for 400ms with a maximum wait of 2 seconds and idle scheduling support. Process Preview and Retry clicks immediately while continuing to debounce subsequent changes in preview mode. Reuse ready or loading buffers with identical HTML and width to avoid remounts.
11
+ - Use `sandbox="allow-same-origin"` on preview iframes to block scripts and event handlers while retaining height measurement. Preserve exported HTML; this restriction is not complete HTML sanitization.
12
+ - Handle generation, load and measurement errors, including a 15-second load/measurement timeout, with error messages and Retry. Retain existing previews on failure and force a new load on retry. Prevent callbacks from stale, replaced or failed buffers from overwriting current content, and clean up timers, observers, listeners and animation frames on unmount.
13
+ - Update both template-engine guides. Bump the npm package from `0.5.1` to `0.5.2` as a patch, preserving the public API, email export and persisted document format. Preview no longer executes scripts in email HTML; custom engines and `renderEmail` should be pure and allow repeated background calls.
14
+
15
+ ### Validation
16
+
17
+ - `npm run check` passes type checking, Oxlint, the build and all 144 Node tests, covering debounce, maximum wait, buffer reuse, stale callbacks, timeouts, exceptions, retry and cleanup.
18
+ - Reuse the existing Chrome page to verify blocked scripts and event handlers, unchanged iframe nodes after subject-only edits, one load for five consecutive changes in preview mode, and a 15-second timeout that retains the old preview and recovers through Retry.
19
+ - Verify desktop 600px and mobile 360px previews and five consecutive editing/preview round trips. Canvas, rich-text and iframe nodes remain mounted, with no visible blank or unmeasured frame in sampled states. Restore the test document and remove temporary probes. `git diff --check` passes.
20
+
5
21
  ## 0.5.1 — 2026-10-29
6
22
 
7
23
  ### Fixes and improvements
@@ -2,6 +2,22 @@
2
2
 
3
3
  [English](./changelog.md) | 简体中文
4
4
 
5
+ ## 0.5.2 — 2026-10-29
6
+
7
+ ### 修复与改进
8
+
9
+ - 修复编辑与预览切换时画布重建和闪动:编辑画布常驻,两个 iframe 在备用层完成加载与高度测量后交换显示,分别保留编辑区和预览区的滚动位置;预览时禁用隐藏画布的焦点、键盘和拖拽交互。
10
+ - 文档、配置、变量和设备宽度变化使用 400ms 防抖与最长 2 秒等待,支持空闲调度;点击 Preview 或 Retry 立即处理,预览模式中的后续修改仍防抖。HTML 和宽度一致时复用已就绪或正在加载的缓冲,避免重复挂载。
11
+ - 预览 iframe 使用 `sandbox="allow-same-origin"` 阻止脚本和事件属性执行,保持高度测量能力;导出 HTML 保持原有内容,此约束不等同于完整 HTML 消毒。
12
+ - 补齐生成、加载和测量错误,以及 15 秒加载测量超时提示与 Retry。已有预览在失败时继续显示,重试强制新加载;过期、被替换或已失败缓冲的回调不能覆盖当前内容,卸载时清理计时器、观察器、监听器和动画帧。
13
+ - 同步中英文模板引擎文档。本次按 patch 将 npm 包版本从 `0.5.1` 升至 `0.5.2`,公开 API、邮件导出与持久化文档格式不变;预览不再执行邮件 HTML 中的脚本,自定义引擎与 `renderEmail` 应允许后台重复调用并保持纯函数。
14
+
15
+ ### 验证
16
+
17
+ - `npm run check` 通过类型检查、Oxlint、构建与全部 144 项 Node 测试,覆盖防抖、最长等待、缓冲复用、过期回调、超时、异常、重试与清理。
18
+ - 复用现有 Chrome 页面验证脚本和事件属性被阻止、仅修改主题时 iframe 节点不变、预览内连续 5 次修改只加载 1 次,以及 15 秒超时保留旧预览并在 Retry 后恢复。
19
+ - 验证桌面 600px 与移动 360px 预览及连续五轮编辑/预览切换,画布、富文本和 iframe 节点保持不变,采样未出现可见空白或未测量帧;测试文档已还原,临时探针已清理。`git diff --check` 通过。
20
+
5
21
  ## 0.5.1 — 2026-10-29
6
22
 
7
23
  ### 修复与改进
@@ -36,6 +36,14 @@ Field names support Unicode letters, digits and underscores, starting with a let
36
36
 
37
37
  Functions (including `eq/ne/and/or/not/len/index/printf/html`), pipelines, parentheses, local variables, template definitions/calls, comments and other full Go features require an external engine. Unsupported syntax or incomplete control structures preserve the **entire input for that render**, without partial evaluation. In the editor this preserves that block's HTML template.
38
38
 
39
+ ## Switching between editing and preview
40
+
41
+ The editing canvas stays mounted, so switching modes preserves rich-text editors and HTML block iframes. Editing and preview retain their own scroll positions. While preview is requested, the canvas is excluded from focus, keyboard and drag interactions, and rich-text toolbars are hidden.
42
+
43
+ Changes to the document, configuration, variables or device width schedule a complete email preview after a 400ms debounce, using an idle callback with a maximum wait of 2 seconds during continuous changes. Browsers without `requestIdleCallback` use a timer. Clicking Preview or Retry bypasses the wait and prepares the latest revision; subsequent changes in preview mode still use the same debounce. HTML generation still runs synchronously on the main thread; custom engines and `renderEmail` should be pure and allow repeated background calls.
44
+
45
+ Two iframe buffers keep loading and measurement in the standby layer. A buffer becomes visible only after it has loaded and its height has been measured. Until the latest revision is ready, the current view remains visible with a preparation status. Stale revisions cannot replace newer content. Identical HTML and width reuse a ready or loading buffer to avoid unnecessary reloads. Generation, load or measurement failures, including a 15-second load/measurement timeout, show an error and Retry while retaining any existing preview. Retry forces a new load. Preview iframes use `sandbox="allow-same-origin"` to block scripts and event handlers while allowing the host to measure height; this is not complete HTML sanitization and does not rewrite exported HTML. This cache only serves the UI; explicit export APIs synchronously generate from the latest document at call time.
46
+
39
47
  ## Integration example
40
48
 
41
49
  Wrap your template library as TemplateEngine and pass the same adapter to editing and standalone preview. The caller provides templateEngine in this example:
@@ -36,6 +36,14 @@ interface TemplateEngineOptions {
36
36
 
37
37
  默认引擎不支持函数调用(含 `eq/ne/and/or/not/len/index/printf/html`)、管道、括号表达式、局部变量、模板定义/调用、注释和其他完整 Go 语法。输入包含不支持的语法或不完整的控制结构时,**保留该次渲染的完整输入,不执行部分替换**;编辑器中意味着保留对应区块的 HTML 模板。需要完整 Go 兼容性时接入外部引擎。
38
38
 
39
+ ## 编辑与预览切换
40
+
41
+ 编辑画布保持挂载,切换预览不会重建富文本编辑器或 HTML 区块 iframe。编辑区与预览区分别保留滚动位置;预览时编辑画布不参与焦点、键盘和拖拽交互,富文本工具栏也会隐藏。
42
+
43
+ 文档、配置、变量或设备宽度变化后,完整邮件预览在停止修改 400ms 后排入空闲任务,连续修改时最长等待 2 秒;不支持 `requestIdleCallback` 时使用定时器。点击 Preview 或 Retry 会跳过等待并准备最新版本;进入预览后的后续修改仍使用相同防抖规则。HTML 生成仍在主线程同步执行,自定义引擎和 `renderEmail` 应保持纯函数,允许后台重复调用。
44
+
45
+ 预览使用两个 iframe 缓冲:最新内容在备用层加载并测量高度,完成后才交换显示。首次预览或最新内容尚未就绪时保留当前画面并显示准备提示;过期版本不会覆盖新内容。HTML 和宽度相同时复用已就绪或正在加载的缓冲,避免无意义重载。生成、加载或测量失败,以及加载测量超过 15 秒时显示错误和 Retry;已有预览仍保留,Retry 强制创建新加载。预览 iframe 使用 `sandbox="allow-same-origin"`,阻止脚本及事件属性执行,同时允许宿主测量高度;这不等同于完整 HTML 消毒,也不改写导出的 HTML。预览缓存仅服务于界面,显式导出 API 仍按调用时的最新文档同步生成。
46
+
39
47
  ## 接入示例
40
48
 
41
49
  业务侧把实际模板库封装为 `TemplateEngine`,再把同一个适配器用于编辑器和独立预览。下面示例的 `templateEngine` 由调用方提供:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@standhigher/react-email-editor",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "description": "React email template editor with drag-and-drop blocks, rich text, JSON storage, and HTML export.",
6
6
  "keywords": [