@templatical/editor 0.28.0 → 0.28.1

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.
Files changed (31) hide show
  1. package/dist/{BlockPreviewCanvas-CeNJqtJ6.js → BlockPreviewCanvas-C4M7JT4x.js} +20 -13
  2. package/dist/{SaveBlockDialog-DPDQTGTB.js → SaveBlockDialog-KWQaS1_q.js} +1 -1
  3. package/dist/{SavedBlocksBrowserModal-D8a-wIRe.js → SavedBlocksBrowserModal-Cg8yP_EN.js} +1 -1
  4. package/dist/{SavedBlocksPanels-CmnGyJHO.js → SavedBlocksPanels-bept0JjA.js} +1 -1
  5. package/dist/{TestEmailModal-C69UbsNy.js → TestEmailModal-DtJabfkS.js} +2 -2
  6. package/dist/{TestEmailPanel-CH2dG-8A.js → TestEmailPanel-CtFxt5q7.js} +1 -1
  7. package/dist/bundle-stats.json +6 -6
  8. package/dist/cdn/chunks/{BlockPreviewCanvas-9tFXPVgi.js → BlockPreviewCanvas-BXVC8-Zj.js} +21 -14
  9. package/dist/cdn/chunks/BlockPreviewCanvas-BXVC8-Zj.js.map +1 -0
  10. package/dist/cdn/chunks/{SaveBlockDialog-DVMxZbmf.js → SaveBlockDialog-DYr5yKqy.js} +2 -2
  11. package/dist/cdn/chunks/{SaveBlockDialog-DVMxZbmf.js.map → SaveBlockDialog-DYr5yKqy.js.map} +1 -1
  12. package/dist/cdn/chunks/{SavedBlocksBrowserModal-C3nfKRQg.js → SavedBlocksBrowserModal-BUJjcHkw.js} +2 -2
  13. package/dist/cdn/chunks/{SavedBlocksBrowserModal-C3nfKRQg.js.map → SavedBlocksBrowserModal-BUJjcHkw.js.map} +1 -1
  14. package/dist/cdn/chunks/{SavedBlocksPanels-DVat5S7j.js → SavedBlocksPanels-DFgvmsbj.js} +2 -2
  15. package/dist/cdn/chunks/{SavedBlocksPanels-DVat5S7j.js.map → SavedBlocksPanels-DFgvmsbj.js.map} +1 -1
  16. package/dist/cdn/chunks/{TestEmailModal-CDx1Pqtx.js → TestEmailModal-DRCcdGvX.js} +3 -3
  17. package/dist/cdn/chunks/TestEmailModal-DRCcdGvX.js.map +1 -0
  18. package/dist/cdn/chunks/{TestEmailPanel--PpajGV7.js → TestEmailPanel-BZU5HX2c.js} +2 -2
  19. package/dist/cdn/chunks/{TestEmailPanel--PpajGV7.js.map → TestEmailPanel-BZU5HX2c.js.map} +1 -1
  20. package/dist/cdn/chunks/emailFrameWidth-BmFCN2dp.js.map +1 -1
  21. package/dist/cdn/chunks/{src-DgxiymKd.js → src-BxHH_6Yr.js} +2 -2
  22. package/dist/cdn/chunks/{src-DgxiymKd.js.map → src-BxHH_6Yr.js.map} +1 -1
  23. package/dist/cdn/chunks/useEditorCore-2ZVOY0Rh.js.map +1 -1
  24. package/dist/cdn/editor.css +1 -1
  25. package/dist/cdn/editor.js +9 -6
  26. package/dist/cdn/editor.js.map +1 -1
  27. package/dist/style.css +1 -1
  28. package/dist/templatical-editor.js +8 -5
  29. package/package.json +8 -8
  30. package/dist/cdn/chunks/BlockPreviewCanvas-9tFXPVgi.js.map +0 -1
  31. package/dist/cdn/chunks/TestEmailModal-CDx1Pqtx.js.map +0 -1
@@ -78,9 +78,9 @@ var L = { class: "tpl:w-full" }, R = { class: "tpl:flex tpl:gap-0" }, z = /* @__
78
78
  }
79
79
  },
80
80
  setup(e) {
81
- let n = e, o = a(P), m = a(N, null), L = a(M, null), R = a(A, null);
82
- t(j, p(() => R?.value ?? !1));
83
- let B = p(() => n.applyConditionFilter ? n.blocks.filter((e) => !L?.isHidden(e.id)) : n.blocks), V = p(() => F(m?.content.value.settings, n.viewport)), H = p(() => m ? y(m.content.value.settings) : {}), U = {
81
+ let n = e, m = a(P), L = a(N, null), R = a(M, null), B = a(A, null);
82
+ t(j, p(() => B?.value ?? !1));
83
+ let V = p(() => n.applyConditionFilter ? n.blocks.filter((e) => !R?.isHidden(e.id)) : n.blocks), H = p(() => F(L?.content.value.settings, n.viewport)), U = p(() => L ? y(L.content.value.settings) : {}), W = p(() => L?.content.value.settings.backgroundColor || "var(--tpl-canvas-bg)"), G = p(() => H.value + 192), K = {
84
84
  section: z,
85
85
  title: C,
86
86
  paragraph: w,
@@ -95,26 +95,33 @@ var L = { class: "tpl:w-full" }, R = { class: "tpl:flex tpl:gap-0" }, z = /* @__
95
95
  html: O,
96
96
  custom: _
97
97
  };
98
- function W(e) {
99
- return E(e, o, U);
98
+ function q(e) {
99
+ return E(e, m, K);
100
100
  }
101
101
  return (t, n) => (l(), s("div", {
102
+ "data-testid": "block-preview-stage",
103
+ class: "tpl:pointer-events-none tpl:mx-auto tpl:flex tpl:select-none tpl:justify-center tpl:rounded-lg",
104
+ style: r({
105
+ width: `${G.value}px`,
106
+ minWidth: `${H.value}px`,
107
+ maxWidth: "100%",
108
+ backgroundColor: W.value,
109
+ boxShadow: "var(--tpl-shadow-sm)"
110
+ })
111
+ }, [o("div", {
102
112
  "data-testid": "block-preview-canvas",
103
- class: "tpl:pointer-events-none tpl:mx-auto tpl:select-none tpl:rounded-lg",
104
113
  style: r({
105
- width: `${V.value}px`,
114
+ width: `${H.value}px`,
106
115
  transition: f(I),
107
- backgroundColor: "var(--tpl-canvas-bg)",
108
- boxShadow: "var(--tpl-shadow-sm)",
109
- ...H.value
116
+ ...U.value
110
117
  })
111
- }, [(l(!0), s(d, null, c(B.value, (t) => (l(), s("div", {
118
+ }, [(l(!0), s(d, null, c(V.value, (t) => (l(), s("div", {
112
119
  key: t.id,
113
120
  style: r(f(v)(t))
114
- }, [(l(), u(i(W(t)), {
121
+ }, [(l(), u(i(q(t)), {
115
122
  block: t,
116
123
  viewport: e.viewport
117
- }, null, 8, ["block", "viewport"]))], 4))), 128))], 4));
124
+ }, null, 8, ["block", "viewport"]))], 4))), 128))], 4)], 4));
118
125
  }
119
126
  }), V = /* @__PURE__ */ e({ default: () => H }), H = B;
120
127
  //#endregion
@@ -9,7 +9,7 @@ import { t as P } from "./loader-circle-CSGh8n8A.js";
9
9
  import { v as F } from "./styleConstants-C_YAPdQS.js";
10
10
  import { n as I } from "./emailFrameWidth-BmFCN2dp.js";
11
11
  import { t as L } from "./TplModal-DBlMd7h9.js";
12
- import { t as R } from "./BlockPreviewCanvas-CeNJqtJ6.js";
12
+ import { t as R } from "./BlockPreviewCanvas-C4M7JT4x.js";
13
13
  //#region src/components/SavedBlockPreviewRow.vue?vue&type=script&setup=true&lang.ts
14
14
  var z = ["data-block-id"], B = ["aria-label", "title"], V = { class: "tpl:relative tpl:min-w-0 tpl:flex-1" }, H = {
15
15
  key: 0,
@@ -65,7 +65,7 @@ var fe = {
65
65
  props: { visible: { type: Boolean } },
66
66
  emits: ["close", "insert"],
67
67
  setup(v, { emit: A }) {
68
- let tt = v, j = A, nt = ee(() => import("./BlockPreviewCanvas-CeNJqtJ6.js").then((e) => e.n)), { t: M, format: N } = ie(), P = C(ne, "SavedBlocksBrowserModal"), F = C(re, "SavedBlocksBrowserModal"), I = o(""), L = o(""), R = o(null), z = o(null), B = o(null), V = o(""), H = o(""), U = o(null), W = o("end"), G = g(() => {
68
+ let tt = v, j = A, nt = ee(() => import("./BlockPreviewCanvas-C4M7JT4x.js").then((e) => e.n)), { t: M, format: N } = ie(), P = C(ne, "SavedBlocksBrowserModal"), F = C(re, "SavedBlocksBrowserModal"), I = o(""), L = o(""), R = o(null), z = o(null), B = o(null), V = o(""), H = o(""), U = o(null), W = o("end"), G = g(() => {
69
69
  let e = I.value.trim().toLowerCase(), t = L.value;
70
70
  return P.savedBlocks.value.filter((n) => !(e && !n.name.toLowerCase().includes(e) || t && n.category !== t));
71
71
  }), K = g(() => P.isLoading.value && P.savedBlocks.value.length === 0), rt = g(() => I.value.trim().length > 0 || L.value !== ""), q = g(() => R.value ? P.savedBlocks.value.find((e) => e.id === R.value) ?? null : null), it = g(() => {
@@ -5,7 +5,7 @@ var c = /* @__PURE__ */ s({
5
5
  __name: "SavedBlocksPanels",
6
6
  props: { feature: {} },
7
7
  setup(s) {
8
- let c = s, l = e(() => import("./SavedBlocksPickBar-Z-YRd488.js")), u = e(() => import("./SaveBlockDialog-DPDQTGTB.js")), d = e(() => import("./SavedBlocksBrowserModal-D8a-wIRe.js"));
8
+ let c = s, l = e(() => import("./SavedBlocksPickBar-Z-YRd488.js")), u = e(() => import("./SaveBlockDialog-KWQaS1_q.js")), d = e(() => import("./SavedBlocksBrowserModal-Cg8yP_EN.js"));
9
9
  function f(e, t) {
10
10
  c.feature.insert(e, t);
11
11
  }
@@ -9,7 +9,7 @@ import { t as ue } from "./loader-circle-CSGh8n8A.js";
9
9
  import { n as de, t as fe } from "./MergeTagModeToggle-CZN0Pr1o.js";
10
10
  import { v as pe } from "./styleConstants-C_YAPdQS.js";
11
11
  import { t as me } from "./TplModal-DBlMd7h9.js";
12
- import { t as he } from "./BlockPreviewCanvas-CeNJqtJ6.js";
12
+ import { t as he } from "./BlockPreviewCanvas-C4M7JT4x.js";
13
13
  //#region src/utils/validateEmailShape.ts
14
14
  var y = /[,;<>()[\]\\"'`|/?#@!$%^&*+=~{}]/;
15
15
  function b(e) {
@@ -113,7 +113,7 @@ var x = ["aria-busy"], S = {
113
113
  "aria-modal": "true",
114
114
  "aria-busy": _.isSending,
115
115
  "aria-labelledby": "tpl-test-email-title",
116
- class: "tpl-scale-in tpl:flex tpl:max-h-[90%] tpl:w-full tpl:max-w-2xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5",
116
+ class: "tpl-scale-in tpl:flex tpl:max-h-[90%] tpl:w-full tpl:max-w-3xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5",
117
117
  style: {
118
118
  "background-color": "var(--tpl-bg-elevated)",
119
119
  "box-shadow": "var(--tpl-shadow-xl)"
@@ -5,7 +5,7 @@ var o = /* @__PURE__ */ a({
5
5
  __name: "TestEmailPanel",
6
6
  props: { feature: {} },
7
7
  setup(a) {
8
- let o = a, s = e(() => import("./TestEmailModal-C69UbsNy.js"));
8
+ let o = a, s = e(() => import("./TestEmailModal-DtJabfkS.js"));
9
9
  function c(e) {
10
10
  o.feature.send(e);
11
11
  }
@@ -1,10 +1,10 @@
1
1
  {
2
- "version": "0.28.0",
3
- "initialGzipBytes": 307320,
4
- "initialRawBytes": 1108275,
2
+ "version": "0.28.1",
3
+ "initialGzipBytes": 307340,
4
+ "initialRawBytes": 1108383,
5
5
  "initialFileCount": 47,
6
- "lazyGzipBytes": 202269,
7
- "lazyRawBytes": 650886,
6
+ "lazyGzipBytes": 202356,
7
+ "lazyRawBytes": 651170,
8
8
  "lazyFileCount": 59,
9
- "generatedAt": "2026-08-24T12:50:49.036Z"
9
+ "generatedAt": "2026-08-25T07:15:57.518Z"
10
10
  }
@@ -78,9 +78,9 @@ var L = { class: "tpl:w-full" }, R = { class: "tpl:flex tpl:gap-0" }, z = /* @__
78
78
  }
79
79
  },
80
80
  setup(e) {
81
- let r = e, s = t(P), p = t(N, null), L = t(M, null), R = t(A, null);
82
- a(j, l(() => R?.value ?? !1));
83
- let B = l(() => r.applyConditionFilter ? r.blocks.filter((e) => !L?.isHidden(e.id)) : r.blocks), V = l(() => F(p?.content.value.settings, r.viewport)), H = l(() => p ? y(p.content.value.settings) : {}), U = {
81
+ let r = e, s = t(P), L = t(N, null), R = t(M, null), B = t(A, null);
82
+ a(j, l(() => B?.value ?? !1));
83
+ let V = l(() => r.applyConditionFilter ? r.blocks.filter((e) => !R?.isHidden(e.id)) : r.blocks), H = l(() => F(L?.content.value.settings, r.viewport)), U = l(() => L ? y(L.content.value.settings) : {}), W = l(() => L?.content.value.settings.backgroundColor || "var(--tpl-canvas-bg)"), G = l(() => H.value + 192), K = {
84
84
  section: z,
85
85
  title: v,
86
86
  paragraph: T,
@@ -95,29 +95,36 @@ var L = { class: "tpl:w-full" }, R = { class: "tpl:flex tpl:gap-0" }, z = /* @__
95
95
  html: g,
96
96
  custom: k
97
97
  };
98
- function W(e) {
99
- return b(e, s, U);
98
+ function q(e) {
99
+ return b(e, s, K);
100
100
  }
101
101
  return (t, r) => (i(), u("div", {
102
+ "data-testid": "block-preview-stage",
103
+ class: "tpl:pointer-events-none tpl:mx-auto tpl:flex tpl:select-none tpl:justify-center tpl:rounded-lg",
104
+ style: d({
105
+ width: `${G.value}px`,
106
+ minWidth: `${H.value}px`,
107
+ maxWidth: "100%",
108
+ backgroundColor: W.value,
109
+ boxShadow: "var(--tpl-shadow-sm)"
110
+ })
111
+ }, [p("div", {
102
112
  "data-testid": "block-preview-canvas",
103
- class: "tpl:pointer-events-none tpl:mx-auto tpl:select-none tpl:rounded-lg",
104
113
  style: d({
105
- width: `${V.value}px`,
114
+ width: `${H.value}px`,
106
115
  transition: c(I),
107
- backgroundColor: "var(--tpl-canvas-bg)",
108
- boxShadow: "var(--tpl-shadow-sm)",
109
- ...H.value
116
+ ...U.value
110
117
  })
111
- }, [(i(!0), u(m, null, n(B.value, (t) => (i(), u("div", {
118
+ }, [(i(!0), u(m, null, n(V.value, (t) => (i(), u("div", {
112
119
  key: t.id,
113
120
  style: d(c(D)(t))
114
- }, [(i(), f(o(W(t)), {
121
+ }, [(i(), f(o(q(t)), {
115
122
  block: t,
116
123
  viewport: e.viewport
117
- }, null, 8, ["block", "viewport"]))], 4))), 128))], 4));
124
+ }, null, 8, ["block", "viewport"]))], 4))), 128))], 4)], 4));
118
125
  }
119
126
  }), V = /* @__PURE__ */ e({ default: () => H }), H = B;
120
127
  //#endregion
121
128
  export { V as n, H as t };
122
129
 
123
- //# sourceMappingURL=BlockPreviewCanvas-9tFXPVgi.js.map
130
+ //# sourceMappingURL=BlockPreviewCanvas-BXVC8-Zj.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BlockPreviewCanvas-BXVC8-Zj.js","names":[],"sources":["../../../src/components/blocks/PreviewSectionBlock.vue","../../../src/components/blocks/PreviewSectionBlock.vue","../../../src/components/BlockPreviewCanvas.vue","../../../src/components/BlockPreviewCanvas.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport ButtonBlock from \"./ButtonBlock.vue\";\nimport CustomBlock from \"./CustomBlock.vue\";\nimport DividerBlock from \"./DividerBlock.vue\";\nimport HtmlBlock from \"./HtmlBlock.vue\";\nimport ImageBlock from \"./ImageBlock.vue\";\nimport MenuBlock from \"./MenuBlock.vue\";\nimport SocialIconsBlock from \"./SocialIconsBlock.vue\";\nimport SpacerBlock from \"./SpacerBlock.vue\";\nimport TableBlock from \"./TableBlock.vue\";\nimport TitleBlock from \"./TitleBlock.vue\";\nimport ParagraphBlock from \"./ParagraphBlock.vue\";\nimport VideoBlock from \"./VideoBlock.vue\";\nimport {\n resolveBlockComponent,\n getBlockWrapperStyle,\n} from \"../../utils/blockComponentResolver\";\nimport type {\n Block,\n SectionBlock as SectionBlockType,\n} from \"@templatical/types\";\nimport { computed, inject, type Component } from \"vue\";\nimport { BLOCK_REGISTRY_KEY } from \"../../keys\";\n\nconst previewBlockComponentMap: Record<string, Component> = {\n title: TitleBlock,\n paragraph: ParagraphBlock,\n image: ImageBlock,\n video: VideoBlock,\n button: ButtonBlock,\n divider: DividerBlock,\n social: SocialIconsBlock,\n menu: MenuBlock,\n table: TableBlock,\n spacer: SpacerBlock,\n html: HtmlBlock,\n custom: CustomBlock,\n};\n\nconst props = defineProps<{\n block: SectionBlockType;\n viewport: \"desktop\";\n}>();\n\ndefineOptions({ name: \"PreviewSectionBlock\" });\n\nconst blockRegistry = inject(BLOCK_REGISTRY_KEY, null);\n\nconst columnWidths = computed(() => {\n switch (props.block.columns) {\n case \"2\":\n return [\"50%\", \"50%\"];\n case \"3\":\n return [\"33.33%\", \"33.33%\", \"33.33%\"];\n case \"1-2\":\n return [\"33.33%\", \"66.67%\"];\n case \"2-1\":\n return [\"66.67%\", \"33.33%\"];\n default:\n return [\"100%\"];\n }\n});\n\nconst columns = computed(() => {\n const count = columnWidths.value.length;\n const children = [...props.block.children];\n while (children.length < count) {\n children.push([]);\n }\n return children.slice(0, count);\n});\n\nfunction getColumnBlocks(colIndex: number): Block[] {\n return columns.value[colIndex] || [];\n}\n\nfunction getBlockComponent(block: Block): Component | null {\n return resolveBlockComponent(block, blockRegistry, previewBlockComponentMap);\n}\n</script>\n\n<template>\n <div class=\"tpl:w-full\">\n <div class=\"tpl:flex tpl:gap-0\">\n <div\n v-for=\"(_, colIndex) in columns\"\n :key=\"colIndex\"\n :style=\"{ width: columnWidths[colIndex] }\"\n >\n <div\n v-for=\"childBlock in getColumnBlocks(colIndex)\"\n :key=\"childBlock.id\"\n :style=\"getBlockWrapperStyle(childBlock)\"\n >\n <!-- Recursive self-reference for nested sections -->\n <PreviewSectionBlock\n v-if=\"childBlock.type === 'section'\"\n :block=\"childBlock\"\n viewport=\"desktop\"\n />\n <component\n :is=\"getBlockComponent(childBlock)\"\n v-else\n :block=\"childBlock\"\n viewport=\"desktop\"\n />\n </div>\n </div>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport ButtonBlock from \"./ButtonBlock.vue\";\nimport CustomBlock from \"./CustomBlock.vue\";\nimport DividerBlock from \"./DividerBlock.vue\";\nimport HtmlBlock from \"./HtmlBlock.vue\";\nimport ImageBlock from \"./ImageBlock.vue\";\nimport MenuBlock from \"./MenuBlock.vue\";\nimport SocialIconsBlock from \"./SocialIconsBlock.vue\";\nimport SpacerBlock from \"./SpacerBlock.vue\";\nimport TableBlock from \"./TableBlock.vue\";\nimport TitleBlock from \"./TitleBlock.vue\";\nimport ParagraphBlock from \"./ParagraphBlock.vue\";\nimport VideoBlock from \"./VideoBlock.vue\";\nimport {\n resolveBlockComponent,\n getBlockWrapperStyle,\n} from \"../../utils/blockComponentResolver\";\nimport type {\n Block,\n SectionBlock as SectionBlockType,\n} from \"@templatical/types\";\nimport { computed, inject, type Component } from \"vue\";\nimport { BLOCK_REGISTRY_KEY } from \"../../keys\";\n\nconst previewBlockComponentMap: Record<string, Component> = {\n title: TitleBlock,\n paragraph: ParagraphBlock,\n image: ImageBlock,\n video: VideoBlock,\n button: ButtonBlock,\n divider: DividerBlock,\n social: SocialIconsBlock,\n menu: MenuBlock,\n table: TableBlock,\n spacer: SpacerBlock,\n html: HtmlBlock,\n custom: CustomBlock,\n};\n\nconst props = defineProps<{\n block: SectionBlockType;\n viewport: \"desktop\";\n}>();\n\ndefineOptions({ name: \"PreviewSectionBlock\" });\n\nconst blockRegistry = inject(BLOCK_REGISTRY_KEY, null);\n\nconst columnWidths = computed(() => {\n switch (props.block.columns) {\n case \"2\":\n return [\"50%\", \"50%\"];\n case \"3\":\n return [\"33.33%\", \"33.33%\", \"33.33%\"];\n case \"1-2\":\n return [\"33.33%\", \"66.67%\"];\n case \"2-1\":\n return [\"66.67%\", \"33.33%\"];\n default:\n return [\"100%\"];\n }\n});\n\nconst columns = computed(() => {\n const count = columnWidths.value.length;\n const children = [...props.block.children];\n while (children.length < count) {\n children.push([]);\n }\n return children.slice(0, count);\n});\n\nfunction getColumnBlocks(colIndex: number): Block[] {\n return columns.value[colIndex] || [];\n}\n\nfunction getBlockComponent(block: Block): Component | null {\n return resolveBlockComponent(block, blockRegistry, previewBlockComponentMap);\n}\n</script>\n\n<template>\n <div class=\"tpl:w-full\">\n <div class=\"tpl:flex tpl:gap-0\">\n <div\n v-for=\"(_, colIndex) in columns\"\n :key=\"colIndex\"\n :style=\"{ width: columnWidths[colIndex] }\"\n >\n <div\n v-for=\"childBlock in getColumnBlocks(colIndex)\"\n :key=\"childBlock.id\"\n :style=\"getBlockWrapperStyle(childBlock)\"\n >\n <!-- Recursive self-reference for nested sections -->\n <PreviewSectionBlock\n v-if=\"childBlock.type === 'section'\"\n :block=\"childBlock\"\n viewport=\"desktop\"\n />\n <component\n :is=\"getBlockComponent(childBlock)\"\n v-else\n :block=\"childBlock\"\n viewport=\"desktop\"\n />\n </div>\n </div>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport ButtonBlock from \"./blocks/ButtonBlock.vue\";\nimport CustomBlock from \"./blocks/CustomBlock.vue\";\nimport DividerBlock from \"./blocks/DividerBlock.vue\";\nimport HtmlBlock from \"./blocks/HtmlBlock.vue\";\nimport ImageBlock from \"./blocks/ImageBlock.vue\";\nimport MenuBlock from \"./blocks/MenuBlock.vue\";\nimport PreviewSectionBlock from \"./blocks/PreviewSectionBlock.vue\";\nimport SocialIconsBlock from \"./blocks/SocialIconsBlock.vue\";\nimport SpacerBlock from \"./blocks/SpacerBlock.vue\";\nimport TableBlock from \"./blocks/TableBlock.vue\";\nimport TitleBlock from \"./blocks/TitleBlock.vue\";\nimport ParagraphBlock from \"./blocks/ParagraphBlock.vue\";\nimport VideoBlock from \"./blocks/VideoBlock.vue\";\nimport {\n BLOCK_REGISTRY_KEY,\n CONDITION_PREVIEW_KEY,\n EDITOR_KEY,\n MERGE_TAG_SAMPLE_MODE_KEY,\n USE_MERGE_TAG_SAMPLES_KEY,\n} from \"../keys\";\nimport {\n resolveBlockComponent,\n getBlockWrapperStyle,\n getDocumentStyle,\n} from \"../utils/blockComponentResolver\";\nimport {\n EMAIL_FRAME_WIDTH_TRANSITION,\n EMAIL_GUTTER,\n getEmailFrameWidth,\n} from \"../utils/emailFrameWidth\";\nimport type { Block, ViewportSize } from \"@templatical/types\";\nimport { computed, inject, provide, type Component } from \"vue\";\n\nconst props = withDefaults(\n defineProps<{\n blocks: Block[];\n /**\n * Which viewport to render. Defaults to `desktop`, so the saved-blocks\n * surfaces — which have no viewport control — behave exactly as before.\n * The test-email dialog passes the user's choice so responsive blocks show\n * the variant a recipient on that device would receive.\n */\n viewport?: ViewportSize;\n /**\n * Whether the hand-toggled display-condition filter still applies here.\n * `false` where a resolver owns the preview: it has already evaluated every\n * condition against real data, so layering a manual hide on top would veto\n * the answer that was asked for.\n *\n * A prop rather than an injected `PREVIEW_RESOLUTION_KEY` read, because the\n * test-email dialog builds its *own* resolution instance (keyed to the\n * selected recipient) — injecting would consult the shared canvas instance\n * and get the wrong answer.\n */\n applyConditionFilter?: boolean;\n }>(),\n { viewport: \"desktop\", applyConditionFilter: true },\n);\n\nconst blockRegistry = inject(BLOCK_REGISTRY_KEY);\nconst editor = inject(EDITOR_KEY, null);\nconst conditionPreview = inject(CONDITION_PREVIEW_KEY, null);\n\n/**\n * This canvas is only ever a preview — nothing here is editable — so it honours\n * the user's mode directly, with no `previewMode` gate of the kind `Canvas.vue`\n * needs. Re-provided rather than left to inherit, because a `BlockPreviewCanvas`\n * inside a dialog may sit under an editing `Canvas` that provides `false`.\n */\nconst mergeTagSampleMode = inject(MERGE_TAG_SAMPLE_MODE_KEY, null);\nprovide(\n USE_MERGE_TAG_SAMPLES_KEY,\n computed(() => mergeTagSampleMode?.value ?? false),\n);\n\n/**\n * Blocks a display condition currently excludes, so a preview never shows\n * content the recipient won't get. The canvas does the same via\n * `v-show=\"!conditionPreview?.isHidden(block.id)\"`.\n *\n * A no-op for the saved-blocks browser: entries there carry their *stored* ids,\n * which aren't in the current template, so nothing is tracked as hidden. It\n * bites where it should — the save dialog and the test-email preview, both of\n * which show blocks that are live on the canvas.\n *\n * Skipped entirely when `applyConditionFilter` is false — see the prop.\n */\nconst visibleBlocks = computed(() =>\n props.applyConditionFilter\n ? props.blocks.filter((block) => !conditionPreview?.isHidden(block.id))\n : props.blocks,\n);\n\n/**\n * Frame width, from the one helper the canvas and the scaled preview rows also\n * use — so all three agree by construction rather than by three constants that\n * happen to match. A template with a custom body width now previews at that\n * width instead of a flat 600.\n */\nconst frameWidth = computed(() =>\n getEmailFrameWidth(editor?.content.value.settings, props.viewport),\n);\n\n/**\n * The same document-level style the canvas applies, so a previewed block\n * renders identically to the canvas one: without it the font falls back to the\n * editor UI's and the link rules hit their unset defaults, dropping the\n * underline and the link colour.\n *\n * Read from the *current* template even in the saved-blocks browser, where the\n * previewed content came from elsewhere — a saved block stores only `Block[]`,\n * and the current settings are what it will actually look like once inserted.\n */\nconst documentStyle = computed(() =>\n editor ? getDocumentStyle(editor.content.value.settings) : {},\n);\n\n/**\n * The email's body background — `mj-body background-color` when sent, and the\n * `.tpl-canvas-bg` layer on the editing canvas.\n *\n * Deliberately NOT part of `getDocumentStyle`: the canvas applies that helper\n * to `.tpl-canvas`, which must stay transparent so the invertible background\n * layer beneath it shows through. A background there would double-paint and\n * defeat the dark-mode preview.\n *\n * Truthiness, not `??`: the colour pickers clear to an empty string to mean\n * \"unset\", which as an inline style renders the frame transparent rather than\n * falling back to the neutral preview surface.\n */\nconst emailBackground = computed(\n () =>\n editor?.content.value.settings.backgroundColor || \"var(--tpl-canvas-bg)\",\n);\n\n/**\n * Width the stage asks for: the content column plus a full gutter on each side,\n * the same stage the canvas builds. It is an ask rather than a fixed size —\n * paired with `max-width: 100%` below, so a container with less room hands over\n * what it has and shows a narrower band instead of overflowing.\n *\n * It has to be a specified width, not `100%`: `TplModal`'s wrapper is\n * shrink-to-fit, so a percentage there resolves against a container whose size\n * the stage is itself supposed to influence, and the stage collapses to its\n * `min-width` — no gutters, on the one surface this exists for.\n */\nconst stageWidth = computed(() => frameWidth.value + EMAIL_GUTTER * 2);\n\nconst previewComponentMap: Record<string, Component> = {\n section: PreviewSectionBlock,\n title: TitleBlock,\n paragraph: ParagraphBlock,\n image: ImageBlock,\n video: VideoBlock,\n button: ButtonBlock,\n divider: DividerBlock,\n social: SocialIconsBlock,\n menu: MenuBlock,\n table: TableBlock,\n spacer: SpacerBlock,\n html: HtmlBlock,\n custom: CustomBlock,\n};\n\nfunction getBlockComponent(block: Block): Component | null {\n return resolveBlockComponent(block, blockRegistry, previewComponentMap);\n}\n</script>\n\n<template>\n <!-- STAGE — the email body, holding the background and a gutter of it on\n each side of the content column, the same shape `Canvas.vue` builds.\n `max-width: 100%` is what makes it fit anywhere: a roomy surface shows\n the full band, a dialog shows as much as it has, and a container sized to\n the content column shows none. `min-width` keeps it from squeezing the\n column, so a cramped surface clips exactly as it did when the frame\n carried the width itself. -->\n <div\n data-testid=\"block-preview-stage\"\n class=\"tpl:pointer-events-none tpl:mx-auto tpl:flex tpl:select-none tpl:justify-center tpl:rounded-lg\"\n :style=\"{\n width: `${stageWidth}px`,\n minWidth: `${frameWidth}px`,\n maxWidth: '100%',\n backgroundColor: emailBackground,\n boxShadow: 'var(--tpl-shadow-sm)',\n }\"\n >\n <!-- CONTENT COLUMN — the blocks at their true email width. Keeps the\n testid: this is the element whose width *is* the email width, which is\n what every viewport assertion measures. -->\n <div\n data-testid=\"block-preview-canvas\"\n :style=\"{\n width: `${frameWidth}px`,\n transition: EMAIL_FRAME_WIDTH_TRANSITION,\n ...documentStyle,\n }\"\n >\n <div\n v-for=\"block in visibleBlocks\"\n :key=\"block.id\"\n :style=\"getBlockWrapperStyle(block)\"\n >\n <component\n :is=\"getBlockComponent(block)\"\n :block=\"block\"\n :viewport=\"viewport\"\n />\n </div>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport ButtonBlock from \"./blocks/ButtonBlock.vue\";\nimport CustomBlock from \"./blocks/CustomBlock.vue\";\nimport DividerBlock from \"./blocks/DividerBlock.vue\";\nimport HtmlBlock from \"./blocks/HtmlBlock.vue\";\nimport ImageBlock from \"./blocks/ImageBlock.vue\";\nimport MenuBlock from \"./blocks/MenuBlock.vue\";\nimport PreviewSectionBlock from \"./blocks/PreviewSectionBlock.vue\";\nimport SocialIconsBlock from \"./blocks/SocialIconsBlock.vue\";\nimport SpacerBlock from \"./blocks/SpacerBlock.vue\";\nimport TableBlock from \"./blocks/TableBlock.vue\";\nimport TitleBlock from \"./blocks/TitleBlock.vue\";\nimport ParagraphBlock from \"./blocks/ParagraphBlock.vue\";\nimport VideoBlock from \"./blocks/VideoBlock.vue\";\nimport {\n BLOCK_REGISTRY_KEY,\n CONDITION_PREVIEW_KEY,\n EDITOR_KEY,\n MERGE_TAG_SAMPLE_MODE_KEY,\n USE_MERGE_TAG_SAMPLES_KEY,\n} from \"../keys\";\nimport {\n resolveBlockComponent,\n getBlockWrapperStyle,\n getDocumentStyle,\n} from \"../utils/blockComponentResolver\";\nimport {\n EMAIL_FRAME_WIDTH_TRANSITION,\n EMAIL_GUTTER,\n getEmailFrameWidth,\n} from \"../utils/emailFrameWidth\";\nimport type { Block, ViewportSize } from \"@templatical/types\";\nimport { computed, inject, provide, type Component } from \"vue\";\n\nconst props = withDefaults(\n defineProps<{\n blocks: Block[];\n /**\n * Which viewport to render. Defaults to `desktop`, so the saved-blocks\n * surfaces — which have no viewport control — behave exactly as before.\n * The test-email dialog passes the user's choice so responsive blocks show\n * the variant a recipient on that device would receive.\n */\n viewport?: ViewportSize;\n /**\n * Whether the hand-toggled display-condition filter still applies here.\n * `false` where a resolver owns the preview: it has already evaluated every\n * condition against real data, so layering a manual hide on top would veto\n * the answer that was asked for.\n *\n * A prop rather than an injected `PREVIEW_RESOLUTION_KEY` read, because the\n * test-email dialog builds its *own* resolution instance (keyed to the\n * selected recipient) — injecting would consult the shared canvas instance\n * and get the wrong answer.\n */\n applyConditionFilter?: boolean;\n }>(),\n { viewport: \"desktop\", applyConditionFilter: true },\n);\n\nconst blockRegistry = inject(BLOCK_REGISTRY_KEY);\nconst editor = inject(EDITOR_KEY, null);\nconst conditionPreview = inject(CONDITION_PREVIEW_KEY, null);\n\n/**\n * This canvas is only ever a preview — nothing here is editable — so it honours\n * the user's mode directly, with no `previewMode` gate of the kind `Canvas.vue`\n * needs. Re-provided rather than left to inherit, because a `BlockPreviewCanvas`\n * inside a dialog may sit under an editing `Canvas` that provides `false`.\n */\nconst mergeTagSampleMode = inject(MERGE_TAG_SAMPLE_MODE_KEY, null);\nprovide(\n USE_MERGE_TAG_SAMPLES_KEY,\n computed(() => mergeTagSampleMode?.value ?? false),\n);\n\n/**\n * Blocks a display condition currently excludes, so a preview never shows\n * content the recipient won't get. The canvas does the same via\n * `v-show=\"!conditionPreview?.isHidden(block.id)\"`.\n *\n * A no-op for the saved-blocks browser: entries there carry their *stored* ids,\n * which aren't in the current template, so nothing is tracked as hidden. It\n * bites where it should — the save dialog and the test-email preview, both of\n * which show blocks that are live on the canvas.\n *\n * Skipped entirely when `applyConditionFilter` is false — see the prop.\n */\nconst visibleBlocks = computed(() =>\n props.applyConditionFilter\n ? props.blocks.filter((block) => !conditionPreview?.isHidden(block.id))\n : props.blocks,\n);\n\n/**\n * Frame width, from the one helper the canvas and the scaled preview rows also\n * use — so all three agree by construction rather than by three constants that\n * happen to match. A template with a custom body width now previews at that\n * width instead of a flat 600.\n */\nconst frameWidth = computed(() =>\n getEmailFrameWidth(editor?.content.value.settings, props.viewport),\n);\n\n/**\n * The same document-level style the canvas applies, so a previewed block\n * renders identically to the canvas one: without it the font falls back to the\n * editor UI's and the link rules hit their unset defaults, dropping the\n * underline and the link colour.\n *\n * Read from the *current* template even in the saved-blocks browser, where the\n * previewed content came from elsewhere — a saved block stores only `Block[]`,\n * and the current settings are what it will actually look like once inserted.\n */\nconst documentStyle = computed(() =>\n editor ? getDocumentStyle(editor.content.value.settings) : {},\n);\n\n/**\n * The email's body background — `mj-body background-color` when sent, and the\n * `.tpl-canvas-bg` layer on the editing canvas.\n *\n * Deliberately NOT part of `getDocumentStyle`: the canvas applies that helper\n * to `.tpl-canvas`, which must stay transparent so the invertible background\n * layer beneath it shows through. A background there would double-paint and\n * defeat the dark-mode preview.\n *\n * Truthiness, not `??`: the colour pickers clear to an empty string to mean\n * \"unset\", which as an inline style renders the frame transparent rather than\n * falling back to the neutral preview surface.\n */\nconst emailBackground = computed(\n () =>\n editor?.content.value.settings.backgroundColor || \"var(--tpl-canvas-bg)\",\n);\n\n/**\n * Width the stage asks for: the content column plus a full gutter on each side,\n * the same stage the canvas builds. It is an ask rather than a fixed size —\n * paired with `max-width: 100%` below, so a container with less room hands over\n * what it has and shows a narrower band instead of overflowing.\n *\n * It has to be a specified width, not `100%`: `TplModal`'s wrapper is\n * shrink-to-fit, so a percentage there resolves against a container whose size\n * the stage is itself supposed to influence, and the stage collapses to its\n * `min-width` — no gutters, on the one surface this exists for.\n */\nconst stageWidth = computed(() => frameWidth.value + EMAIL_GUTTER * 2);\n\nconst previewComponentMap: Record<string, Component> = {\n section: PreviewSectionBlock,\n title: TitleBlock,\n paragraph: ParagraphBlock,\n image: ImageBlock,\n video: VideoBlock,\n button: ButtonBlock,\n divider: DividerBlock,\n social: SocialIconsBlock,\n menu: MenuBlock,\n table: TableBlock,\n spacer: SpacerBlock,\n html: HtmlBlock,\n custom: CustomBlock,\n};\n\nfunction getBlockComponent(block: Block): Component | null {\n return resolveBlockComponent(block, blockRegistry, previewComponentMap);\n}\n</script>\n\n<template>\n <!-- STAGE — the email body, holding the background and a gutter of it on\n each side of the content column, the same shape `Canvas.vue` builds.\n `max-width: 100%` is what makes it fit anywhere: a roomy surface shows\n the full band, a dialog shows as much as it has, and a container sized to\n the content column shows none. `min-width` keeps it from squeezing the\n column, so a cramped surface clips exactly as it did when the frame\n carried the width itself. -->\n <div\n data-testid=\"block-preview-stage\"\n class=\"tpl:pointer-events-none tpl:mx-auto tpl:flex tpl:select-none tpl:justify-center tpl:rounded-lg\"\n :style=\"{\n width: `${stageWidth}px`,\n minWidth: `${frameWidth}px`,\n maxWidth: '100%',\n backgroundColor: emailBackground,\n boxShadow: 'var(--tpl-shadow-sm)',\n }\"\n >\n <!-- CONTENT COLUMN — the blocks at their true email width. Keeps the\n testid: this is the element whose width *is* the email width, which is\n what every viewport assertion measures. -->\n <div\n data-testid=\"block-preview-canvas\"\n :style=\"{\n width: `${frameWidth}px`,\n transition: EMAIL_FRAME_WIDTH_TRANSITION,\n ...documentStyle,\n }\"\n >\n <div\n v-for=\"block in visibleBlocks\"\n :key=\"block.id\"\n :style=\"getBlockWrapperStyle(block)\"\n >\n <component\n :is=\"getBlockComponent(block)\"\n :block=\"block\"\n :viewport=\"viewport\"\n />\n </div>\n </div>\n </div>\n</template>\n"],"mappings":"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"}
@@ -6,7 +6,7 @@ import { It as j, Pt as M, ht as N, it as P } from "./icons-MeYdeH4P.js";
6
6
  import { v as F } from "./styleConstants-C_YAPdQS.js";
7
7
  import { n as I } from "./emailFrameWidth-BmFCN2dp.js";
8
8
  import { t as L } from "./TplModal-BhfPoZ_W.js";
9
- import { t as R } from "./BlockPreviewCanvas-9tFXPVgi.js";
9
+ import { t as R } from "./BlockPreviewCanvas-BXVC8-Zj.js";
10
10
  //#region src/components/SavedBlockPreviewRow.vue?vue&type=script&setup=true&lang.ts
11
11
  var z = ["data-block-id"], B = ["aria-label", "title"], V = { class: "tpl:relative tpl:min-w-0 tpl:flex-1" }, H = {
12
12
  key: 0,
@@ -261,4 +261,4 @@ var z = ["data-block-id"], B = ["aria-label", "title"], V = { class: "tpl:relati
261
261
  //#endregion
262
262
  export { Q as default };
263
263
 
264
- //# sourceMappingURL=SaveBlockDialog-DVMxZbmf.js.map
264
+ //# sourceMappingURL=SaveBlockDialog-DYr5yKqy.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"SaveBlockDialog-DVMxZbmf.js","names":[],"sources":["../../../src/components/SavedBlockPreviewRow.vue","../../../src/components/SavedBlockPreviewRow.vue","../../../src/components/SaveBlockDialog.vue","../../../src/components/SaveBlockDialog.vue"],"sourcesContent":["<script setup lang=\"ts\">\n/**\n * One reorderable row in the save dialog's preview list: a grip handle beside a\n * scaled-down render of the real block.\n *\n * The preview is the same `BlockPreviewCanvas` the browser modal uses, so\n * a row shows what the block actually looks like rather than a description of\n * it. That canvas is fixed at the 600px email width every block component\n * renders against, so the row shrinks it with `transform: scale()` instead of\n * re-flowing it at dialog width — a re-flow would show a layout the recipient\n * will never see.\n *\n * `transform` doesn't participate in layout, so the frame would keep reserving\n * the full unscaled height; a `ResizeObserver` mirrors the scaled height back\n * onto it. The handle is a sibling of the scaled subtree, so it stays full size\n * and keeps its real hit area.\n *\n * Row chrome uses the raw theme tokens, not the `--tpl-chrome-*` aliases: the\n * chrome is an ancestor and a sibling of the rendered email content, never a\n * descendant, so the `.tpl-block-content` token override can't reach it.\n */\nimport BlockPreviewCanvas from \"./BlockPreviewCanvas.vue\";\nimport { useI18n } from \"../composables\";\nimport { CUSTOM_BLOCK_DEFINITIONS_KEY, EDITOR_KEY } from \"../keys\";\nimport { getEmailFrameWidth } from \"../utils/emailFrameWidth\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { ChevronDown, ChevronUp, GripVertical } from \"@lucide/vue\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, onBeforeUnmount, onMounted, ref } from \"vue\";\n\nconst props = defineProps<{\n block: Block;\n /** 1-based position in the list — spoken by the handle's label. */\n position: number;\n total: number;\n}>();\n\nconst emit = defineEmits<{\n (e: \"move\", delta: -1 | 1): void;\n}>();\n\nconst { t, format } = useI18n();\n// Optional: absent when the consumer registers no custom blocks.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst editor = inject(EDITOR_KEY, null);\n\n/**\n * The width the nested `BlockPreviewCanvas` actually renders at.\n *\n * MUST come from the same helper the canvas uses, because the scale factor below\n * divides by it: a mismatch scales every row by the wrong amount, so a template\n * with a custom body width would overflow its frame or under-fill it. Rows are\n * always the desktop width — the save dialog has no viewport control.\n */\nconst previewWidth = computed(() =>\n getEmailFrameWidth(editor?.content.value.settings),\n);\n/**\n * Collapsed height for a tall block. A hero card or a multi-row section would\n * otherwise be taller than the whole list, leaving one row on screen — and\n * seeing the rows together is what makes them orderable. Anything taller\n * collapses to this, fades out at the cut, and can be expanded in place.\n */\nconst COLLAPSED_HEIGHT = 240;\n\nconst frameEl = ref<HTMLElement | null>(null);\nconst contentEl = ref<HTMLElement | null>(null);\nconst scale = ref(1);\n/** Scaled height of the whole block; `null` until first measured. */\nconst fullHeight = ref<number | null>(null);\nconst expanded = ref(false);\n\nlet observer: ResizeObserver | null = null;\n\n/** Only a block taller than the collapsed cap needs fading or expanding. */\nconst isClipped = computed(\n () => fullHeight.value !== null && fullHeight.value > COLLAPSED_HEIGHT,\n);\n\nconst frameHeight = computed(() => {\n if (fullHeight.value === null) return null;\n if (expanded.value || !isClipped.value) return fullHeight.value;\n return COLLAPSED_HEIGHT;\n});\n\nfunction measure(): void {\n const frame = frameEl.value;\n const content = contentEl.value;\n if (!frame || !content) return;\n const width = frame.clientWidth;\n if (width <= 0) return;\n // Never scale up: a dialog wider than the email would blow the preview past\n // its real proportions.\n const next = Math.min(1, width / previewWidth.value);\n scale.value = next;\n fullHeight.value = content.offsetHeight * next;\n}\n\nonMounted(() => {\n measure();\n // Absent in non-DOM test environments. Without it the frame simply falls back\n // to the untransformed height and clips — degraded, never broken.\n if (typeof ResizeObserver === \"undefined\") return;\n observer = new ResizeObserver(() => measure());\n if (frameEl.value) observer.observe(frameEl.value);\n if (contentEl.value) observer.observe(contentEl.value);\n});\n\nonBeforeUnmount(() => {\n observer?.disconnect();\n observer = null;\n});\n\nconst blockLabel = computed(() =>\n getBlockLabel(props.block, t, customBlockDefinitions),\n);\n\nconst handleLabel = computed(() =>\n format(t.savedBlocks.reorderHandle, {\n block: blockLabel.value,\n position: props.position,\n total: props.total,\n }),\n);\n\nconst toggleLabel = computed(() =>\n format(\n expanded.value\n ? t.savedBlocks.collapsePreview\n : t.savedBlocks.expandPreview,\n { block: blockLabel.value },\n ),\n);\n</script>\n\n<template>\n <div\n data-testid=\"saved-blocks-reorder-row\"\n :data-block-id=\"block.id\"\n class=\"tpl:flex tpl:items-start tpl:gap-1.5 tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:p-1.5 tpl:border-[var(--tpl-border)]\"\n style=\"background-color: var(--tpl-bg)\"\n >\n <!-- Sortable's `handle` selector points here, so a pointer-drag can only\n start from the grip — never from the preview itself. -->\n <button\n type=\"button\"\n data-testid=\"saved-blocks-reorder-handle\"\n class=\"tpl-saved-block-reorder-handle tpl:mt-0.5 tpl:shrink-0 tpl:cursor-grab tpl:rounded tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:text-[var(--tpl-text-dim)]\"\n :aria-label=\"handleLabel\"\n :title=\"t.savedBlocks.reorderHint\"\n @keydown.up.prevent=\"emit('move', -1)\"\n @keydown.down.prevent=\"emit('move', 1)\"\n >\n <GripVertical :size=\"14\" :stroke-width=\"1.5\" />\n </button>\n\n <div class=\"tpl:relative tpl:min-w-0 tpl:flex-1\">\n <div\n ref=\"frameEl\"\n class=\"tpl:overflow-hidden\"\n :style=\"{\n height: frameHeight !== null ? `${frameHeight}px` : undefined,\n }\"\n >\n <div\n ref=\"contentEl\"\n :style=\"{\n width: `${previewWidth}px`,\n transform: `scale(${scale})`,\n transformOrigin: 'top left',\n }\"\n >\n <BlockPreviewCanvas :blocks=\"[block]\" />\n </div>\n </div>\n\n <!-- Fade over the cut, so a clipped block reads as truncated rather than\n as a block that simply ends there. Pointer-transparent: it sits over\n the preview, and swallowing clicks would break the row. -->\n <div\n v-if=\"isClipped && !expanded\"\n data-testid=\"saved-blocks-preview-fade\"\n aria-hidden=\"true\"\n class=\"tpl:pointer-events-none tpl:absolute tpl:inset-x-0 tpl:bottom-0 tpl:h-10\"\n style=\"\n background: linear-gradient(\n to bottom,\n transparent,\n var(--tpl-bg) 90%\n );\n \"\n />\n\n <button\n v-if=\"isClipped\"\n type=\"button\"\n data-testid=\"saved-blocks-preview-toggle\"\n class=\"tpl:absolute tpl:bottom-1 tpl:right-1 tpl:flex tpl:cursor-pointer tpl:items-center tpl:gap-0.5 tpl:rounded tpl:border tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg-elevated)] tpl:text-[var(--tpl-text-muted)]\"\n :aria-label=\"toggleLabel\"\n :aria-expanded=\"expanded\"\n @click=\"expanded = !expanded\"\n >\n <component\n :is=\"expanded ? ChevronUp : ChevronDown\"\n :size=\"11\"\n :stroke-width=\"2\"\n />\n {{ expanded ? t.savedBlocks.collapse : t.savedBlocks.expand }}\n </button>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\n/**\n * One reorderable row in the save dialog's preview list: a grip handle beside a\n * scaled-down render of the real block.\n *\n * The preview is the same `BlockPreviewCanvas` the browser modal uses, so\n * a row shows what the block actually looks like rather than a description of\n * it. That canvas is fixed at the 600px email width every block component\n * renders against, so the row shrinks it with `transform: scale()` instead of\n * re-flowing it at dialog width — a re-flow would show a layout the recipient\n * will never see.\n *\n * `transform` doesn't participate in layout, so the frame would keep reserving\n * the full unscaled height; a `ResizeObserver` mirrors the scaled height back\n * onto it. The handle is a sibling of the scaled subtree, so it stays full size\n * and keeps its real hit area.\n *\n * Row chrome uses the raw theme tokens, not the `--tpl-chrome-*` aliases: the\n * chrome is an ancestor and a sibling of the rendered email content, never a\n * descendant, so the `.tpl-block-content` token override can't reach it.\n */\nimport BlockPreviewCanvas from \"./BlockPreviewCanvas.vue\";\nimport { useI18n } from \"../composables\";\nimport { CUSTOM_BLOCK_DEFINITIONS_KEY, EDITOR_KEY } from \"../keys\";\nimport { getEmailFrameWidth } from \"../utils/emailFrameWidth\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { ChevronDown, ChevronUp, GripVertical } from \"@lucide/vue\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, onBeforeUnmount, onMounted, ref } from \"vue\";\n\nconst props = defineProps<{\n block: Block;\n /** 1-based position in the list — spoken by the handle's label. */\n position: number;\n total: number;\n}>();\n\nconst emit = defineEmits<{\n (e: \"move\", delta: -1 | 1): void;\n}>();\n\nconst { t, format } = useI18n();\n// Optional: absent when the consumer registers no custom blocks.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst editor = inject(EDITOR_KEY, null);\n\n/**\n * The width the nested `BlockPreviewCanvas` actually renders at.\n *\n * MUST come from the same helper the canvas uses, because the scale factor below\n * divides by it: a mismatch scales every row by the wrong amount, so a template\n * with a custom body width would overflow its frame or under-fill it. Rows are\n * always the desktop width — the save dialog has no viewport control.\n */\nconst previewWidth = computed(() =>\n getEmailFrameWidth(editor?.content.value.settings),\n);\n/**\n * Collapsed height for a tall block. A hero card or a multi-row section would\n * otherwise be taller than the whole list, leaving one row on screen — and\n * seeing the rows together is what makes them orderable. Anything taller\n * collapses to this, fades out at the cut, and can be expanded in place.\n */\nconst COLLAPSED_HEIGHT = 240;\n\nconst frameEl = ref<HTMLElement | null>(null);\nconst contentEl = ref<HTMLElement | null>(null);\nconst scale = ref(1);\n/** Scaled height of the whole block; `null` until first measured. */\nconst fullHeight = ref<number | null>(null);\nconst expanded = ref(false);\n\nlet observer: ResizeObserver | null = null;\n\n/** Only a block taller than the collapsed cap needs fading or expanding. */\nconst isClipped = computed(\n () => fullHeight.value !== null && fullHeight.value > COLLAPSED_HEIGHT,\n);\n\nconst frameHeight = computed(() => {\n if (fullHeight.value === null) return null;\n if (expanded.value || !isClipped.value) return fullHeight.value;\n return COLLAPSED_HEIGHT;\n});\n\nfunction measure(): void {\n const frame = frameEl.value;\n const content = contentEl.value;\n if (!frame || !content) return;\n const width = frame.clientWidth;\n if (width <= 0) return;\n // Never scale up: a dialog wider than the email would blow the preview past\n // its real proportions.\n const next = Math.min(1, width / previewWidth.value);\n scale.value = next;\n fullHeight.value = content.offsetHeight * next;\n}\n\nonMounted(() => {\n measure();\n // Absent in non-DOM test environments. Without it the frame simply falls back\n // to the untransformed height and clips — degraded, never broken.\n if (typeof ResizeObserver === \"undefined\") return;\n observer = new ResizeObserver(() => measure());\n if (frameEl.value) observer.observe(frameEl.value);\n if (contentEl.value) observer.observe(contentEl.value);\n});\n\nonBeforeUnmount(() => {\n observer?.disconnect();\n observer = null;\n});\n\nconst blockLabel = computed(() =>\n getBlockLabel(props.block, t, customBlockDefinitions),\n);\n\nconst handleLabel = computed(() =>\n format(t.savedBlocks.reorderHandle, {\n block: blockLabel.value,\n position: props.position,\n total: props.total,\n }),\n);\n\nconst toggleLabel = computed(() =>\n format(\n expanded.value\n ? t.savedBlocks.collapsePreview\n : t.savedBlocks.expandPreview,\n { block: blockLabel.value },\n ),\n);\n</script>\n\n<template>\n <div\n data-testid=\"saved-blocks-reorder-row\"\n :data-block-id=\"block.id\"\n class=\"tpl:flex tpl:items-start tpl:gap-1.5 tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:p-1.5 tpl:border-[var(--tpl-border)]\"\n style=\"background-color: var(--tpl-bg)\"\n >\n <!-- Sortable's `handle` selector points here, so a pointer-drag can only\n start from the grip — never from the preview itself. -->\n <button\n type=\"button\"\n data-testid=\"saved-blocks-reorder-handle\"\n class=\"tpl-saved-block-reorder-handle tpl:mt-0.5 tpl:shrink-0 tpl:cursor-grab tpl:rounded tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:text-[var(--tpl-text-dim)]\"\n :aria-label=\"handleLabel\"\n :title=\"t.savedBlocks.reorderHint\"\n @keydown.up.prevent=\"emit('move', -1)\"\n @keydown.down.prevent=\"emit('move', 1)\"\n >\n <GripVertical :size=\"14\" :stroke-width=\"1.5\" />\n </button>\n\n <div class=\"tpl:relative tpl:min-w-0 tpl:flex-1\">\n <div\n ref=\"frameEl\"\n class=\"tpl:overflow-hidden\"\n :style=\"{\n height: frameHeight !== null ? `${frameHeight}px` : undefined,\n }\"\n >\n <div\n ref=\"contentEl\"\n :style=\"{\n width: `${previewWidth}px`,\n transform: `scale(${scale})`,\n transformOrigin: 'top left',\n }\"\n >\n <BlockPreviewCanvas :blocks=\"[block]\" />\n </div>\n </div>\n\n <!-- Fade over the cut, so a clipped block reads as truncated rather than\n as a block that simply ends there. Pointer-transparent: it sits over\n the preview, and swallowing clicks would break the row. -->\n <div\n v-if=\"isClipped && !expanded\"\n data-testid=\"saved-blocks-preview-fade\"\n aria-hidden=\"true\"\n class=\"tpl:pointer-events-none tpl:absolute tpl:inset-x-0 tpl:bottom-0 tpl:h-10\"\n style=\"\n background: linear-gradient(\n to bottom,\n transparent,\n var(--tpl-bg) 90%\n );\n \"\n />\n\n <button\n v-if=\"isClipped\"\n type=\"button\"\n data-testid=\"saved-blocks-preview-toggle\"\n class=\"tpl:absolute tpl:bottom-1 tpl:right-1 tpl:flex tpl:cursor-pointer tpl:items-center tpl:gap-0.5 tpl:rounded tpl:border tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg-elevated)] tpl:text-[var(--tpl-text-muted)]\"\n :aria-label=\"toggleLabel\"\n :aria-expanded=\"expanded\"\n @click=\"expanded = !expanded\"\n >\n <component\n :is=\"expanded ? ChevronUp : ChevronDown\"\n :size=\"11\"\n :stroke-width=\"2\"\n />\n {{ expanded ? t.savedBlocks.collapse : t.savedBlocks.expand }}\n </button>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\nimport SavedBlockPreviewRow from \"./SavedBlockPreviewRow.vue\";\nimport TplModal from \"./TplModal.vue\";\nimport { useI18n } from \"../composables\";\nimport {\n CUSTOM_BLOCK_DEFINITIONS_KEY,\n EDITOR_KEY,\n SAVED_BLOCKS_KEY,\n requireInject,\n} from \"../keys\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { LoaderCircle } from \"@lucide/vue\";\nimport { VueDraggable } from \"vue-draggable-plus\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, ref, watch } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n /**\n * Blocks chosen during the canvas pick session, in the order they were\n * picked — `pickedIds` is built from a `Set`, which iterates in insertion\n * order. That order seeds the reorderable list below. Every id must be a\n * top-level block, which is what the session guarantees.\n */\n pickedIds: readonly string[];\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"saved\"): void;\n}>();\n\nconst { t, format } = useI18n();\nconst editor = requireInject(EDITOR_KEY, \"SaveBlockDialog\");\nconst savedBlocks = requireInject(SAVED_BLOCKS_KEY, \"SaveBlockDialog\");\n// Optional: a consumer with no custom blocks never provides definitions, and\n// the label helper degrades to the type slug rather than throwing.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst name = ref(\"\");\nconst category = ref(\"\");\nconst isSaving = ref(false);\nconst error = ref<string | null>(null);\nconst announcement = ref(\"\");\n\n/**\n * The order the blocks will be saved in: seeded from the pick order, then owned\n * by this dialog once the user drags a row.\n *\n * Ids rather than blocks, deliberately. Resolving late means a block edited\n * while the dialog is open (a collaborator in Cloud) is persisted as it stands\n * at save time, not as it looked when the dialog opened, and an id that stops\n * resolving drops out on its own instead of leaving a stale snapshot behind.\n */\nconst orderedIds = ref<string[]>([]);\n\n/**\n * The picked blocks in the list's current order. Ids that no longer resolve (a\n * block deleted mid-session) simply drop out.\n */\nconst pickedBlocks = computed(() => {\n const byId = new Map(editor.content.value.blocks.map((b) => [b.id, b]));\n return orderedIds.value\n .map((id) => byId.get(id))\n .filter((b): b is Block => b !== undefined);\n});\n\n/**\n * Sortable's model. The setter reads ids straight back off the emitted entries\n * rather than storing them, so a Sortable `el` expando can never reach the\n * payload `create()` sends to the provider — and because the getter is already\n * filtered, writing through it prunes unresolvable ids too.\n */\nconst orderedBlocks = computed<Block[]>({\n get: () => pickedBlocks.value,\n set: (blocks) => {\n orderedIds.value = blocks.map((b) => b.id);\n },\n});\n\n/** Keyboard reorder from a focused grip: swap with the neighbouring row. */\nfunction moveBlock(blockId: string, delta: -1 | 1): void {\n const order = pickedBlocks.value.map((b) => b.id);\n const from = order.indexOf(blockId);\n const to = from + delta;\n if (from === -1 || to < 0 || to >= order.length) return;\n [order[from], order[to]] = [order[to], order[from]];\n orderedIds.value = order;\n\n const moved = pickedBlocks.value[to];\n announcement.value = moved\n ? format(t.savedBlocks.reorderAnnouncement, {\n block: label(moved),\n position: to + 1,\n total: order.length,\n })\n : \"\";\n}\n\n/** Custom blocks read as the consumer's name for them, not the type `custom`. */\nfunction label(block: Block): string {\n return getBlockLabel(block, t, customBlockDefinitions);\n}\n\n/** e.g. \"Title, Paragraph, Button\" — what the user is about to save. */\nconst summary = computed(() => pickedBlocks.value.map(label).join(\", \"));\n\n// `immediate` matters: the dialog is mounted lazily behind a `v-if` on the\n// same state that drives `visible`, so it can mount with `visible` already\n// true and would otherwise never reset the name field or seed the order.\nwatch(\n () => props.visible,\n (visible) => {\n if (visible) {\n name.value = \"\";\n category.value = \"\";\n error.value = null;\n announcement.value = \"\";\n orderedIds.value = [...props.pickedIds];\n }\n },\n { immediate: true },\n);\n\nconst canSave = computed(\n () =>\n name.value.trim().length > 0 &&\n pickedBlocks.value.length > 0 &&\n !isSaving.value,\n);\n\nasync function handleSave(): Promise<void> {\n if (!canSave.value) return;\n\n const blocks = pickedBlocks.value;\n // Defence in depth: never persist an empty saved block (it would list as\n // \"0 block(s)\" and insert nothing). Guards a pick set whose ids no longer\n // resolve to anything on the canvas.\n if (blocks.length === 0) return;\n\n isSaving.value = true;\n error.value = null;\n\n try {\n // Empty stays undefined rather than \"\": an entry is either categorised or\n // it isn't, and a blank string would show up as a nameless filter option.\n await savedBlocks.create(\n name.value.trim(),\n blocks,\n category.value.trim() || undefined,\n );\n emit(\"saved\");\n emit(\"close\");\n } catch (err) {\n error.value = (err as Error).message;\n } finally {\n isSaving.value = false;\n }\n}\n\nfunction handleClose(): void {\n if (!isSaving.value) {\n emit(\"close\");\n }\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n handleSave();\n }\n if (event.key === \"Escape\") {\n handleClose();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-busy=\"isSaving\"\n aria-labelledby=\"tpl-save-block-title\"\n class=\"tpl-scale-in tpl:flex tpl:max-h-[90%] tpl:w-full tpl:max-w-2xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n \"\n >\n <h3\n id=\"tpl-save-block-title\"\n class=\"tpl:mb-4 tpl:shrink-0 tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.savedBlocks.saveAsBlock }}\n </h3>\n\n <!-- Saved block name -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.name }}\n </label>\n <input\n v-model=\"name\"\n type=\"text\"\n data-testid=\"saved-blocks-name-input\"\n :placeholder=\"t.savedBlocks.namePlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n </div>\n\n <!-- Category: free text, with the already-used categories offered as\n suggestions. A datalist rather than a <select> because the set is\n derived from existing entries — there is no registry to pick from,\n and the first entry in a category has to be able to name it. -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n for=\"tpl-save-block-category\"\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.category }}\n </label>\n <input\n id=\"tpl-save-block-category\"\n v-model=\"category\"\n type=\"text\"\n data-testid=\"saved-blocks-category-input\"\n list=\"tpl-saved-block-categories\"\n :placeholder=\"t.savedBlocks.categoryPlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n <datalist id=\"tpl-saved-block-categories\">\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n />\n </datalist>\n </div>\n\n <!-- Summary of what the pick session chose. Which blocks were picked is\n settled on the canvas — the dialog re-presents them only so the user\n can see and order what they're about to save. -->\n <p\n data-testid=\"saved-blocks-save-summary\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ format(t.savedBlocks.savingCount, { count: pickedBlocks.length }) }}\n <span class=\"tpl:text-[var(--tpl-text-dim)]\">{{ summary }}</span>\n </p>\n\n <!-- Reorderable preview. Seeded in pick order and saved in whatever order\n the list ends up in. Its own Sortable instance with no `group`, so it\n can never exchange items with the canvas or a section column. -->\n <p\n v-if=\"pickedBlocks.length > 1\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-[11px] tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.reorderHint }}\n </p>\n <!-- The only growing region: takes whatever the dialog's backdrop cap\n leaves after the fixed chrome, rather than a hand-tuned vh guess that\n a row expanding to full height would blow past. `min-h-0` is what\n lets a flex child actually shrink below its content and scroll. -->\n <VueDraggable\n v-model=\"orderedBlocks\"\n data-testid=\"saved-blocks-reorder-list\"\n :animation=\"150\"\n ghost-class=\"tpl-ghost\"\n drag-class=\"tpl-dragging\"\n handle=\".tpl-saved-block-reorder-handle\"\n :force-fallback=\"true\"\n class=\"tpl:mb-4 tpl:flex tpl:min-h-0 tpl:flex-1 tpl:flex-col tpl:gap-1.5 tpl:overflow-y-auto\"\n >\n <SavedBlockPreviewRow\n v-for=\"(block, index) in orderedBlocks\"\n :key=\"block.id\"\n :block=\"block\"\n :position=\"index + 1\"\n :total=\"orderedBlocks.length\"\n @move=\"(delta) => moveBlock(block.id, delta)\"\n />\n </VueDraggable>\n\n <!-- Keyboard reorder announcement region (visually hidden, live) -->\n <div\n class=\"tpl-sr-only\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n {{ announcement }}\n </div>\n\n <!-- Error message -->\n <p\n v-if=\"error\"\n role=\"alert\"\n class=\"tpl:mb-3 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-danger)]\"\n >\n {{ error }}\n </p>\n\n <!-- Actions -->\n <div class=\"tpl:flex tpl:shrink-0 tpl:justify-end tpl:gap-2\">\n <button\n type=\"button\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n :disabled=\"isSaving\"\n :class=\"{\n 'tpl:cursor-not-allowed tpl:opacity-50': isSaving,\n }\"\n @click=\"handleClose\"\n >\n {{ t.savedBlocks.cancel }}\n </button>\n <button\n type=\"button\"\n :class=\"primaryBtnClass\"\n :disabled=\"!canSave\"\n @click=\"handleSave\"\n >\n <span v-if=\"isSaving\" class=\"tpl:flex tpl:items-center tpl:gap-1.5\">\n <LoaderCircle\n class=\"tpl:animate-spin\"\n :size=\"12\"\n :stroke-width=\"2\"\n />\n {{ t.savedBlocks.saving }}\n </span>\n <span v-else>\n {{ t.savedBlocks.save }}\n </span>\n </button>\n </div>\n </div>\n </TplModal>\n</template>\n","<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\nimport SavedBlockPreviewRow from \"./SavedBlockPreviewRow.vue\";\nimport TplModal from \"./TplModal.vue\";\nimport { useI18n } from \"../composables\";\nimport {\n CUSTOM_BLOCK_DEFINITIONS_KEY,\n EDITOR_KEY,\n SAVED_BLOCKS_KEY,\n requireInject,\n} from \"../keys\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { LoaderCircle } from \"@lucide/vue\";\nimport { VueDraggable } from \"vue-draggable-plus\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, ref, watch } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n /**\n * Blocks chosen during the canvas pick session, in the order they were\n * picked — `pickedIds` is built from a `Set`, which iterates in insertion\n * order. That order seeds the reorderable list below. Every id must be a\n * top-level block, which is what the session guarantees.\n */\n pickedIds: readonly string[];\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"saved\"): void;\n}>();\n\nconst { t, format } = useI18n();\nconst editor = requireInject(EDITOR_KEY, \"SaveBlockDialog\");\nconst savedBlocks = requireInject(SAVED_BLOCKS_KEY, \"SaveBlockDialog\");\n// Optional: a consumer with no custom blocks never provides definitions, and\n// the label helper degrades to the type slug rather than throwing.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst name = ref(\"\");\nconst category = ref(\"\");\nconst isSaving = ref(false);\nconst error = ref<string | null>(null);\nconst announcement = ref(\"\");\n\n/**\n * The order the blocks will be saved in: seeded from the pick order, then owned\n * by this dialog once the user drags a row.\n *\n * Ids rather than blocks, deliberately. Resolving late means a block edited\n * while the dialog is open (a collaborator in Cloud) is persisted as it stands\n * at save time, not as it looked when the dialog opened, and an id that stops\n * resolving drops out on its own instead of leaving a stale snapshot behind.\n */\nconst orderedIds = ref<string[]>([]);\n\n/**\n * The picked blocks in the list's current order. Ids that no longer resolve (a\n * block deleted mid-session) simply drop out.\n */\nconst pickedBlocks = computed(() => {\n const byId = new Map(editor.content.value.blocks.map((b) => [b.id, b]));\n return orderedIds.value\n .map((id) => byId.get(id))\n .filter((b): b is Block => b !== undefined);\n});\n\n/**\n * Sortable's model. The setter reads ids straight back off the emitted entries\n * rather than storing them, so a Sortable `el` expando can never reach the\n * payload `create()` sends to the provider — and because the getter is already\n * filtered, writing through it prunes unresolvable ids too.\n */\nconst orderedBlocks = computed<Block[]>({\n get: () => pickedBlocks.value,\n set: (blocks) => {\n orderedIds.value = blocks.map((b) => b.id);\n },\n});\n\n/** Keyboard reorder from a focused grip: swap with the neighbouring row. */\nfunction moveBlock(blockId: string, delta: -1 | 1): void {\n const order = pickedBlocks.value.map((b) => b.id);\n const from = order.indexOf(blockId);\n const to = from + delta;\n if (from === -1 || to < 0 || to >= order.length) return;\n [order[from], order[to]] = [order[to], order[from]];\n orderedIds.value = order;\n\n const moved = pickedBlocks.value[to];\n announcement.value = moved\n ? format(t.savedBlocks.reorderAnnouncement, {\n block: label(moved),\n position: to + 1,\n total: order.length,\n })\n : \"\";\n}\n\n/** Custom blocks read as the consumer's name for them, not the type `custom`. */\nfunction label(block: Block): string {\n return getBlockLabel(block, t, customBlockDefinitions);\n}\n\n/** e.g. \"Title, Paragraph, Button\" — what the user is about to save. */\nconst summary = computed(() => pickedBlocks.value.map(label).join(\", \"));\n\n// `immediate` matters: the dialog is mounted lazily behind a `v-if` on the\n// same state that drives `visible`, so it can mount with `visible` already\n// true and would otherwise never reset the name field or seed the order.\nwatch(\n () => props.visible,\n (visible) => {\n if (visible) {\n name.value = \"\";\n category.value = \"\";\n error.value = null;\n announcement.value = \"\";\n orderedIds.value = [...props.pickedIds];\n }\n },\n { immediate: true },\n);\n\nconst canSave = computed(\n () =>\n name.value.trim().length > 0 &&\n pickedBlocks.value.length > 0 &&\n !isSaving.value,\n);\n\nasync function handleSave(): Promise<void> {\n if (!canSave.value) return;\n\n const blocks = pickedBlocks.value;\n // Defence in depth: never persist an empty saved block (it would list as\n // \"0 block(s)\" and insert nothing). Guards a pick set whose ids no longer\n // resolve to anything on the canvas.\n if (blocks.length === 0) return;\n\n isSaving.value = true;\n error.value = null;\n\n try {\n // Empty stays undefined rather than \"\": an entry is either categorised or\n // it isn't, and a blank string would show up as a nameless filter option.\n await savedBlocks.create(\n name.value.trim(),\n blocks,\n category.value.trim() || undefined,\n );\n emit(\"saved\");\n emit(\"close\");\n } catch (err) {\n error.value = (err as Error).message;\n } finally {\n isSaving.value = false;\n }\n}\n\nfunction handleClose(): void {\n if (!isSaving.value) {\n emit(\"close\");\n }\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n handleSave();\n }\n if (event.key === \"Escape\") {\n handleClose();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-busy=\"isSaving\"\n aria-labelledby=\"tpl-save-block-title\"\n class=\"tpl-scale-in tpl:flex tpl:max-h-[90%] tpl:w-full tpl:max-w-2xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n \"\n >\n <h3\n id=\"tpl-save-block-title\"\n class=\"tpl:mb-4 tpl:shrink-0 tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.savedBlocks.saveAsBlock }}\n </h3>\n\n <!-- Saved block name -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.name }}\n </label>\n <input\n v-model=\"name\"\n type=\"text\"\n data-testid=\"saved-blocks-name-input\"\n :placeholder=\"t.savedBlocks.namePlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n </div>\n\n <!-- Category: free text, with the already-used categories offered as\n suggestions. A datalist rather than a <select> because the set is\n derived from existing entries — there is no registry to pick from,\n and the first entry in a category has to be able to name it. -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n for=\"tpl-save-block-category\"\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.category }}\n </label>\n <input\n id=\"tpl-save-block-category\"\n v-model=\"category\"\n type=\"text\"\n data-testid=\"saved-blocks-category-input\"\n list=\"tpl-saved-block-categories\"\n :placeholder=\"t.savedBlocks.categoryPlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n <datalist id=\"tpl-saved-block-categories\">\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n />\n </datalist>\n </div>\n\n <!-- Summary of what the pick session chose. Which blocks were picked is\n settled on the canvas — the dialog re-presents them only so the user\n can see and order what they're about to save. -->\n <p\n data-testid=\"saved-blocks-save-summary\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ format(t.savedBlocks.savingCount, { count: pickedBlocks.length }) }}\n <span class=\"tpl:text-[var(--tpl-text-dim)]\">{{ summary }}</span>\n </p>\n\n <!-- Reorderable preview. Seeded in pick order and saved in whatever order\n the list ends up in. Its own Sortable instance with no `group`, so it\n can never exchange items with the canvas or a section column. -->\n <p\n v-if=\"pickedBlocks.length > 1\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-[11px] tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.reorderHint }}\n </p>\n <!-- The only growing region: takes whatever the dialog's backdrop cap\n leaves after the fixed chrome, rather than a hand-tuned vh guess that\n a row expanding to full height would blow past. `min-h-0` is what\n lets a flex child actually shrink below its content and scroll. -->\n <VueDraggable\n v-model=\"orderedBlocks\"\n data-testid=\"saved-blocks-reorder-list\"\n :animation=\"150\"\n ghost-class=\"tpl-ghost\"\n drag-class=\"tpl-dragging\"\n handle=\".tpl-saved-block-reorder-handle\"\n :force-fallback=\"true\"\n class=\"tpl:mb-4 tpl:flex tpl:min-h-0 tpl:flex-1 tpl:flex-col tpl:gap-1.5 tpl:overflow-y-auto\"\n >\n <SavedBlockPreviewRow\n v-for=\"(block, index) in orderedBlocks\"\n :key=\"block.id\"\n :block=\"block\"\n :position=\"index + 1\"\n :total=\"orderedBlocks.length\"\n @move=\"(delta) => moveBlock(block.id, delta)\"\n />\n </VueDraggable>\n\n <!-- Keyboard reorder announcement region (visually hidden, live) -->\n <div\n class=\"tpl-sr-only\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n {{ announcement }}\n </div>\n\n <!-- Error message -->\n <p\n v-if=\"error\"\n role=\"alert\"\n class=\"tpl:mb-3 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-danger)]\"\n >\n {{ error }}\n </p>\n\n <!-- Actions -->\n <div class=\"tpl:flex tpl:shrink-0 tpl:justify-end tpl:gap-2\">\n <button\n type=\"button\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n :disabled=\"isSaving\"\n :class=\"{\n 'tpl:cursor-not-allowed tpl:opacity-50': isSaving,\n }\"\n @click=\"handleClose\"\n >\n {{ t.savedBlocks.cancel }}\n </button>\n <button\n type=\"button\"\n :class=\"primaryBtnClass\"\n :disabled=\"!canSave\"\n @click=\"handleSave\"\n >\n <span v-if=\"isSaving\" class=\"tpl:flex tpl:items-center tpl:gap-1.5\">\n <LoaderCircle\n class=\"tpl:animate-spin\"\n :size=\"12\"\n :stroke-width=\"2\"\n />\n {{ t.savedBlocks.saving }}\n </span>\n <span v-else>\n {{ t.savedBlocks.save }}\n </span>\n </button>\n </div>\n </div>\n </TplModal>\n</template>\n"],"mappings":"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"}
1
+ {"version":3,"file":"SaveBlockDialog-DYr5yKqy.js","names":[],"sources":["../../../src/components/SavedBlockPreviewRow.vue","../../../src/components/SavedBlockPreviewRow.vue","../../../src/components/SaveBlockDialog.vue","../../../src/components/SaveBlockDialog.vue"],"sourcesContent":["<script setup lang=\"ts\">\n/**\n * One reorderable row in the save dialog's preview list: a grip handle beside a\n * scaled-down render of the real block.\n *\n * The preview is the same `BlockPreviewCanvas` the browser modal uses, so\n * a row shows what the block actually looks like rather than a description of\n * it. That canvas is fixed at the 600px email width every block component\n * renders against, so the row shrinks it with `transform: scale()` instead of\n * re-flowing it at dialog width — a re-flow would show a layout the recipient\n * will never see.\n *\n * `transform` doesn't participate in layout, so the frame would keep reserving\n * the full unscaled height; a `ResizeObserver` mirrors the scaled height back\n * onto it. The handle is a sibling of the scaled subtree, so it stays full size\n * and keeps its real hit area.\n *\n * Row chrome uses the raw theme tokens, not the `--tpl-chrome-*` aliases: the\n * chrome is an ancestor and a sibling of the rendered email content, never a\n * descendant, so the `.tpl-block-content` token override can't reach it.\n */\nimport BlockPreviewCanvas from \"./BlockPreviewCanvas.vue\";\nimport { useI18n } from \"../composables\";\nimport { CUSTOM_BLOCK_DEFINITIONS_KEY, EDITOR_KEY } from \"../keys\";\nimport { getEmailFrameWidth } from \"../utils/emailFrameWidth\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { ChevronDown, ChevronUp, GripVertical } from \"@lucide/vue\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, onBeforeUnmount, onMounted, ref } from \"vue\";\n\nconst props = defineProps<{\n block: Block;\n /** 1-based position in the list — spoken by the handle's label. */\n position: number;\n total: number;\n}>();\n\nconst emit = defineEmits<{\n (e: \"move\", delta: -1 | 1): void;\n}>();\n\nconst { t, format } = useI18n();\n// Optional: absent when the consumer registers no custom blocks.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst editor = inject(EDITOR_KEY, null);\n\n/**\n * The width the nested `BlockPreviewCanvas` actually renders at.\n *\n * MUST come from the same helper the canvas uses, because the scale factor below\n * divides by it: a mismatch scales every row by the wrong amount, so a template\n * with a custom body width would overflow its frame or under-fill it. Rows are\n * always the desktop width — the save dialog has no viewport control.\n */\nconst previewWidth = computed(() =>\n getEmailFrameWidth(editor?.content.value.settings),\n);\n/**\n * Collapsed height for a tall block. A hero card or a multi-row section would\n * otherwise be taller than the whole list, leaving one row on screen — and\n * seeing the rows together is what makes them orderable. Anything taller\n * collapses to this, fades out at the cut, and can be expanded in place.\n */\nconst COLLAPSED_HEIGHT = 240;\n\nconst frameEl = ref<HTMLElement | null>(null);\nconst contentEl = ref<HTMLElement | null>(null);\nconst scale = ref(1);\n/** Scaled height of the whole block; `null` until first measured. */\nconst fullHeight = ref<number | null>(null);\nconst expanded = ref(false);\n\nlet observer: ResizeObserver | null = null;\n\n/** Only a block taller than the collapsed cap needs fading or expanding. */\nconst isClipped = computed(\n () => fullHeight.value !== null && fullHeight.value > COLLAPSED_HEIGHT,\n);\n\nconst frameHeight = computed(() => {\n if (fullHeight.value === null) return null;\n if (expanded.value || !isClipped.value) return fullHeight.value;\n return COLLAPSED_HEIGHT;\n});\n\nfunction measure(): void {\n const frame = frameEl.value;\n const content = contentEl.value;\n if (!frame || !content) return;\n const width = frame.clientWidth;\n if (width <= 0) return;\n // Never scale up: a dialog wider than the email would blow the preview past\n // its real proportions.\n const next = Math.min(1, width / previewWidth.value);\n scale.value = next;\n fullHeight.value = content.offsetHeight * next;\n}\n\nonMounted(() => {\n measure();\n // Absent in non-DOM test environments. Without it the frame simply falls back\n // to the untransformed height and clips — degraded, never broken.\n if (typeof ResizeObserver === \"undefined\") return;\n observer = new ResizeObserver(() => measure());\n if (frameEl.value) observer.observe(frameEl.value);\n if (contentEl.value) observer.observe(contentEl.value);\n});\n\nonBeforeUnmount(() => {\n observer?.disconnect();\n observer = null;\n});\n\nconst blockLabel = computed(() =>\n getBlockLabel(props.block, t, customBlockDefinitions),\n);\n\nconst handleLabel = computed(() =>\n format(t.savedBlocks.reorderHandle, {\n block: blockLabel.value,\n position: props.position,\n total: props.total,\n }),\n);\n\nconst toggleLabel = computed(() =>\n format(\n expanded.value\n ? t.savedBlocks.collapsePreview\n : t.savedBlocks.expandPreview,\n { block: blockLabel.value },\n ),\n);\n</script>\n\n<template>\n <div\n data-testid=\"saved-blocks-reorder-row\"\n :data-block-id=\"block.id\"\n class=\"tpl:flex tpl:items-start tpl:gap-1.5 tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:p-1.5 tpl:border-[var(--tpl-border)]\"\n style=\"background-color: var(--tpl-bg)\"\n >\n <!-- Sortable's `handle` selector points here, so a pointer-drag can only\n start from the grip — never from the preview itself. -->\n <button\n type=\"button\"\n data-testid=\"saved-blocks-reorder-handle\"\n class=\"tpl-saved-block-reorder-handle tpl:mt-0.5 tpl:shrink-0 tpl:cursor-grab tpl:rounded tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:text-[var(--tpl-text-dim)]\"\n :aria-label=\"handleLabel\"\n :title=\"t.savedBlocks.reorderHint\"\n @keydown.up.prevent=\"emit('move', -1)\"\n @keydown.down.prevent=\"emit('move', 1)\"\n >\n <GripVertical :size=\"14\" :stroke-width=\"1.5\" />\n </button>\n\n <div class=\"tpl:relative tpl:min-w-0 tpl:flex-1\">\n <div\n ref=\"frameEl\"\n class=\"tpl:overflow-hidden\"\n :style=\"{\n height: frameHeight !== null ? `${frameHeight}px` : undefined,\n }\"\n >\n <div\n ref=\"contentEl\"\n :style=\"{\n width: `${previewWidth}px`,\n transform: `scale(${scale})`,\n transformOrigin: 'top left',\n }\"\n >\n <BlockPreviewCanvas :blocks=\"[block]\" />\n </div>\n </div>\n\n <!-- Fade over the cut, so a clipped block reads as truncated rather than\n as a block that simply ends there. Pointer-transparent: it sits over\n the preview, and swallowing clicks would break the row. -->\n <div\n v-if=\"isClipped && !expanded\"\n data-testid=\"saved-blocks-preview-fade\"\n aria-hidden=\"true\"\n class=\"tpl:pointer-events-none tpl:absolute tpl:inset-x-0 tpl:bottom-0 tpl:h-10\"\n style=\"\n background: linear-gradient(\n to bottom,\n transparent,\n var(--tpl-bg) 90%\n );\n \"\n />\n\n <button\n v-if=\"isClipped\"\n type=\"button\"\n data-testid=\"saved-blocks-preview-toggle\"\n class=\"tpl:absolute tpl:bottom-1 tpl:right-1 tpl:flex tpl:cursor-pointer tpl:items-center tpl:gap-0.5 tpl:rounded tpl:border tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg-elevated)] tpl:text-[var(--tpl-text-muted)]\"\n :aria-label=\"toggleLabel\"\n :aria-expanded=\"expanded\"\n @click=\"expanded = !expanded\"\n >\n <component\n :is=\"expanded ? ChevronUp : ChevronDown\"\n :size=\"11\"\n :stroke-width=\"2\"\n />\n {{ expanded ? t.savedBlocks.collapse : t.savedBlocks.expand }}\n </button>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\n/**\n * One reorderable row in the save dialog's preview list: a grip handle beside a\n * scaled-down render of the real block.\n *\n * The preview is the same `BlockPreviewCanvas` the browser modal uses, so\n * a row shows what the block actually looks like rather than a description of\n * it. That canvas is fixed at the 600px email width every block component\n * renders against, so the row shrinks it with `transform: scale()` instead of\n * re-flowing it at dialog width — a re-flow would show a layout the recipient\n * will never see.\n *\n * `transform` doesn't participate in layout, so the frame would keep reserving\n * the full unscaled height; a `ResizeObserver` mirrors the scaled height back\n * onto it. The handle is a sibling of the scaled subtree, so it stays full size\n * and keeps its real hit area.\n *\n * Row chrome uses the raw theme tokens, not the `--tpl-chrome-*` aliases: the\n * chrome is an ancestor and a sibling of the rendered email content, never a\n * descendant, so the `.tpl-block-content` token override can't reach it.\n */\nimport BlockPreviewCanvas from \"./BlockPreviewCanvas.vue\";\nimport { useI18n } from \"../composables\";\nimport { CUSTOM_BLOCK_DEFINITIONS_KEY, EDITOR_KEY } from \"../keys\";\nimport { getEmailFrameWidth } from \"../utils/emailFrameWidth\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { ChevronDown, ChevronUp, GripVertical } from \"@lucide/vue\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, onBeforeUnmount, onMounted, ref } from \"vue\";\n\nconst props = defineProps<{\n block: Block;\n /** 1-based position in the list — spoken by the handle's label. */\n position: number;\n total: number;\n}>();\n\nconst emit = defineEmits<{\n (e: \"move\", delta: -1 | 1): void;\n}>();\n\nconst { t, format } = useI18n();\n// Optional: absent when the consumer registers no custom blocks.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst editor = inject(EDITOR_KEY, null);\n\n/**\n * The width the nested `BlockPreviewCanvas` actually renders at.\n *\n * MUST come from the same helper the canvas uses, because the scale factor below\n * divides by it: a mismatch scales every row by the wrong amount, so a template\n * with a custom body width would overflow its frame or under-fill it. Rows are\n * always the desktop width — the save dialog has no viewport control.\n */\nconst previewWidth = computed(() =>\n getEmailFrameWidth(editor?.content.value.settings),\n);\n/**\n * Collapsed height for a tall block. A hero card or a multi-row section would\n * otherwise be taller than the whole list, leaving one row on screen — and\n * seeing the rows together is what makes them orderable. Anything taller\n * collapses to this, fades out at the cut, and can be expanded in place.\n */\nconst COLLAPSED_HEIGHT = 240;\n\nconst frameEl = ref<HTMLElement | null>(null);\nconst contentEl = ref<HTMLElement | null>(null);\nconst scale = ref(1);\n/** Scaled height of the whole block; `null` until first measured. */\nconst fullHeight = ref<number | null>(null);\nconst expanded = ref(false);\n\nlet observer: ResizeObserver | null = null;\n\n/** Only a block taller than the collapsed cap needs fading or expanding. */\nconst isClipped = computed(\n () => fullHeight.value !== null && fullHeight.value > COLLAPSED_HEIGHT,\n);\n\nconst frameHeight = computed(() => {\n if (fullHeight.value === null) return null;\n if (expanded.value || !isClipped.value) return fullHeight.value;\n return COLLAPSED_HEIGHT;\n});\n\nfunction measure(): void {\n const frame = frameEl.value;\n const content = contentEl.value;\n if (!frame || !content) return;\n const width = frame.clientWidth;\n if (width <= 0) return;\n // Never scale up: a dialog wider than the email would blow the preview past\n // its real proportions.\n const next = Math.min(1, width / previewWidth.value);\n scale.value = next;\n fullHeight.value = content.offsetHeight * next;\n}\n\nonMounted(() => {\n measure();\n // Absent in non-DOM test environments. Without it the frame simply falls back\n // to the untransformed height and clips — degraded, never broken.\n if (typeof ResizeObserver === \"undefined\") return;\n observer = new ResizeObserver(() => measure());\n if (frameEl.value) observer.observe(frameEl.value);\n if (contentEl.value) observer.observe(contentEl.value);\n});\n\nonBeforeUnmount(() => {\n observer?.disconnect();\n observer = null;\n});\n\nconst blockLabel = computed(() =>\n getBlockLabel(props.block, t, customBlockDefinitions),\n);\n\nconst handleLabel = computed(() =>\n format(t.savedBlocks.reorderHandle, {\n block: blockLabel.value,\n position: props.position,\n total: props.total,\n }),\n);\n\nconst toggleLabel = computed(() =>\n format(\n expanded.value\n ? t.savedBlocks.collapsePreview\n : t.savedBlocks.expandPreview,\n { block: blockLabel.value },\n ),\n);\n</script>\n\n<template>\n <div\n data-testid=\"saved-blocks-reorder-row\"\n :data-block-id=\"block.id\"\n class=\"tpl:flex tpl:items-start tpl:gap-1.5 tpl:rounded-[var(--tpl-radius-md)] tpl:border tpl:p-1.5 tpl:border-[var(--tpl-border)]\"\n style=\"background-color: var(--tpl-bg)\"\n >\n <!-- Sortable's `handle` selector points here, so a pointer-drag can only\n start from the grip — never from the preview itself. -->\n <button\n type=\"button\"\n data-testid=\"saved-blocks-reorder-handle\"\n class=\"tpl-saved-block-reorder-handle tpl:mt-0.5 tpl:shrink-0 tpl:cursor-grab tpl:rounded tpl:border-none tpl:bg-transparent tpl:p-0.5 tpl:text-[var(--tpl-text-dim)]\"\n :aria-label=\"handleLabel\"\n :title=\"t.savedBlocks.reorderHint\"\n @keydown.up.prevent=\"emit('move', -1)\"\n @keydown.down.prevent=\"emit('move', 1)\"\n >\n <GripVertical :size=\"14\" :stroke-width=\"1.5\" />\n </button>\n\n <div class=\"tpl:relative tpl:min-w-0 tpl:flex-1\">\n <div\n ref=\"frameEl\"\n class=\"tpl:overflow-hidden\"\n :style=\"{\n height: frameHeight !== null ? `${frameHeight}px` : undefined,\n }\"\n >\n <div\n ref=\"contentEl\"\n :style=\"{\n width: `${previewWidth}px`,\n transform: `scale(${scale})`,\n transformOrigin: 'top left',\n }\"\n >\n <BlockPreviewCanvas :blocks=\"[block]\" />\n </div>\n </div>\n\n <!-- Fade over the cut, so a clipped block reads as truncated rather than\n as a block that simply ends there. Pointer-transparent: it sits over\n the preview, and swallowing clicks would break the row. -->\n <div\n v-if=\"isClipped && !expanded\"\n data-testid=\"saved-blocks-preview-fade\"\n aria-hidden=\"true\"\n class=\"tpl:pointer-events-none tpl:absolute tpl:inset-x-0 tpl:bottom-0 tpl:h-10\"\n style=\"\n background: linear-gradient(\n to bottom,\n transparent,\n var(--tpl-bg) 90%\n );\n \"\n />\n\n <button\n v-if=\"isClipped\"\n type=\"button\"\n data-testid=\"saved-blocks-preview-toggle\"\n class=\"tpl:absolute tpl:bottom-1 tpl:right-1 tpl:flex tpl:cursor-pointer tpl:items-center tpl:gap-0.5 tpl:rounded tpl:border tpl:px-1.5 tpl:py-0.5 tpl:text-[10px] tpl:font-medium tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg-elevated)] tpl:text-[var(--tpl-text-muted)]\"\n :aria-label=\"toggleLabel\"\n :aria-expanded=\"expanded\"\n @click=\"expanded = !expanded\"\n >\n <component\n :is=\"expanded ? ChevronUp : ChevronDown\"\n :size=\"11\"\n :stroke-width=\"2\"\n />\n {{ expanded ? t.savedBlocks.collapse : t.savedBlocks.expand }}\n </button>\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\nimport SavedBlockPreviewRow from \"./SavedBlockPreviewRow.vue\";\nimport TplModal from \"./TplModal.vue\";\nimport { useI18n } from \"../composables\";\nimport {\n CUSTOM_BLOCK_DEFINITIONS_KEY,\n EDITOR_KEY,\n SAVED_BLOCKS_KEY,\n requireInject,\n} from \"../keys\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { LoaderCircle } from \"@lucide/vue\";\nimport { VueDraggable } from \"vue-draggable-plus\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, ref, watch } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n /**\n * Blocks chosen during the canvas pick session, in the order they were\n * picked — `pickedIds` is built from a `Set`, which iterates in insertion\n * order. That order seeds the reorderable list below. Every id must be a\n * top-level block, which is what the session guarantees.\n */\n pickedIds: readonly string[];\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"saved\"): void;\n}>();\n\nconst { t, format } = useI18n();\nconst editor = requireInject(EDITOR_KEY, \"SaveBlockDialog\");\nconst savedBlocks = requireInject(SAVED_BLOCKS_KEY, \"SaveBlockDialog\");\n// Optional: a consumer with no custom blocks never provides definitions, and\n// the label helper degrades to the type slug rather than throwing.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst name = ref(\"\");\nconst category = ref(\"\");\nconst isSaving = ref(false);\nconst error = ref<string | null>(null);\nconst announcement = ref(\"\");\n\n/**\n * The order the blocks will be saved in: seeded from the pick order, then owned\n * by this dialog once the user drags a row.\n *\n * Ids rather than blocks, deliberately. Resolving late means a block edited\n * while the dialog is open (a collaborator in Cloud) is persisted as it stands\n * at save time, not as it looked when the dialog opened, and an id that stops\n * resolving drops out on its own instead of leaving a stale snapshot behind.\n */\nconst orderedIds = ref<string[]>([]);\n\n/**\n * The picked blocks in the list's current order. Ids that no longer resolve (a\n * block deleted mid-session) simply drop out.\n */\nconst pickedBlocks = computed(() => {\n const byId = new Map(editor.content.value.blocks.map((b) => [b.id, b]));\n return orderedIds.value\n .map((id) => byId.get(id))\n .filter((b): b is Block => b !== undefined);\n});\n\n/**\n * Sortable's model. The setter reads ids straight back off the emitted entries\n * rather than storing them, so a Sortable `el` expando can never reach the\n * payload `create()` sends to the provider — and because the getter is already\n * filtered, writing through it prunes unresolvable ids too.\n */\nconst orderedBlocks = computed<Block[]>({\n get: () => pickedBlocks.value,\n set: (blocks) => {\n orderedIds.value = blocks.map((b) => b.id);\n },\n});\n\n/** Keyboard reorder from a focused grip: swap with the neighbouring row. */\nfunction moveBlock(blockId: string, delta: -1 | 1): void {\n const order = pickedBlocks.value.map((b) => b.id);\n const from = order.indexOf(blockId);\n const to = from + delta;\n if (from === -1 || to < 0 || to >= order.length) return;\n [order[from], order[to]] = [order[to], order[from]];\n orderedIds.value = order;\n\n const moved = pickedBlocks.value[to];\n announcement.value = moved\n ? format(t.savedBlocks.reorderAnnouncement, {\n block: label(moved),\n position: to + 1,\n total: order.length,\n })\n : \"\";\n}\n\n/** Custom blocks read as the consumer's name for them, not the type `custom`. */\nfunction label(block: Block): string {\n return getBlockLabel(block, t, customBlockDefinitions);\n}\n\n/** e.g. \"Title, Paragraph, Button\" — what the user is about to save. */\nconst summary = computed(() => pickedBlocks.value.map(label).join(\", \"));\n\n// `immediate` matters: the dialog is mounted lazily behind a `v-if` on the\n// same state that drives `visible`, so it can mount with `visible` already\n// true and would otherwise never reset the name field or seed the order.\nwatch(\n () => props.visible,\n (visible) => {\n if (visible) {\n name.value = \"\";\n category.value = \"\";\n error.value = null;\n announcement.value = \"\";\n orderedIds.value = [...props.pickedIds];\n }\n },\n { immediate: true },\n);\n\nconst canSave = computed(\n () =>\n name.value.trim().length > 0 &&\n pickedBlocks.value.length > 0 &&\n !isSaving.value,\n);\n\nasync function handleSave(): Promise<void> {\n if (!canSave.value) return;\n\n const blocks = pickedBlocks.value;\n // Defence in depth: never persist an empty saved block (it would list as\n // \"0 block(s)\" and insert nothing). Guards a pick set whose ids no longer\n // resolve to anything on the canvas.\n if (blocks.length === 0) return;\n\n isSaving.value = true;\n error.value = null;\n\n try {\n // Empty stays undefined rather than \"\": an entry is either categorised or\n // it isn't, and a blank string would show up as a nameless filter option.\n await savedBlocks.create(\n name.value.trim(),\n blocks,\n category.value.trim() || undefined,\n );\n emit(\"saved\");\n emit(\"close\");\n } catch (err) {\n error.value = (err as Error).message;\n } finally {\n isSaving.value = false;\n }\n}\n\nfunction handleClose(): void {\n if (!isSaving.value) {\n emit(\"close\");\n }\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n handleSave();\n }\n if (event.key === \"Escape\") {\n handleClose();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-busy=\"isSaving\"\n aria-labelledby=\"tpl-save-block-title\"\n class=\"tpl-scale-in tpl:flex tpl:max-h-[90%] tpl:w-full tpl:max-w-2xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n \"\n >\n <h3\n id=\"tpl-save-block-title\"\n class=\"tpl:mb-4 tpl:shrink-0 tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.savedBlocks.saveAsBlock }}\n </h3>\n\n <!-- Saved block name -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.name }}\n </label>\n <input\n v-model=\"name\"\n type=\"text\"\n data-testid=\"saved-blocks-name-input\"\n :placeholder=\"t.savedBlocks.namePlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n </div>\n\n <!-- Category: free text, with the already-used categories offered as\n suggestions. A datalist rather than a <select> because the set is\n derived from existing entries — there is no registry to pick from,\n and the first entry in a category has to be able to name it. -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n for=\"tpl-save-block-category\"\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.category }}\n </label>\n <input\n id=\"tpl-save-block-category\"\n v-model=\"category\"\n type=\"text\"\n data-testid=\"saved-blocks-category-input\"\n list=\"tpl-saved-block-categories\"\n :placeholder=\"t.savedBlocks.categoryPlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n <datalist id=\"tpl-saved-block-categories\">\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n />\n </datalist>\n </div>\n\n <!-- Summary of what the pick session chose. Which blocks were picked is\n settled on the canvas — the dialog re-presents them only so the user\n can see and order what they're about to save. -->\n <p\n data-testid=\"saved-blocks-save-summary\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ format(t.savedBlocks.savingCount, { count: pickedBlocks.length }) }}\n <span class=\"tpl:text-[var(--tpl-text-dim)]\">{{ summary }}</span>\n </p>\n\n <!-- Reorderable preview. Seeded in pick order and saved in whatever order\n the list ends up in. Its own Sortable instance with no `group`, so it\n can never exchange items with the canvas or a section column. -->\n <p\n v-if=\"pickedBlocks.length > 1\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-[11px] tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.reorderHint }}\n </p>\n <!-- The only growing region: takes whatever the dialog's backdrop cap\n leaves after the fixed chrome, rather than a hand-tuned vh guess that\n a row expanding to full height would blow past. `min-h-0` is what\n lets a flex child actually shrink below its content and scroll. -->\n <VueDraggable\n v-model=\"orderedBlocks\"\n data-testid=\"saved-blocks-reorder-list\"\n :animation=\"150\"\n ghost-class=\"tpl-ghost\"\n drag-class=\"tpl-dragging\"\n handle=\".tpl-saved-block-reorder-handle\"\n :force-fallback=\"true\"\n class=\"tpl:mb-4 tpl:flex tpl:min-h-0 tpl:flex-1 tpl:flex-col tpl:gap-1.5 tpl:overflow-y-auto\"\n >\n <SavedBlockPreviewRow\n v-for=\"(block, index) in orderedBlocks\"\n :key=\"block.id\"\n :block=\"block\"\n :position=\"index + 1\"\n :total=\"orderedBlocks.length\"\n @move=\"(delta) => moveBlock(block.id, delta)\"\n />\n </VueDraggable>\n\n <!-- Keyboard reorder announcement region (visually hidden, live) -->\n <div\n class=\"tpl-sr-only\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n {{ announcement }}\n </div>\n\n <!-- Error message -->\n <p\n v-if=\"error\"\n role=\"alert\"\n class=\"tpl:mb-3 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-danger)]\"\n >\n {{ error }}\n </p>\n\n <!-- Actions -->\n <div class=\"tpl:flex tpl:shrink-0 tpl:justify-end tpl:gap-2\">\n <button\n type=\"button\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n :disabled=\"isSaving\"\n :class=\"{\n 'tpl:cursor-not-allowed tpl:opacity-50': isSaving,\n }\"\n @click=\"handleClose\"\n >\n {{ t.savedBlocks.cancel }}\n </button>\n <button\n type=\"button\"\n :class=\"primaryBtnClass\"\n :disabled=\"!canSave\"\n @click=\"handleSave\"\n >\n <span v-if=\"isSaving\" class=\"tpl:flex tpl:items-center tpl:gap-1.5\">\n <LoaderCircle\n class=\"tpl:animate-spin\"\n :size=\"12\"\n :stroke-width=\"2\"\n />\n {{ t.savedBlocks.saving }}\n </span>\n <span v-else>\n {{ t.savedBlocks.save }}\n </span>\n </button>\n </div>\n </div>\n </TplModal>\n</template>\n","<script setup lang=\"ts\">\nimport { primaryBtnClass } from \"../constants/styleConstants\";\nimport SavedBlockPreviewRow from \"./SavedBlockPreviewRow.vue\";\nimport TplModal from \"./TplModal.vue\";\nimport { useI18n } from \"../composables\";\nimport {\n CUSTOM_BLOCK_DEFINITIONS_KEY,\n EDITOR_KEY,\n SAVED_BLOCKS_KEY,\n requireInject,\n} from \"../keys\";\nimport { getBlockLabel } from \"../utils/blockTypeLabels\";\nimport { LoaderCircle } from \"@lucide/vue\";\nimport { VueDraggable } from \"vue-draggable-plus\";\nimport type { Block } from \"@templatical/types\";\nimport { computed, inject, ref, watch } from \"vue\";\n\nconst props = defineProps<{\n visible: boolean;\n /**\n * Blocks chosen during the canvas pick session, in the order they were\n * picked — `pickedIds` is built from a `Set`, which iterates in insertion\n * order. That order seeds the reorderable list below. Every id must be a\n * top-level block, which is what the session guarantees.\n */\n pickedIds: readonly string[];\n}>();\n\nconst emit = defineEmits<{\n (e: \"close\"): void;\n (e: \"saved\"): void;\n}>();\n\nconst { t, format } = useI18n();\nconst editor = requireInject(EDITOR_KEY, \"SaveBlockDialog\");\nconst savedBlocks = requireInject(SAVED_BLOCKS_KEY, \"SaveBlockDialog\");\n// Optional: a consumer with no custom blocks never provides definitions, and\n// the label helper degrades to the type slug rather than throwing.\nconst customBlockDefinitions = inject(CUSTOM_BLOCK_DEFINITIONS_KEY, []);\n\nconst name = ref(\"\");\nconst category = ref(\"\");\nconst isSaving = ref(false);\nconst error = ref<string | null>(null);\nconst announcement = ref(\"\");\n\n/**\n * The order the blocks will be saved in: seeded from the pick order, then owned\n * by this dialog once the user drags a row.\n *\n * Ids rather than blocks, deliberately. Resolving late means a block edited\n * while the dialog is open (a collaborator in Cloud) is persisted as it stands\n * at save time, not as it looked when the dialog opened, and an id that stops\n * resolving drops out on its own instead of leaving a stale snapshot behind.\n */\nconst orderedIds = ref<string[]>([]);\n\n/**\n * The picked blocks in the list's current order. Ids that no longer resolve (a\n * block deleted mid-session) simply drop out.\n */\nconst pickedBlocks = computed(() => {\n const byId = new Map(editor.content.value.blocks.map((b) => [b.id, b]));\n return orderedIds.value\n .map((id) => byId.get(id))\n .filter((b): b is Block => b !== undefined);\n});\n\n/**\n * Sortable's model. The setter reads ids straight back off the emitted entries\n * rather than storing them, so a Sortable `el` expando can never reach the\n * payload `create()` sends to the provider — and because the getter is already\n * filtered, writing through it prunes unresolvable ids too.\n */\nconst orderedBlocks = computed<Block[]>({\n get: () => pickedBlocks.value,\n set: (blocks) => {\n orderedIds.value = blocks.map((b) => b.id);\n },\n});\n\n/** Keyboard reorder from a focused grip: swap with the neighbouring row. */\nfunction moveBlock(blockId: string, delta: -1 | 1): void {\n const order = pickedBlocks.value.map((b) => b.id);\n const from = order.indexOf(blockId);\n const to = from + delta;\n if (from === -1 || to < 0 || to >= order.length) return;\n [order[from], order[to]] = [order[to], order[from]];\n orderedIds.value = order;\n\n const moved = pickedBlocks.value[to];\n announcement.value = moved\n ? format(t.savedBlocks.reorderAnnouncement, {\n block: label(moved),\n position: to + 1,\n total: order.length,\n })\n : \"\";\n}\n\n/** Custom blocks read as the consumer's name for them, not the type `custom`. */\nfunction label(block: Block): string {\n return getBlockLabel(block, t, customBlockDefinitions);\n}\n\n/** e.g. \"Title, Paragraph, Button\" — what the user is about to save. */\nconst summary = computed(() => pickedBlocks.value.map(label).join(\", \"));\n\n// `immediate` matters: the dialog is mounted lazily behind a `v-if` on the\n// same state that drives `visible`, so it can mount with `visible` already\n// true and would otherwise never reset the name field or seed the order.\nwatch(\n () => props.visible,\n (visible) => {\n if (visible) {\n name.value = \"\";\n category.value = \"\";\n error.value = null;\n announcement.value = \"\";\n orderedIds.value = [...props.pickedIds];\n }\n },\n { immediate: true },\n);\n\nconst canSave = computed(\n () =>\n name.value.trim().length > 0 &&\n pickedBlocks.value.length > 0 &&\n !isSaving.value,\n);\n\nasync function handleSave(): Promise<void> {\n if (!canSave.value) return;\n\n const blocks = pickedBlocks.value;\n // Defence in depth: never persist an empty saved block (it would list as\n // \"0 block(s)\" and insert nothing). Guards a pick set whose ids no longer\n // resolve to anything on the canvas.\n if (blocks.length === 0) return;\n\n isSaving.value = true;\n error.value = null;\n\n try {\n // Empty stays undefined rather than \"\": an entry is either categorised or\n // it isn't, and a blank string would show up as a nameless filter option.\n await savedBlocks.create(\n name.value.trim(),\n blocks,\n category.value.trim() || undefined,\n );\n emit(\"saved\");\n emit(\"close\");\n } catch (err) {\n error.value = (err as Error).message;\n } finally {\n isSaving.value = false;\n }\n}\n\nfunction handleClose(): void {\n if (!isSaving.value) {\n emit(\"close\");\n }\n}\n\nfunction handleKeydown(event: KeyboardEvent): void {\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n handleSave();\n }\n if (event.key === \"Escape\") {\n handleClose();\n }\n}\n</script>\n\n<template>\n <TplModal :visible=\"visible\" @close=\"handleClose\" @keydown=\"handleKeydown\">\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-busy=\"isSaving\"\n aria-labelledby=\"tpl-save-block-title\"\n class=\"tpl-scale-in tpl:flex tpl:max-h-[90%] tpl:w-full tpl:max-w-2xl tpl:flex-col tpl:rounded-[var(--tpl-radius-lg)] tpl:p-5\"\n style=\"\n background-color: var(--tpl-bg-elevated);\n box-shadow: var(--tpl-shadow-xl);\n \"\n >\n <h3\n id=\"tpl-save-block-title\"\n class=\"tpl:mb-4 tpl:shrink-0 tpl:text-sm tpl:font-semibold tpl:text-[var(--tpl-text)]\"\n >\n {{ t.savedBlocks.saveAsBlock }}\n </h3>\n\n <!-- Saved block name -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.name }}\n </label>\n <input\n v-model=\"name\"\n type=\"text\"\n data-testid=\"saved-blocks-name-input\"\n :placeholder=\"t.savedBlocks.namePlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n </div>\n\n <!-- Category: free text, with the already-used categories offered as\n suggestions. A datalist rather than a <select> because the set is\n derived from existing entries — there is no registry to pick from,\n and the first entry in a category has to be able to name it. -->\n <div class=\"tpl:mb-3 tpl:shrink-0\">\n <label\n for=\"tpl-save-block-category\"\n class=\"tpl:mb-1.5 tpl:block tpl:text-sm tpl:font-medium tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ t.savedBlocks.category }}\n </label>\n <input\n id=\"tpl-save-block-category\"\n v-model=\"category\"\n type=\"text\"\n data-testid=\"saved-blocks-category-input\"\n list=\"tpl-saved-block-categories\"\n :placeholder=\"t.savedBlocks.categoryPlaceholder\"\n class=\"tpl:h-9 tpl:w-full tpl:rounded-md tpl:border tpl:px-3 tpl:py-1 tpl:text-sm tpl:shadow-[var(--tpl-shadow-sm)] tpl:outline-none tpl:border-[var(--tpl-border)] tpl:bg-[var(--tpl-bg)] tpl:text-[var(--tpl-text)]\"\n :disabled=\"isSaving\"\n />\n <datalist id=\"tpl-saved-block-categories\">\n <option\n v-for=\"option in savedBlocks.categories.value\"\n :key=\"option\"\n :value=\"option\"\n />\n </datalist>\n </div>\n\n <!-- Summary of what the pick session chose. Which blocks were picked is\n settled on the canvas — the dialog re-presents them only so the user\n can see and order what they're about to save. -->\n <p\n data-testid=\"saved-blocks-save-summary\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-text-muted)]\"\n >\n {{ format(t.savedBlocks.savingCount, { count: pickedBlocks.length }) }}\n <span class=\"tpl:text-[var(--tpl-text-dim)]\">{{ summary }}</span>\n </p>\n\n <!-- Reorderable preview. Seeded in pick order and saved in whatever order\n the list ends up in. Its own Sortable instance with no `group`, so it\n can never exchange items with the canvas or a section column. -->\n <p\n v-if=\"pickedBlocks.length > 1\"\n class=\"tpl:mb-1.5 tpl:shrink-0 tpl:text-[11px] tpl:text-[var(--tpl-text-dim)]\"\n >\n {{ t.savedBlocks.reorderHint }}\n </p>\n <!-- The only growing region: takes whatever the dialog's backdrop cap\n leaves after the fixed chrome, rather than a hand-tuned vh guess that\n a row expanding to full height would blow past. `min-h-0` is what\n lets a flex child actually shrink below its content and scroll. -->\n <VueDraggable\n v-model=\"orderedBlocks\"\n data-testid=\"saved-blocks-reorder-list\"\n :animation=\"150\"\n ghost-class=\"tpl-ghost\"\n drag-class=\"tpl-dragging\"\n handle=\".tpl-saved-block-reorder-handle\"\n :force-fallback=\"true\"\n class=\"tpl:mb-4 tpl:flex tpl:min-h-0 tpl:flex-1 tpl:flex-col tpl:gap-1.5 tpl:overflow-y-auto\"\n >\n <SavedBlockPreviewRow\n v-for=\"(block, index) in orderedBlocks\"\n :key=\"block.id\"\n :block=\"block\"\n :position=\"index + 1\"\n :total=\"orderedBlocks.length\"\n @move=\"(delta) => moveBlock(block.id, delta)\"\n />\n </VueDraggable>\n\n <!-- Keyboard reorder announcement region (visually hidden, live) -->\n <div\n class=\"tpl-sr-only\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n {{ announcement }}\n </div>\n\n <!-- Error message -->\n <p\n v-if=\"error\"\n role=\"alert\"\n class=\"tpl:mb-3 tpl:shrink-0 tpl:text-xs tpl:text-[var(--tpl-danger)]\"\n >\n {{ error }}\n </p>\n\n <!-- Actions -->\n <div class=\"tpl:flex tpl:shrink-0 tpl:justify-end tpl:gap-2\">\n <button\n type=\"button\"\n class=\"tpl:cursor-pointer tpl:rounded-md tpl:border tpl:px-3 tpl:py-1.5 tpl:text-sm tpl:font-medium tpl:shadow-[var(--tpl-shadow-sm)] tpl:transition-all tpl:border-[var(--tpl-border)] tpl:text-[var(--tpl-text)] tpl:bg-[var(--tpl-bg)]\"\n :disabled=\"isSaving\"\n :class=\"{\n 'tpl:cursor-not-allowed tpl:opacity-50': isSaving,\n }\"\n @click=\"handleClose\"\n >\n {{ t.savedBlocks.cancel }}\n </button>\n <button\n type=\"button\"\n :class=\"primaryBtnClass\"\n :disabled=\"!canSave\"\n @click=\"handleSave\"\n >\n <span v-if=\"isSaving\" class=\"tpl:flex tpl:items-center tpl:gap-1.5\">\n <LoaderCircle\n class=\"tpl:animate-spin\"\n :size=\"12\"\n :stroke-width=\"2\"\n />\n {{ t.savedBlocks.saving }}\n </span>\n <span v-else>\n {{ t.savedBlocks.save }}\n </span>\n </button>\n </div>\n </div>\n </TplModal>\n</template>\n"],"mappings":"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"}
@@ -62,7 +62,7 @@ var me = {
62
62
  props: { visible: { type: Boolean } },
63
63
  emits: ["close", "insert"],
64
64
  setup(a, { emit: A }) {
65
- let tt = a, j = A, nt = re(() => import("./BlockPreviewCanvas-9tFXPVgi.js").then((e) => e.n)), { t: M, format: N } = oe(), P = x(ie, "SavedBlocksBrowserModal"), F = x(ae, "SavedBlocksBrowserModal"), I = i(""), L = i(""), R = i(null), z = i(null), B = i(null), V = i(""), H = i(""), U = i(null), W = i("end"), G = f(() => {
65
+ let tt = a, j = A, nt = re(() => import("./BlockPreviewCanvas-BXVC8-Zj.js").then((e) => e.n)), { t: M, format: N } = oe(), P = x(ie, "SavedBlocksBrowserModal"), F = x(ae, "SavedBlocksBrowserModal"), I = i(""), L = i(""), R = i(null), z = i(null), B = i(null), V = i(""), H = i(""), U = i(null), W = i("end"), G = f(() => {
66
66
  let e = I.value.trim().toLowerCase(), t = L.value;
67
67
  return P.savedBlocks.value.filter((n) => !(e && !n.name.toLowerCase().includes(e) || t && n.category !== t));
68
68
  }), K = f(() => P.isLoading.value && P.savedBlocks.value.length === 0), rt = f(() => I.value.trim().length > 0 || L.value !== ""), q = f(() => R.value ? P.savedBlocks.value.find((e) => e.id === R.value) ?? null : null), it = f(() => {
@@ -319,4 +319,4 @@ var me = {
319
319
  //#endregion
320
320
  export { A as default };
321
321
 
322
- //# sourceMappingURL=SavedBlocksBrowserModal-C3nfKRQg.js.map
322
+ //# sourceMappingURL=SavedBlocksBrowserModal-BUJjcHkw.js.map