proposal-studio 0.2.1 → 0.2.3

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.
@@ -179,7 +179,7 @@
179
179
  <body ngcm="">
180
180
  <app-root></app-root>
181
181
  <script data-injected="proposal-studio-canvas">
182
- window.__PS_CANVAS_SRCDOC__ = "<!doctype html>\n<html lang=\"en\">\n\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Custom Form</title>\n <style data-src=\"./css/custom-form.css\">\n:root {\n color-scheme: light;\n --page-bg: #f4f5fb;\n --ink: #20233d;\n --muted: #72769a;\n --line: #e2e5f4;\n --line-strong: #cfd4f6;\n --accent: #5c5cff;\n --accent-soft: #eef0ff;\n --accent-ghost: rgba(92, 92, 255, 0.08);\n --shadow: 0 18px 40px rgba(34, 36, 61, 0.08);\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin-top: 15px;\n padding: 0;\n background: var(--page-bg);\n font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n color: var(--ink);\n font-size: 14px;\n}\n\nbody {\n min-height: 100%;\n /* The iframe is sized to the page in canvas.scss. The horizontal\n axis must NEVER scroll inside the iframe (it produces an ugly\n extra scrollbar under the canvas). Vertical scroll IS allowed so\n long forms can be edited; the outer .canvas-stage handles the\n bigger picture. */\n overflow-x: hidden;\n}\n\n#place_everything {\n min-height: 100%;\n height: auto;\n}\n\n.page_container {\n min-height: 100%;\n height: auto;\n}\n\n.cs_paper {\n min-height: 100%;\n height: auto;\n /* background: #f4f5fb; */\n padding: 24px 0;\n}\n\n.custom-form-design {\n position: relative;\n height: 100%;\n overflow: hidden;\n /* border-radius: 8px; */\n background: #ffffff;\n}\n\n/* .custom-form-design::before {\n content: 'Drag a block here';\n position: absolute;\n inset: 16px;\n display: grid;\n place-items: center;\n text-align: center;\n color: var(--muted);\n font-size: 14px;\n line-height: 1.4;\n pointer-events: none;\n opacity: 0.7;\n transition: opacity 160ms ease, transform 160ms ease;\n} */\n\n.drop-surface--active {\n background:\n linear-gradient(180deg, rgba(92, 92, 255, 0.04), rgba(92, 92, 255, 0.02)),\n #ffffff;\n}\n\n.custom-form-design.drop-surface--active::before {\n color: var(--accent);\n opacity: 1;\n transform: scale(1.03);\n}\n\n.page-grid {\n position: absolute;\n inset: 0;\n background-image:\n linear-gradient(rgba(92, 92, 255, 0.05) 1px, transparent 1px),\n linear-gradient(90deg, rgba(92, 92, 255, 0.05) 1px, transparent 1px);\n background-size: 24px 24px;\n opacity: 0.28;\n pointer-events: none;\n}\n\n.page-empty-state {\n position: absolute;\n inset: 50% auto auto 50%;\n width: min(100% - 96px, 420px);\n transform: translate(-50%, -50%);\n display: grid;\n gap: 12px;\n justify-items: center;\n text-align: center;\n padding: 28px;\n border: 1px dashed var(--line-strong);\n border-radius: 24px;\n background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 246, 255, 0.94));\n box-shadow: var(--shadow);\n}\n\n.page-empty-state[hidden] {\n display: none;\n}\n\n.page-empty-state__badge {\n display: inline-flex;\n align-items: center;\n min-height: 28px;\n padding: 0 12px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.page-empty-state h1 {\n margin: 0;\n font-size: 14px;\n line-height: 1.15;\n letter-spacing: -0.04em;\n}\n\n.page-empty-state p {\n margin: 0;\n color: var(--muted);\n font-size: 14px;\n line-height: 1.65;\n}\n\n.section-binding-modal {\n position: fixed;\n inset: 0;\n display: grid;\n place-items: center;\n z-index: 1000;\n}\n\n.section-binding-modal[hidden] {\n display: none !important;\n}\n\n.section-binding-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(20, 24, 48, 0.62);\n backdrop-filter: blur(8px);\n}\n\n.section-binding-card {\n position: relative;\n width: min(820px, calc(100% - 32px));\n max-height: min(90vh, 680px);\n overflow: hidden;\n display: grid;\n gap: 24px;\n padding: 28px;\n border-radius: 28px;\n background: #192238;\n color: #f0f2f9;\n box-shadow: 0 28px 72px rgba(14, 20, 48, 0.38), 0 0 1px rgba(255, 255, 255, 0.12);\n z-index: 1;\n}\n\n.section-binding-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n}\n\n.section-binding-title {\n margin: 0 0 8px;\n font-size: 14px;\n font-weight: 700;\n color: #fff;\n letter-spacing: -0.02em;\n}\n\n.section-binding-subtitle {\n margin: 0;\n color: rgba(255, 255, 255, 0.68);\n font-size: 14px;\n line-height: 1.6;\n}\n\n.section-binding-close {\n border: 1px solid rgba(255, 255, 255, 0.12);\n background: rgba(255, 255, 255, 0.06);\n color: #fff;\n width: 40px;\n height: 40px;\n border-radius: 999px;\n font-size: 14px;\n cursor: pointer;\n flex-shrink: 0;\n transition: background-color 160ms, border-color 160ms;\n}\n\n.section-binding-close:hover {\n background: rgba(255, 255, 255, 0.11);\n border-color: rgba(255, 255, 255, 0.18);\n}\n\n.section-binding-grid {\n display: grid;\n grid-template-columns: 1fr 1.2fr;\n gap: 20px;\n min-height: 0;\n}\n\n.section-binding-list-card,\n.section-binding-config-card {\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid rgba(255, 255, 255, 0.09);\n border-radius: 20px;\n padding: 18px;\n overflow: hidden;\n display: grid;\n gap: 14px;\n}\n\n.section-binding-list-title {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 2px;\n font-size: 14px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: #8aa4e6;\n font-weight: 700;\n}\n\n.section-binding-badge {\n display: inline-flex;\n min-width: 48px;\n justify-content: center;\n background: rgba(138, 164, 230, 0.18);\n color: #a0b2ff;\n font-size: 14px;\n padding: 5px 12px;\n border-radius: 999px;\n font-weight: 600;\n}\n\n.section-binding-list {\n display: grid;\n gap: 10px;\n max-height: 360px;\n overflow-y: auto;\n overflow-x: hidden;\n padding-right: 6px;\n}\n\n.section-binding-list::-webkit-scrollbar {\n width: 6px;\n}\n\n.section-binding-list::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.section-binding-list::-webkit-scrollbar-thumb {\n background: rgba(255, 255, 255, 0.12);\n border-radius: 3px;\n}\n\n.section-binding-list::-webkit-scrollbar-thumb:hover {\n background: rgba(255, 255, 255, 0.18);\n}\n\n.section-binding-array-item {\n width: 100%;\n text-align: left;\n border: 1px solid rgba(255, 255, 255, 0.09);\n border-radius: 14px;\n padding: 14px;\n background: rgba(255, 255, 255, 0.02);\n color: #f0f2f9;\n cursor: pointer;\n transition: all 180ms ease;\n}\n\n.section-binding-array-item:hover {\n border-color: rgba(92, 92, 255, 0.35);\n background: rgba(92, 92, 255, 0.08);\n}\n\n.section-binding-array-item--selected {\n border-color: #5c5cff;\n background: rgba(92, 92, 255, 0.15);\n box-shadow: inset 0 0 0 1px rgba(92, 92, 255, 0.25);\n}\n\n.section-binding-array-item__row {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 8px;\n align-items: center;\n}\n\n.section-binding-array-item__path {\n font-size: 14px;\n font-weight: 600;\n color: #fff;\n word-break: break-word;\n}\n\n.section-binding-array-item__count {\n font-size: 14px;\n color: rgba(255, 255, 255, 0.58);\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.section-binding-array-item__preview {\n color: rgba(255, 255, 255, 0.54);\n font-size: 14px;\n line-height: 1.45;\n word-break: break-word;\n}\n\n.section-binding-empty {\n grid-column: 1 / -1;\n padding: 32px 16px;\n text-align: center;\n color: rgba(255, 255, 255, 0.52);\n font-size: 14px;\n line-height: 1.6;\n}\n\n.section-binding-field-label {\n display: block;\n margin-bottom: 8px;\n color: rgba(255, 255, 255, 0.62);\n font-size: 14px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n font-weight: 700;\n}\n\n.section-binding-field {\n min-height: 44px;\n display: flex;\n align-items: center;\n padding: 12px 14px;\n border-radius: 14px;\n background: rgba(255, 255, 255, 0.04);\n color: #f0f2f9;\n font-size: 14px;\n border: 1px solid rgba(255, 255, 255, 0.08);\n}\n\n.section-binding-field--readonly {\n pointer-events: none;\n}\n\n.section-binding-input {\n width: 100%;\n padding: 12px 14px;\n border-radius: 14px;\n border: 1px solid rgba(255, 255, 255, 0.12);\n background: rgba(255, 255, 255, 0.05);\n color: #f0f2f9;\n font-size: 14px;\n font-family: inherit;\n transition: border-color 160ms, background-color 160ms;\n}\n\n.section-binding-input:focus {\n outline: none;\n border-color: rgba(92, 92, 255, 0.40);\n background: rgba(92, 92, 255, 0.08);\n}\n\n.section-binding-generated-code {\n margin-top: 12px;\n}\n\n.section-binding-code-title {\n margin-bottom: 10px;\n font-size: 14px;\n color: rgba(255, 255, 255, 0.62);\n text-transform: uppercase;\n letter-spacing: 0.12em;\n font-weight: 700;\n}\n\n.section-binding-code {\n margin: 0;\n padding: 14px;\n border-radius: 14px;\n background: rgba(0, 0, 0, 0.28);\n border: 1px solid rgba(92, 92, 255, 0.15);\n color: #a0b2ff;\n font-size: 14px;\n line-height: 1.6;\n white-space: pre-wrap;\n word-break: break-word;\n font-family: 'Monaco', 'Courier New', monospace;\n}\n\n.section-binding-footer {\n display: flex;\n justify-content: flex-end;\n gap: 12px;\n margin-top: 4px;\n}\n\n.section-binding-skip,\n.section-binding-apply {\n border: none;\n min-height: 44px;\n border-radius: 999px;\n padding: 0 26px;\n font-size: 14px;\n font-weight: 600;\n cursor: pointer;\n transition: all 160ms ease;\n}\n\n.section-binding-skip {\n background: transparent;\n color: rgba(255, 255, 255, 0.78);\n border: 1px solid rgba(255, 255, 255, 0.12);\n}\n\n.section-binding-skip:hover {\n background: rgba(255, 255, 255, 0.06);\n border-color: rgba(255, 255, 255, 0.2);\n}\n\n.section-binding-apply {\n background: #5c5cff;\n color: #fff;\n box-shadow: 0 8px 20px rgba(92, 92, 255, 0.24);\n}\n\n.section-binding-apply:hover:not(:disabled) {\n background: #6b6bff;\n box-shadow: 0 12px 28px rgba(92, 92, 255, 0.32);\n}\n\n.section-binding-apply:disabled {\n opacity: 0.42;\n cursor: not-allowed;\n}\n\n.canvas-block {\n position: absolute;\n /* min-width: 120px; */\n max-width: calc(100% - 32px);\n user-select: none;\n cursor: grab;\n}\n\n.canvas-block:active {\n cursor: grabbing;\n}\n\n.canvas-block--selected .canvas-block__inner {\n box-shadow:\n 0 0 0 2px var(--accent),\n 0 18px 40px rgba(34, 36, 61, 0.12);\n}\n\n.canvas-block__inner {\n position: relative;\n padding: 14px;\n border: 1px solid var(--line);\n border-radius: 22px;\n background: #ffffff;\n box-shadow: var(--shadow);\n}\n\n.canvas-block__tag {\n display: inline-flex;\n align-items: center;\n min-height: 24px;\n padding: 0 10px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n}\n\n.canvas-block__remove {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 28px;\n height: 28px;\n border: 0;\n border-radius: 999px;\n background: #f5f6ff;\n color: #5e6288;\n font: inherit;\n font-size: 14px;\n cursor: pointer;\n pointer-events: auto;\n}\n\n.canvas-block__remove:hover {\n background: #eceeff;\n}\n\n.canvas-block__content,\n.canvas-block__content * {\n pointer-events: none;\n}\n\n.block-card {\n display: grid;\n gap: 14px;\n}\n\n.block-card h2,\n.block-card h3,\n.block-heading {\n margin: 0;\n letter-spacing: -0.03em;\n}\n\n.block-card p,\n.block-paragraph,\n.block-caption,\n.block-list li,\n.block-table__row span,\n.block-input {\n color: var(--muted);\n}\n\n.block-card--hero {\n padding-top: 10px;\n}\n\n.block-card--hero h2 {\n font-size: 14px;\n line-height: 1.08;\n}\n\n.block-card--hero p {\n margin: 0;\n max-width: 520px;\n font-size: 14px;\n line-height: 1.7;\n}\n\n.block-actions {\n display: flex;\n gap: 10px;\n}\n\n.block-pill {\n display: inline-flex;\n align-items: center;\n min-height: 38px;\n padding: 0 16px;\n border-radius: 12px;\n background: #ffffff;\n border: 1px solid var(--line);\n color: var(--ink);\n font-size: 14px;\n font-weight: 600;\n}\n\n.block-pill--primary,\n.block-button {\n background: var(--accent);\n border-color: var(--accent);\n color: #ffffff;\n}\n\n.block-columns {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 16px;\n}\n\n.block-column {\n padding: 18px;\n border: 1px solid var(--line);\n border-radius: 18px;\n background: linear-gradient(180deg, #ffffff, #fafbff);\n}\n\n.block-column strong {\n display: block;\n margin-bottom: 8px;\n font-size: 14px;\n}\n\n.block-media {\n display: grid;\n grid-template-columns: 220px minmax(0, 1fr);\n gap: 18px;\n align-items: center;\n}\n\n.block-image {\n height: 180px;\n border: 1px dashed var(--line-strong);\n border-radius: 20px;\n background:\n linear-gradient(135deg, rgba(92, 92, 255, 0.1), rgba(92, 92, 255, 0.02)),\n #f7f8ff;\n}\n\n.block-pricing {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 14px;\n}\n\n.block-price-card {\n padding: 18px;\n border: 1px solid var(--line);\n border-radius: 18px;\n background: #ffffff;\n}\n\n.block-price-card strong {\n display: block;\n margin: 6px 0;\n font-size: 14px;\n line-height: 1;\n}\n\n.block-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 18px;\n}\n\n.block-footer__links {\n display: flex;\n gap: 16px;\n color: var(--muted);\n font-size: 14px;\n}\n\n.block-heading {\n font-size: 14px;\n line-height: 1.15;\n}\n\n.block-paragraph {\n margin: 0;\n font-size: 14px;\n line-height: 1.7;\n}\n\n.block-label {\n display: inline-flex;\n align-items: center;\n min-height: 30px;\n padding: 0 12px;\n border-radius: 999px;\n background: var(--accent-ghost);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n}\n\n.block-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 140px;\n min-height: 44px;\n padding: 0 18px;\n border-radius: 14px;\n font-size: 14px;\n font-weight: 600;\n}\n\n.block-divider {\n height: 1px;\n background: linear-gradient(90deg, transparent, var(--line-strong), transparent);\n}\n\n.block-spacer {\n height: 72px;\n border: 1px dashed var(--line-strong);\n border-radius: 16px;\n background: linear-gradient(180deg, rgba(92, 92, 255, 0.05), rgba(92, 92, 255, 0.01));\n}\n\n.block-container {\n min-height: 180px;\n padding: 20px;\n border: 1px dashed var(--line-strong);\n border-radius: 22px;\n background: #fbfbff;\n}\n\n.block-container strong {\n display: block;\n margin-bottom: 8px;\n}\n\n.block-table {\n display: grid;\n gap: 10px;\n overflow: hidden;\n}\n\n.cs_block_s[data=\"Table\"] {\n overflow: hidden;\n}\n\n.block-table__row {\n display: grid;\n grid-template-columns: 1.2fr 0.8fr 0.8fr;\n gap: 10px;\n}\n\n.block-table__row span,\n.block-list li,\n.block-input {\n min-height: 42px;\n display: flex;\n align-items: center;\n padding: 0 14px;\n border: 1px solid var(--line);\n border-radius: 14px;\n background: #ffffff;\n font-size: 14px;\n}\n\n.block-list {\n margin: 0;\n padding: 0;\n list-style: none;\n display: grid;\n gap: 10px;\n}\n\n.block-shape {\n border-radius: 20px;\n background: linear-gradient(135deg, rgba(92, 92, 255, 0.16), rgba(92, 92, 255, 0.05));\n border: 1px solid rgba(92, 92, 255, 0.26);\n}\n\n.block-shape--rectangle {\n width: 100%;\n height: 160px;\n}\n\n.block-shape--circle {\n width: 180px;\n height: 180px;\n border-radius: 999px;\n}\n\n.block-icon-badge {\n width: 88px;\n height: 88px;\n display: grid;\n place-items: center;\n border-radius: 24px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n}\n\n\n.cs_block_s {\n overflow-wrap: break-word;\n /* padding: 5px; */\n margin: 0;\n height: max-content;\n width: 250px;\n max-width: 100%;\n position: relative;\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n border: 1px solid #e1e1e1;\n}\n\n\n\n.edit_me:empty:before {\n content: attr(placeholder);\n /*if affected some place please unhide this below command*/\n color: #AAA;\n font-family: sans-serif;\n line-height: 1.1;\n}\n\n.section-binding-info {\n display: none;\n}\n\n/* ============================================================\n FLOW CANVAS — row / column layout (Word-style)\n Variables are populated by canvas-config.js — tune that file\n to change page dimensions, paddings, colors, etc.\n ============================================================ */\n\n.custom-form-design.cs-flow-canvas {\n padding: 0;\n overflow: visible;\n background: transparent;\n height: auto;\n flex: 0 0 auto;\n}\n\n.cs_paper {\n /* Multi-page container — holds one or more .cs_margin pages stacked\n vertically. Each page is an A4 sheet (794 × 1123 px). */\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 28px;\n width: 100%;\n}\n\n.cs_margin {\n width: var(--cs-page-width, 794px);\n max-width: 100%;\n min-height: var(--cs-page-min-height, 1123px);\n margin: 0 auto;\n /* padding: var(--cs-page-padding, 16px); */\n background: var(--cs-page-bg, #ffffff);\n background-image: var(--cs-page-bg-image, none);\n background-size: cover;\n background-position: center;\n background-repeat: no-repeat;\n position: relative;\n box-sizing: border-box;\n border: 1px solid #e1e1e1;\n display: flex;\n flex-direction: column;\n flex: 0 0 auto;\n}\n\n/* Page background shape layer (.cs-page-shape-bg, injected by the Page Shape\n designer): z-index:0 keeps it above the page background; page content is\n lifted to z-index:1 so it always paints on top. This pure-z-index approach\n (no negative z / isolation) renders consistently in wkhtmltopdf + puppeteer. */\n.cs_margin>.cs-page-shape-bg,\n.cs-cover-canvas>.cs-page-shape-bg {\n z-index: 0;\n}\n\n/* Smart alignment guides shown while dragging / resizing a free-move block\n (cover or section). Editor-only chrome (data-cs-chrome → stripped on export). */\n.cs-align-guides {\n position: absolute;\n inset: 0;\n pointer-events: none;\n z-index: 60;\n}\n\n.cs-align-guide {\n position: absolute;\n background: #f43f7e;\n}\n\n.cs-align-guide--v {\n top: 0;\n bottom: 0;\n width: 1px;\n}\n\n.cs-align-guide--h {\n left: 0;\n right: 0;\n height: 1px;\n}\n\n/* ============================================================\n Distance measurement overlay (measure-distance.js)\n Figma-style gap measurement: select a free block, hold Ctrl/⌘,\n then hover another block. Editor-only chrome — transient, never\n exported. All accents use the brand teal #248567.\n ============================================================ */\n.cs-measure {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n animation: cs-measure-fade .14s ease-out both;\n}\n\n.cs-measure__svg {\n position: absolute;\n top: 0;\n left: 0;\n overflow: visible;\n pointer-events: none;\n}\n\n/* the two block outlines: source solid + glow/pulse, target dashed marching-ants */\n.cs-measure__box {\n fill: none;\n stroke: #248567;\n stroke-width: 1.5;\n animation: cs-measure-box .18s ease-out both;\n}\n\n.cs-measure__box--src {\n filter: drop-shadow(0 0 3px rgba(36, 133, 103, .55));\n animation: cs-measure-box .18s ease-out both, cs-measure-pulse 1.8s ease-in-out infinite .18s;\n}\n\n.cs-measure__box--tgt {\n stroke-dasharray: 5 4;\n animation: cs-measure-box .18s ease-out both, cs-measure-march 700ms linear infinite;\n}\n\n/* measurement lines + end caps + dotted extensions */\n.cs-measure__line {\n stroke: #248567;\n stroke-width: 1.5;\n stroke-linecap: round;\n stroke-dasharray: 1;\n stroke-dashoffset: 1;\n animation: cs-measure-draw .22s ease-out forwards;\n}\n\n.cs-measure__cap {\n stroke: #248567;\n stroke-width: 1.5;\n stroke-linecap: round;\n opacity: 0;\n animation: cs-measure-in .22s ease-out .06s forwards;\n}\n\n.cs-measure__ext {\n stroke: #248567;\n stroke-width: 1;\n stroke-dasharray: 2 3;\n opacity: 0;\n animation: cs-measure-in .22s ease-out .04s forwards;\n}\n\n/* value badges */\n.cs-measure__label {\n position: absolute;\n transform: translate(-50%, -50%);\n background: #248567;\n color: #fff;\n font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n letter-spacing: .2px;\n padding: 1px 6px;\n border-radius: 5px;\n white-space: nowrap;\n box-shadow: 0 2px 6px rgba(36, 133, 103, .45), 0 0 0 1px rgba(255, 255, 255, .18) inset;\n animation: cs-measure-pop .24s cubic-bezier(.34, 1.56, .64, 1) both;\n}\n\n/* hint chip while armed but not yet over a target */\n.cs-measure__hint {\n position: absolute;\n transform: translate(14px, 16px);\n background: #248567;\n color: #fff;\n font: 500 11px/1.4 system-ui, sans-serif;\n padding: 4px 9px;\n border-radius: 7px;\n white-space: nowrap;\n box-shadow: 0 4px 14px rgba(36, 133, 103, .4);\n animation: cs-measure-fade .14s ease-out both;\n}\n\n.cs-measure__hint b {\n font-weight: 700;\n}\n\n@keyframes cs-measure-fade {\n from {\n opacity: 0\n }\n\n to {\n opacity: 1\n }\n}\n\n@keyframes cs-measure-in {\n to {\n opacity: 1\n }\n}\n\n@keyframes cs-measure-draw {\n to {\n stroke-dashoffset: 0\n }\n}\n\n@keyframes cs-measure-box {\n from {\n opacity: 0\n }\n\n to {\n opacity: 1\n }\n}\n\n@keyframes cs-measure-pop {\n from {\n opacity: 0;\n transform: translate(-50%, -50%) scale(.55)\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, -50%) scale(1)\n }\n}\n\n@keyframes cs-measure-march {\n to {\n stroke-dashoffset: -18\n }\n}\n\n@keyframes cs-measure-pulse {\n\n 0%,\n 100% {\n filter: drop-shadow(0 0 2px rgba(36, 133, 103, .35))\n }\n\n 50% {\n filter: drop-shadow(0 0 6px rgba(36, 133, 103, .7))\n }\n}\n\n/* Active-page highlight (set by active-page.js on the in-view .cs_page).\n Uses box-shadow so it never shifts layout. cs_selected = cover page,\n cs_selected_border = content page. Stripped from exported markup. */\n.cs_page.cs_selected,\n.cs_page.cs_selected_border {\n box-shadow: 0 0 0 0px #248567, 0 6px 18px rgba(36, 133, 103, 0.18);\n}\n\n.cs_margin>.row-item,\n.cs_margin>.body-main-content,\n.cs_margin>.cs-page-header,\n.cs_margin>.cs-page-footer {\n position: relative;\n z-index: 1;\n}\n\n/* A4 boundary indicator — a real DOM element injected by JS into any\n .cs_margin whose content overflows past the configured A4 height. The\n line spans the full doc width, with a centered pill label that has\n a solid background so it's readable on top of the dashed line. */\n.cs-overflow-mark {\n position: absolute;\n left: 0;\n right: 0;\n top: var(--cs-page-min-height, 1123px);\n height: 0;\n border-top: 1px dashed #f97316;\n pointer-events: none;\n z-index: 5;\n}\n\n.cs-overflow-mark__label {\n position: absolute;\n left: 50%;\n top: 0;\n transform: translate(-50%, -50%);\n padding: 3px 12px;\n font-size: 14px;\n font-weight: 600;\n color: #c2410c;\n background: #ffffff;\n border: 1px solid #f97316;\n border-radius: 999px;\n white-space: nowrap;\n}\n\n/* Page number badge (top-right corner of each page) */\n.cs_margin::before {\n /* content: \"Page \" attr(data-page); */\n position: absolute;\n top: 8px;\n right: 12px;\n font-size: 10px;\n font-weight: 600;\n color: rgba(99, 102, 241, 0.55);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n pointer-events: none;\n}\n\n.row-item {\n display: flex;\n gap: 0;\n /* margin-bottom: var(--row-item-margin-bottom, 8px); */\n position: relative;\n /* min-height: var(--row-item-min-height, 40px); */\n flex: 0 0 auto;\n /* padding: 5px; */\n}\n\n/* ============================================================\n WORD-STYLE PAGE HEADER / FOOTER\n Header is the first row (top of page), footer is the last row\n (bottom of page). `margin-top: auto` on the footer pushes it to\n the bottom of the flex column — empty space sits between the\n body and the footer.\n ============================================================ */\n.cs_margin>.cs-page-header,\n.cs_margin>.cs-page-footer {\n flex: 0 0 auto;\n position: relative;\n min-height: 70px;\n /* padding: 10px; */\n border: 1px dashed transparent;\n /* border-radius: 4px; */\n background: transparent;\n color: rgba(31, 31, 31, 0.42);\n transition: color 120ms ease, background 120ms ease, border-color 120ms ease;\n cursor: pointer;\n}\n\n.cs_margin>.cs-page-header {\n margin-top: 0;\n margin-bottom: 0px;\n border-bottom: 1px solid #e1e1e1;\n align-items: center;\n}\n\n.cs_margin>.cs-page-footer {\n margin-top: auto;\n align-items: center;\n /* push to bottom of the flex column */\n margin-bottom: 0;\n border-top: 1px solid #e1e1e1;\n}\n\n/* Placeholder hint shown when the region is empty (no nested blocks) */\n.cs-page-header>.col-item:empty::before,\n.cs-page-footer>.col-item:empty::before {\n content: attr(data-cs-placeholder);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n min-height: 50px;\n font-size: 14px;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: rgba(99, 102, 241, 0.55);\n pointer-events: none;\n}\n\n.cs-page-header>.col-item:empty,\n.cs-page-footer>.col-item:empty {\n position: relative;\n}\n\n/* Mirror the placeholder text from the row to its inner col so the\n :empty selector still has access to the attribute. */\n.cs-page-header>.col-item,\n.cs-page-footer>.col-item {\n background: transparent;\n}\n\n/* Hover hint: faint blue suggests \"click here\" */\n.cs_margin>.cs-page-header:not(.is-active):hover,\n.cs_margin>.cs-page-footer:not(.is-active):hover {\n background: rgba(99, 102, 241, 0.04);\n border-color: rgba(99, 102, 241, 0.3);\n}\n\n/* Active state — bright outline + corner label, drop-zone ready */\n.cs_margin>.cs-page-header.is-active,\n.cs_margin>.cs-page-footer.is-active {\n color: inherit;\n background: rgba(99, 102, 241, 0.06);\n border-color: rgba(99, 102, 241, 0.6);\n cursor: auto;\n}\n\n.cs_margin>.cs-page-header.is-active::before,\n.cs_margin>.cs-page-footer.is-active::before {\n content: attr(data-cs-region-label);\n position: absolute;\n top: -8px;\n left: 12px;\n font-size: 14px;\n font-weight: 700;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: #4338ca;\n background: #ffffff;\n padding: 0 6px;\n pointer-events: none;\n line-height: 1;\n z-index: 1;\n}\n\n/* When a region is active, dim the inactive areas */\n.cs_margin.editing-header>.row-item:not(.cs-page-header):not(.cs-page-footer),\n.cs_margin.editing-footer>.row-item:not(.cs-page-header):not(.cs-page-footer) {\n opacity: 0.5;\n}\n\n.cs_margin.editing-header>.cs-page-footer,\n.cs_margin.editing-footer>.cs-page-header {\n opacity: 0.5;\n}\n\n.col-item {\n flex: 1 1 0;\n min-width: var(--col-item-min-width, 60px);\n /* min-height: var(--col-item-min-height, 40px); */\n position: relative;\n display: flex;\n flex-direction: column;\n /* padding: var(--col-item-padding, 4px); */\n}\n\n/* Show placeholder only when column is truly empty (no block content) */\n.col-item:empty::before {\n content: 'Drop block here';\n display: block;\n color: var(--muted);\n font-size: 14px;\n text-align: center;\n padding: 12px;\n /* border: 1px dashed var(--line); */\n border-radius: 4px;\n pointer-events: none;\n}\n\n/* Hide placeholder if column has any block content */\n.col-item:has(> .cs_block_s)::before,\n.col-item:has(> .canvas-block)::before {\n content: '';\n display: none;\n}\n\n/* Column divider (resize handle between columns) — a 10px-wide draggable\n strip with a visible 1px line in the middle. The whole strip is the hit\n area so it's easy to grab. */\n.cs-line-divider {\n flex: 0 0 10px;\n background: transparent;\n cursor: col-resize;\n position: relative;\n align-self: stretch;\n transition: background 120ms ease;\n z-index: 5;\n}\n\n.cs-line-divider::before {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n width: 1px;\n background: transparent;\n transform: translateX(-50%);\n pointer-events: none;\n}\n\n.row-item:hover .cs-line-divider::before {\n background: rgba(92, 92, 255, 0.25);\n}\n\n.cs-line-divider:hover,\n.cs-line-divider.cs-line-divider--active {\n background: rgba(92, 92, 255, 0.12);\n}\n\n.cs-line-divider:hover::before,\n.cs-line-divider.cs-line-divider--active::before {\n background: var(--accent);\n width: 2px;\n}\n\n/* Premium Drop Indicators & Animations */\n@keyframes csDropPulse {\n 0% {\n box-shadow: 0 0 0 0 rgba(36, 133, 103, 0.6);\n opacity: 0.85;\n }\n\n 50% {\n box-shadow: 0 0 0 8px rgba(36, 133, 103, 0.1);\n opacity: 1;\n }\n\n 100% {\n box-shadow: 0 0 0 0 rgba(36, 133, 103, 0.6);\n opacity: 0.85;\n }\n}\n\n@keyframes csBgPan {\n to {\n background-position: 200% center;\n }\n}\n\n@keyframes scaleInDrop {\n 0% {\n transform: scale(0.92);\n opacity: 0;\n }\n\n 100% {\n transform: scale(1);\n opacity: 1;\n }\n}\n\n.cs-drop-indicator {\n position: absolute;\n background: #248567;\n background-size: 200% auto;\n border-radius: 4px;\n pointer-events: none;\n z-index: 9999;\n box-shadow: 0 0 8px rgba(92, 92, 255, 0.5);\n animation: csDropPulse 1.5s infinite ease-in-out, csBgPan 2s linear infinite;\n /* Smoothly animate movement as the user drags across slots */\n transition: top 0.15s cubic-bezier(0.2, 0, 0, 1), left 0.15s cubic-bezier(0.2, 0, 0, 1), width 0.15s cubic-bezier(0.2, 0, 0, 1), height 0.15s cubic-bezier(0.2, 0, 0, 1);\n}\n\n.cs-drop-indicator--horizontal {\n height: 2px !important;\n margin-top: -1px;\n}\n\n.cs-drop-indicator--vertical {\n width: 4px !important;\n margin-left: -1px;\n}\n\n/* ============================================================\n Inline hover insert (+) control\n ============================================================ */\n.cs-inline-insert-line {\n position: fixed;\n height: 2px;\n transform: translateY(-50%);\n background: rgba(36, 133, 103, 0.18);\n box-shadow: 0 0 0 1px rgba(36, 133, 103, 0.04);\n pointer-events: none;\n z-index: 9996;\n opacity: 0;\n transition: opacity 120ms ease;\n}\n\n.cs-inline-insert-line.is-visible {\n opacity: 1;\n}\n\n/* New-column variant: vertical line on a block's left/right edge. */\n.cs-inline-insert-line--vertical {\n height: auto;\n width: 2px;\n transform: translateX(-50%);\n}\n\n.cs-inline-insert-line.is-active {\n background: rgba(36, 133, 103, 0.9);\n box-shadow: 0 0 0 1px rgba(36, 133, 103, 0.08);\n}\n\n.cs-inline-insert {\n position: fixed;\n width: 25px;\n height: 25px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transform: translate(0%, -50%);\n border: 1px solid #dfe5df;\n border-radius: 999px;\n background: #f7fbf8;\n color: #b7c1b8;\n font-size: 20px;\n line-height: 1;\n cursor: pointer;\n box-shadow: 0 4px 10px rgba(20, 24, 60, 0.05);\n z-index: 9997;\n opacity: 0;\n pointer-events: none;\n transition: opacity 120ms ease, transform 120ms ease, background 120ms ease, color 120ms ease, border-color 120ms ease;\n}\n\n.cs-inline-insert span {\n transform: translateY(-1px);\n pointer-events: none;\n}\n\n/* New-column variant: centre the + on the vertical line's top point. */\n.cs-inline-insert--vertical {\n transform: translate(-50%, -50%);\n}\n\n.cs-inline-insert.is-visible {\n opacity: 1;\n pointer-events: auto;\n}\n\n.cs-inline-insert:hover,\n.cs-inline-insert.is-open {\n background: #248567;\n border-color: #248567;\n color: #ffffff;\n}\n\n\n.cs-inline-insert::after {\n content: attr(title);\n position: absolute;\n left: 181%;\n top: -26px;\n transform: translateX(-50%);\n padding: 6px 10px;\n border-radius: 4px;\n background: rgba(33, 33, 33, 0.92);\n color: #ffffff;\n font-size: 12px;\n font-weight: 500;\n white-space: nowrap;\n opacity: 0;\n pointer-events: none;\n transition: opacity 120ms ease;\n}\n\n.cs-inline-insert:hover::after,\n.cs-inline-insert.is-open::after {\n opacity: 1;\n}\n\n.cs-inline-insert-menu {\n position: fixed;\n width: 270px;\n max-height: min(420px, calc(100vh - 24px));\n overflow: auto;\n padding: 8px 0;\n border: 1px solid #e2e6f0;\n border-radius: 10px;\n background: #ffffff;\n box-shadow: 0 18px 40px rgba(20, 24, 60, 0.16);\n z-index: 9998;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-4px);\n transition: opacity 140ms ease, transform 140ms ease;\n}\n\n.cs-inline-insert-menu.is-open {\n opacity: 1;\n pointer-events: auto;\n transform: translateY(0);\n}\n\n.cs-inline-insert-menu__section+.cs-inline-insert-menu__section {\n margin-top: 8px;\n padding-top: 8px;\n border-top: 1px solid #eef1f6;\n}\n\n.cs-inline-insert-menu__title {\n padding: 6px 14px 8px;\n color: #7b8198;\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.cs-inline-insert-menu__item {\n width: 100%;\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 14px;\n border: 0;\n background: transparent;\n text-align: left;\n color: #243047;\n cursor: pointer;\n transition: background 120ms ease, color 120ms ease;\n}\n\n.cs-inline-insert-menu__item:hover {\n background: #f3f8f6;\n color: #248567;\n}\n\n.cs-inline-insert-menu__icon {\n width: 22px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: #8b93a7;\n font-size: 14px;\n flex: 0 0 22px;\n}\n\n.cs-inline-insert-menu__item:hover .cs-inline-insert-menu__icon {\n color: #248567;\n}\n\n.cs-inline-insert-menu__label {\n font-size: 14px;\n font-weight: 500;\n}\n\n\n.cs-inline-insert-menu {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n padding: 14px 12px 24px;\n\n /* Invisible scrollbar */\n &::-webkit-scrollbar {\n width: 0;\n background: transparent;\n }\n\n &::-webkit-scrollbar-track {\n background: transparent;\n }\n\n &::-webkit-scrollbar-thumb {\n background: transparent;\n }\n\n /* Firefox */\n scrollbar-width: none;\n -ms-overflow-style: none;\n}\n\n/* Add a premium pop animation when a new block is dropped on canvas */\n.custom-form-design>.cs_paper .cs_block_s,\n.cs_margin .cs_block_s {\n animation: scaleInDrop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);\n}\n\n/* Top-level blocks inside flow canvas (direct children of col-item) — strip\n absolute positioning. In-section blocks keep absolute positioning.\n Width is auto by default (block elements fill their column); inline width\n set via resize wins because we don't !important it. */\n.cs-flow-canvas .col-item>.cs_block_s,\n.cs-flow-canvas .col-item>.canvas-block {\n position: relative !important;\n top: auto !important;\n left: auto !important;\n max-width: none !important;\n /* TODO : multiple col little gap reduced not sure incase we need gap please uncomments the below margin code */\n /* margin: 0 0 6px 0; */\n display: block;\n box-sizing: border-box;\n padding: 2px;\n}\n\n.cs-flow-canvas .col-item>.cs_block_s:last-child,\n.cs-flow-canvas .col-item>.canvas-block:last-child {\n margin-bottom: 0;\n}\n\n/* Section content area is a nested row/col flow region — its height is\n driven entirely by the rows it contains, so a table that grows from a\n {% for %} loop naturally stretches the section box too. */\n.cs-flow-canvas .section-container-content {\n position: relative;\n /* min-height: var(--cs-section-min-height, 160px); */\n /* background: var(--cs-section-bg, #e5e7e7); */\n display: flex;\n flex-direction: column;\n /* padding: 8px; */\n /* gap: 4px; */\n /* outline: 1px solid #e1e1e1; */\n height: auto !important;\n}\n\n/* The section's outer block wrapper must not lock to a fixed height —\n the rendered output (and PDF) needs to grow with the {% for %} body. */\n.cs-flow-canvas .cs_block_s:has(> .section-container-content) {\n height: auto !important;\n min-height: 0 !important;\n}\n\n/* Rows inside a section behave like rows in the doc root. */\n.cs-flow-canvas .section-container-content>.row-item {\n flex: 0 0 auto;\n}\n\n/* Flexible block: free positioning canvas for child blocks */\n.cs-flow-canvas .cs-flexible-content {\n position: relative;\n display: block !important;\n /* min-height: 80px;\n padding: 8px; */\n /* outline: 1px dashed #cfd4f6; */\n /* background: #fafbfe; */\n}\n\n/* Cover page: the .cs_page itself IS the free-move canvas sheet. No .cs_margin\n and no .cs-flexible-content wrapper — blocks are absolutely-positioned DIRECT\n children. Give it A4 page dimensions and make it the positioning context. */\n.cs-cover-canvas.cs_page {\n width: var(--cs-page-width, 794px);\n min-height: var(--cs-page-min-height, 1123px);\n height: auto;\n margin: 24px auto;\n background: var(--cs-page-bg, #ffffff);\n border: 1px solid #e1e1e1;\n position: relative;\n box-sizing: border-box;\n}\n\n.cs-cover-canvas>.cs_block_s[data-cs-in-section=\"1\"] {\n position: absolute !important;\n display: block !important;\n margin: 0 !important;\n z-index: 1;\n}\n\n/* Blocks inside flexible use absolute positioning for free movement */\n.cs-flow-canvas .cs-flexible-content>.cs_block_s[data-cs-in-section=\"1\"] {\n position: absolute !important;\n display: block !important;\n margin: 0 !important;\n top: 8px;\n left: 8px;\n}\n\n/* Drop surface highlight while dragging from sidebar */\n.cs-flow-canvas.drop-surface--active .col-item:empty::before {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* ============================================================\n BLOCK WIDTH NORMALIZATION (flow contexts only)\n In a row/column flow canvas, blocks fill their column width.\n In absolute canvas mode the user controls width via drag-resize,\n so we don't force anything there.\n ============================================================ */\n.cs_margin>.cs_block_s,\n.cs_margin>.canvas-block,\n.col-item>.cs_block_s,\n.col-item>.canvas-block {\n width: 100% !important;\n max-width: 100% !important;\n box-sizing: border-box;\n}\n\n\n/* ============================================================\n Block reorder — grip handle + dragging state\n ============================================================ */\n.cs-block-grip {\n position: absolute;\n top: 50%;\n left: -28px;\n transform: translateY(-50%);\n width: 22px;\n height: 26px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: #ffffff;\n border: 1px solid #d8dbef;\n border-radius: 4px;\n color: #8a90b8;\n cursor: grab;\n opacity: 0;\n transition: opacity 120ms ease, background 120ms ease, color 120ms ease, border-color 120ms ease;\n user-select: none;\n z-index: 30;\n line-height: 1;\n touch-action: none;\n box-shadow: 0 1px 2px rgba(20, 24, 60, 0.05);\n}\n\n.cs-block-grip svg {\n display: block;\n pointer-events: none;\n}\n\n/* Show the grip when the block (or grip itself) is hovered */\n.cs-flow-canvas .col-item>.cs_block_s:hover>.cs-block-grip,\n.cs-flow-canvas .col-item>.canvas-block:hover>.cs-block-grip,\n.cs-block-grip:hover {\n opacity: 1;\n}\n\n.cs-block-grip:hover {\n background: #248567;\n border-color: #248567;\n color: #ffffff;\n}\n\n.cs-block-grip:active {\n cursor: grabbing;\n background: #5c5cff;\n color: #ffffff;\n}\n\n/* The host block needs position:relative so the grip's absolute placement\n anchors correctly. Top-level flow blocks already have position:relative\n from the normalize step. */\n.cs-flow-canvas .col-item>.cs_block_s,\n.cs-flow-canvas .col-item>.canvas-block {\n position: relative;\n}\n\n/* During drag, fade out block content but keep grip icon visible for better UX */\n.cs-flow-canvas .cs-block--dragging {\n opacity: 0.15;\n pointer-events: none;\n z-index: 100;\n}\n\n/* Keep grip icon fully visible during drag */\n.cs-flow-canvas .cs-block--dragging .cs-block-grip {\n opacity: 1;\n}\n\n/* Field panel UI lives in the parent Angular app — see app.scss. */\n\n\n\n.body-main-content {\n /* padding: 16px; */\n}\n\n@media print {\n .cs_margin {\n height: var(--cs-page-min-height, 1123px) !important;\n }\n\n .cs-page-number {\n display: none !important;\n }\n\n /* Disable all animations during PDF generation */\n * {\n animation: none !important;\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition: none !important;\n transition-duration: 0s !important;\n }\n\n .cs-drop-indicator,\n .cs_block_s,\n .canvas-block {\n animation: none !important;\n }\n}\n\n/* Dimension indicator — shows block dimensions during resize */\n@keyframes cs-dimension-fade-in {\n from {\n opacity: 0;\n transform: translate(-50%, -8px) scale(0.95);\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, 0) scale(1);\n }\n}\n\n@keyframes cs-dimension-fade-out {\n from {\n opacity: 1;\n transform: translate(-50%, 0) scale(1);\n }\n\n to {\n opacity: 0;\n transform: translate(-50%, -8px) scale(0.95);\n }\n}\n\n.cs-dimension-indicator {\n position: fixed;\n pointer-events: none;\n z-index: 9999;\n opacity: 0;\n}\n\n.cs-dimension-indicator--visible {\n animation: cs-dimension-fade-in 200ms ease forwards;\n}\n\n.cs-dimension-indicator--visible:not(.cs-dimension-indicator--hide) {\n animation: cs-dimension-fade-in 200ms ease forwards;\n}\n\n.cs-dimension-indicator__text {\n display: inline-block;\n padding: 8px 14px;\n background: rgba(0, 0, 0, 0.9);\n color: #ffffff;\n font-size: 13px;\n font-weight: 500;\n border-radius: 6px;\n white-space: nowrap;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);\n letter-spacing: 0.3px;\n}\n</style>\n <link rel=\"stylesheet\"\n href=\"https://cdnjs.cloudflare.com/ajax/libs/froala-editor/4.3.1/css/froala_editor.pkgd.min.css\" />\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\" />\n <style data-src=\"./editor/editor.css\">\n/* ============================================================\n Block state machine: idle → selected → editing\n ------------------------------------------------------------\n - idle: no chrome, block is just placed\n - selected: blue outline + top badge (move handle + menu)\n - editing: blue outline + 8 resize handles + Froala active\n ============================================================ */\n\n.cs_block_s {\n cursor: pointer;\n /* No visible border at rest — only hover / selected / editing reveal it by\n swapping border-color below. `transparent` (not `none`) keeps the 1px so\n hovering never shifts layout. Overrides the solid #e1e1e1 base in\n custom-form.css (editor.css loads last). */\n border: 1px solid transparent;\n transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);\n}\n\n.cs_block_s:hover:not(.cs-selected):not(.cs-editing) {\n border-color: #76767629;\n /* box-shadow: 0 4px 16px rgba(92, 92, 255, 0.12);\n transform: translateY(-1px); */\n}\n\n/* ---------- Selected state ---------- */\n.cs_block_s.cs-selected,\n.cs_block_s.cs-editing {\n border-color: #248567;\n box-shadow: 0 0 0 1px rgba(92, 92, 255, 0.2);\n outline: none;\n}\n\n@keyframes slideUpBadge {\n 0% {\n transform: translateY(8px);\n opacity: 0;\n }\n\n 100% {\n transform: translateY(0);\n opacity: 1;\n }\n}\n\n/* The blue badge at the top-left of a selected/editing block */\n.cs-block-badge {\n position: absolute;\n top: -26px;\n left: -1px;\n height: 24px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 0 8px;\n background: #248567;\n color: #ffffff;\n font-family: Inter, \"Segoe UI\", sans-serif;\n font-size: 12px;\n font-weight: 600;\n border-radius: 6px 6px 0 0;\n user-select: none;\n z-index: 10;\n white-space: nowrap;\n cursor: grab;\n touch-action: none;\n animation: slideUpBadge 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;\n}\n\n.cs-block-badge__handle {\n cursor: grab;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n font-size: 12px;\n line-height: 1;\n}\n\n.cs-block-badge__handle:active {\n cursor: grabbing;\n}\n\n.cs-block-badge__label {\n pointer-events: none;\n}\n\n/* Renaming the block (Ctrl+R): the label becomes an inline text field. Override\n the badge's user-select:none / label's pointer-events:none so the caret and\n text selection work. */\n.cs-block-badge__label--editing {\n pointer-events: auto;\n user-select: text;\n cursor: text;\n outline: none;\n min-width: 12px;\n padding: 0 4px;\n border-radius: 2px;\n background: #ffffff;\n color: #1f2937;\n caret-color: #1f2937;\n}\n\n/* Live X/Y (move) or W/H (resize) readout shown in place of the title while\n dragging/resizing a free-move block. Blue HUD so it reads as a measurement. */\n.cs-block-badge__label--metric {\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n\n.cs-block-badge:has(.cs-block-badge__label--metric) {\n background: #248567;\n}\n\n/* While the live readout is active (dragging/resizing), show ONLY the X/Y\n (or W/H) value — hide the move handle, duplicate, delete and menu. */\n.cs-block-badge:has(.cs-block-badge__label--metric) .cs-block-badge__handle,\n.cs-block-badge:has(.cs-block-badge__label--metric) .cs-block-badge__actions,\n.cs-block-badge:has(.cs-block-badge__label--metric) .cs-block-badge__menu {\n display: none;\n}\n\n/* ============================================================\n Cover-page grouping: marquee, multi-select, group, toolbar\n ============================================================ */\n\n/* Rubber-band marquee rectangle */\n.cs-marquee {\n pointer-events: none;\n background: rgba(92, 92, 255, 0.10);\n border: 1px solid rgba(92, 92, 255, 0.55);\n border-radius: 2px;\n}\n\n/* Dotted bounding box around the whole multi-selection (shown before grouping) */\n.cs-group-bounds {\n pointer-events: none;\n border: 1px dashed #20233d;\n background: transparent;\n}\n\n/* Blocks caught by the marquee (multi-selected, pre-group) */\n.cs-multi-selected {\n outline: 1px solid #248567;\n outline-offset: 1px;\n box-shadow: 0 0 0 1px rgba(92, 92, 255, 0.25);\n}\n\n/* A group container on the cover page */\n.cs-group-block {\n box-sizing: border-box;\n}\n\n.cs-group-block.cs-selected,\n.cs-group-block.cs-editing {\n outline: 1.5px solid #5c5cff;\n outline-offset: 0;\n}\n\n/* Children of a group are absolutely positioned within the group box */\n.cs-group-block>.cs_block_s[data-cs-in-section=\"1\"] {\n position: absolute !important;\n display: block !important;\n margin: 0 !important;\n}\n\n/* Floating Group / Ungroup button */\n.cs-group-toolbar {\n align-items: center;\n gap: 4px;\n padding: 2px;\n background: #1b2030;\n /* border: 1px solid rgba(255, 255, 255, 0.12); */\n /* border-radius: 9px; */\n /* box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4); */\n}\n\n/* Align / distribute icon buttons inside the multi-select toolbar. */\n.cs-group-toolbar__ico {\n width: 28px;\n height: 28px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #e5e7f0;\n cursor: pointer;\n padding: 0;\n}\n\n.cs-group-toolbar__ico:hover {\n background: rgba(92, 92, 255, 0.3);\n}\n\n.cs-group-toolbar__ico svg {\n width: 16px;\n height: 16px;\n}\n\n.cs-group-toolbar__ico svg rect {\n fill: currentColor;\n}\n\n.cs-group-toolbar__ico svg line {\n stroke: currentColor;\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n\n.cs-group-toolbar__sep {\n width: 1px;\n align-self: stretch;\n margin: 2px 3px;\n background: rgba(255, 255, 255, 0.14);\n}\n\n.cs-group-toolbar__btn {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n height: 28px;\n padding: 0 12px;\n border: none;\n border-radius: 6px;\n background: #5c5cff;\n color: #ffffff;\n font-family: Inter, \"Segoe UI\", sans-serif;\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n box-shadow: 0 6px 16px rgba(92, 92, 255, 0.35);\n transition: background 120ms ease;\n}\n\n.cs-group-toolbar__btn:hover {\n background: #4a4ae6;\n}\n\n.cs-block-badge__menu {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n margin-left: 4px;\n border-radius: 3px;\n background: rgba(255, 255, 255, 0.18);\n font-size: 14px;\n line-height: 1;\n letter-spacing: 1px;\n}\n\n.cs-block-badge__menu:hover {\n background: rgba(255, 255, 255, 0.32);\n}\n\n/* Action buttons (move up/down, duplicate, delete) on the right of the badge */\n.cs-block-badge__actions {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n margin-left: 4px;\n padding-left: 6px;\n border-left: 1px solid rgba(255, 255, 255, 0.28);\n}\n\n.cs-block-badge__btn {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 3px;\n background: rgba(255, 255, 255, 0.18);\n color: #ffffff;\n font-size: 10px;\n line-height: 1;\n transition: background 120ms ease;\n}\n\n.cs-block-badge__btn:hover {\n background: rgba(255, 255, 255, 0.34);\n}\n\n.cs-block-badge__btn--danger:hover {\n background: #e5484d;\n}\n\n/* In editing mode the move handle + actions disappear (matches screenshot 2) */\n.cs_block_s.cs-editing .cs-block-badge__handle,\n.cs_block_s.cs-editing .cs-block-badge__actions,\n.cs_block_s.cs-editing .cs-block-badge__menu {\n display: none;\n}\n\n/* While editing, suppress the hover border (cleaner UI) */\n.cs_block_s.cs-editing {\n cursor: text;\n}\n\n@keyframes popResizeHandle {\n 0% {\n transform: scale(0);\n opacity: 0;\n }\n\n 100% {\n transform: scale(1);\n opacity: 1;\n }\n}\n\n/* ---------- Resize handles (editing state) ---------- */\n.cs-resize-handle {\n position: absolute;\n width: 10px;\n height: 10px;\n background: #ffffff;\n border: 1.5px solid #5c5cff;\n border-radius: 50%;\n /* Make them circular for a modern look */\n z-index: 9000;\n box-sizing: border-box;\n pointer-events: auto;\n box-shadow: 0 2px 4px rgba(92, 92, 255, 0.3);\n animation: popResizeHandle 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) backwards;\n}\n\n/* Add slight delay staggering to handles for an amazing unrolling effect */\n.cs-resize-handle[data-dir=\"nw\"] {\n animation-delay: 0.00s;\n}\n\n.cs-resize-handle[data-dir=\"n\"] {\n animation-delay: 0.02s;\n}\n\n.cs-resize-handle[data-dir=\"ne\"] {\n animation-delay: 0.04s;\n}\n\n.cs-resize-handle[data-dir=\"e\"] {\n animation-delay: 0.06s;\n}\n\n.cs-resize-handle[data-dir=\"se\"] {\n animation-delay: 0.08s;\n}\n\n.cs-resize-handle[data-dir=\"s\"] {\n animation-delay: 0.10s;\n}\n\n.cs-resize-handle[data-dir=\"sw\"] {\n animation-delay: 0.12s;\n}\n\n.cs-resize-handle[data-dir=\"w\"] {\n animation-delay: 0.14s;\n}\n\n/* Make sure the parent block doesn't clip the handles that sit on its edge */\n.cs_block_s.cs-editing,\n.cs_block_s.cs-selected {\n overflow: visible !important;\n}\n\n.cs-resize-handle[data-dir=\"nw\"] {\n top: -6px;\n left: -6px;\n cursor: nwse-resize;\n}\n\n.cs-resize-handle[data-dir=\"n\"] {\n top: -6px;\n left: 50%;\n cursor: ns-resize;\n transform: translateX(-50%);\n}\n\n.cs-resize-handle[data-dir=\"ne\"] {\n top: -6px;\n right: -6px;\n cursor: nesw-resize;\n}\n\n.cs-resize-handle[data-dir=\"e\"] {\n top: 50%;\n right: -6px;\n cursor: ew-resize;\n transform: translateY(-50%);\n}\n\n.cs-resize-handle[data-dir=\"se\"] {\n bottom: -6px;\n right: -6px;\n cursor: nwse-resize;\n}\n\n.cs-resize-handle[data-dir=\"s\"] {\n bottom: -6px;\n left: 50%;\n cursor: ns-resize;\n transform: translateX(-50%);\n}\n\n.cs-resize-handle[data-dir=\"sw\"] {\n bottom: -6px;\n left: -6px;\n cursor: nesw-resize;\n}\n\n.cs-resize-handle[data-dir=\"w\"] {\n top: 50%;\n left: -6px;\n cursor: ew-resize;\n transform: translateY(-50%);\n}\n\n/* ---------- Editable content target ---------- */\n/* Force-override Froala's default min-height (200px) — otherwise the block\n inflates on edit-init, the resize handles end up far from where the user\n clicked, and bottom handles may even be clipped by the canvas overflow. */\n.cs_block_s.cs-editing .edit_me,\n.cs_block_s.cs-editing .fr-element,\n.cs_block_s.cs-editing .fr-view {\n cursor: text;\n outline: none;\n min-height: 1em !important;\n height: auto !important;\n overflow: visible !important;\n}\n\n.cs_block_s.cs-editing .fr-wrapper,\n.cs_block_s.cs-editing .fr-box {\n min-height: 0 !important;\n height: auto !important;\n}\n\n/* Froala wraps .edit_me in .fr-box; make sure the wrapper doesn't shrink */\n.cs_block_s .fr-box {\n width: 100%;\n display: block;\n}\n\n/* Keep the block tall enough for resize handles to be reachable */\n.cs_block_s.cs-editing {\n min-height: 32px;\n min-width: 60px;\n}\n\n/* Free-form blocks (flexible containers + their absolutely-positioned\n in-section children) are meant to be sized freely, so they may shrink\n below the default editing minimums. */\n.cs-flexible-content>.cs_block_s.cs-editing[data-cs-in-section=\"1\"],\n.cs_block_s.cs-flexible-block.cs-editing {\n min-width: 20px;\n min-height: 20px;\n}\n\n.edit_me {\n height: auto;\n padding: 0;\n margin: 0;\n overflow: hidden;\n color: #505b65;\n line-height: 1.5;\n width: 100%;\n word-break: break-word;\n overflow-wrap: break-word;\n text-align: left;\n}\n\n.edit_me:empty:before {\n content: attr(placeholder);\n color: #aaa;\n font-family: sans-serif;\n line-height: 1.1;\n}\n\n/* ---------- Froala tweaks ---------- */\n.fr-counter,\n.fr-word-count,\n.fr-word-counter,\n.fr-footer .fr-counter {\n display: none !important;\n}\n\n/* Keep Froala's popup toolbar above the resize handles */\n.fr-popup,\n.fr-toolbar {\n z-index: 9999 !important;\n}\n\n.cs_block_s p {\n margin: 0;\n}\n\n.normal-table-width table {\n width: 100%;\n}\n\n.section-container-content {\n height: 100% !important;\n /* background: #e5e7e7; */\n outline: none;\n min-height: 100px;\n}\n\n\n\n/* image block */\n\n\n@use './cs-mixin-style' as mixins;\n\n/* ============================== image block css ======================================= */\n.image-container {\n display: flex;\n flex-direction: column;\n align-items: center;\n overflow: hidden;\n margin: auto;\n aspect-ratio: 1;\n max-width: 100%;\n max-height: 100%;\n width: 100%;\n height: 100%;\n clip-path: unset !important;\n}\n\n.image-container img {\n max-width: 100%;\n max-height: 100%;\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: inherit;\n}\n\n/* ---- Image zoom / pan (active only while the block is in edit mode) ---- */\n/* Hint that the image can be scrolled to zoom while editing. */\n.cs-image-block.cs-editing .image-container img {\n user-select: none;\n -webkit-user-drag: none;\n}\n\n/* Once zoomed past 1x the image is draggable to reposition the crop. */\n.cs-image-block.cs-editing.cs-img-zoomed .image-container {\n cursor: grab;\n}\n\n.cs-image-block.cs-img-panning .image-container,\n.cs-image-block.cs-img-panning .image-container img {\n cursor: grabbing !important;\n}\n\n/* ============================== upload-image block css ======================================= */\n.img-btn {\n background-color: var(--upload-image-placeholder-bg);\n border: 1px solid var(--upload-image-placeholder-border);\n box-sizing: border-box;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n min-height: fit-content;\n max-height: 100%;\n width: 100%;\n height: 100%;\n padding: 10px;\n cursor: default;\n}\n\n.icon-group {\n width: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center\n}\n\n.icon-layer {\n padding: 0;\n margin-bottom: 10px;\n color: var(--upload-icon-text-bg);\n cursor: pointer;\n font-size: 14px;\n width: 24px;\n height: 24px;\n}\n\n.img-btn-txt {\n width: 100%;\n text-align: center;\n font-size: 14px;\n line-height: 19px;\n color: var(--Text-colors-Secondary);\n display: flex;\n align-items: center;\n flex-direction: row;\n justify-content: center;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n cursor: pointer;\n}\n\n.img-icon {\n background: var(--upload-plus-text-bg);\n border-radius: 6px;\n}\n\n.icon-layer,\n.img-btn-txt {\n cursor: pointer !important;\n position: relative;\n z-index: 10;\n pointer-events: auto !important;\n}\n\n.cs_block_s img:not(.pricing-section-image-section) {\n max-width: 100% !important;\n max-height: 100% !important;\n object-fit: contain;\n}\n\n.cs-image-block {\n display: flex;\n width: auto;\n height: auto;\n}\n\n.EHP .cs-image-block,\n.EHP .cs-video-block {\n position: relative;\n width: max-content;\n}\n\n.ECP .cs-image-block,\n.ECP .cs-video-block {\n position: relative;\n width: 100%;\n}\n\n.cs-image-block.block-selected,\n.cs-video-block.block-selected {\n .img-btn {\n cursor: move;\n\n .icon-layer,\n .img-btn-txt {\n cursor: pointer;\n }\n }\n}\n\n/* ============================== image block shapes css ======================================= */\n/* Frame shapes are applied to the CONTAINER only. The container is never\n * transformed, so its clip/round stays fixed while the <img> inside can still\n * be zoomed/panned (image-zoom.js) and gets clipped to the frame by the\n * container's `overflow: hidden`. clip-path needs !important to beat the base\n * `.image-container { clip-path: unset !important }` rule above. */\n.image-container.square-image {\n border-radius: 0;\n clip-path: none !important;\n}\n\n.image-container.rounded-square-image {\n border-radius: 16px;\n clip-path: none !important;\n}\n\n.image-container.circle-image {\n aspect-ratio: 1 !important;\n border-radius: 50%;\n clip-path: none !important;\n}\n\n.image-container.diagonal-corners-image {\n border-radius: 0;\n clip-path: polygon(20% 0%, 80% 0%, 100% 20%, 100% 80%, 80% 100%, 20% 100%, 0% 80%, 0% 20%) !important;\n}\n\n.image-container.star {\n border-radius: 0;\n clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%) !important;\n}\n\n.image-container.polygon {\n border-radius: 0;\n clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%) !important;\n}\n\n.image-container.rectangle-image {\n border-radius: 0;\n clip-path: none !important;\n}\n\n.image-container.rectangle-image {\n aspect-ratio: unset;\n}\n\n.ECP .image-container.rectangle-image {\n width: 100%;\n height: auto;\n}\n\n.ListBlankHorizontal .circle-image.image-container .img-btn {\n height: calc(100% - 10px);\n width: auto;\n}\n\n.image-container.circle-image .img-btn {\n min-width: unset;\n min-height: unset;\n}\n\n.cs_margin .circle-image {\n margin: auto;\n}\n\n/* ============================== icon-block css ======================================= */\n.cs-icon-block {\n padding: 8px;\n aspect-ratio: 1 / 1;\n min-width: 42px;\n width: auto;\n max-width: 100%;\n height: auto !important;\n container-type: inline-size;\n display: flex;\n place-items: center;\n align-items: center;\n justify-content: center;\n\n .icon-box {\n width: 100%;\n height: 100%;\n max-width: 100%;\n max-height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 1;\n\n i.icon-text {\n font-size: 80cqw;\n /* Adjust icon size based on container width (80cqw given bcz it will 80% of parent width, remainging will be around space) */\n height: 100%;\n width: 100%;\n max-width: 100%;\n max-height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 1;\n }\n }\n}\n\n/* ============================== content-page-image-block css ======================================= */\n.cs_margin div.cs_block_s[data^=\"Image\"],\n.cs_margin div.cs_block_s[data^=\"Video\"] {\n text-align: center;\n user-select: none;\n}\n\n.cs_margin div.cs_block_s[data^=\"Image\"] .cs-image-cover {\n display: revert !important;\n}\n\n.cs_margin .drag-move-icon svg {\n margin: 0;\n}\n\n.cs_content_block {\n /* Default values */\n --rotate: 0deg;\n --zoom: 1;\n\n /* The magic line: combine them automatically */\n transform: rotate(var(--rotate)) scale(var(--zoom)) !important;\n transform-origin: center center;\n}\n\n.fr-view table td,\n.fr-view table th {\n padding: 4px;\n width: 185px;\n font-weight: 500;\n}\n\n.fr-view table td:empty,\n.fr-view table th:empty {\n height: 33px;\n}\n\n/* ============================ Pen Shape block ============================ */\n/* Default height — a CSS rule (not inline) so normalizeForFlow's inline-style\n strip on drop can't wipe it. A manual resize writes an inline height that\n overrides this, so resizing (smaller or larger) still works. */\n.cs-pen-shape-block {\n height: 200px;\n}\n\n.cs-pen-shape-block .cs-pen-shape {\n position: relative;\n width: 100%;\n height: 100%;\n min-height: 40px;\n box-sizing: border-box;\n}\n\n.cs-pen-shape-block .cs-pen-svg {\n display: block;\n width: 100%;\n height: 100%;\n /* Clip the shape to the block so a dragged anchor/curve can never spill\n outside the block's bounds. */\n overflow: hidden;\n}\n\n/* While editing the shape, hide only the 4 CORNER resize handles — they sit\n exactly on the corner anchors and make those anchors impossible to grab. The\n 4 SIDE handles (n/e/s/w) stay, so the box is still resizable from its edges\n without deselecting. The toolbar \"Resize box\" (⤢) toggle adds .cs-pen-resizing\n to bring ALL handles back (anchors hidden meanwhile) for full corner resize. */\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"nw\"],\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"ne\"],\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"se\"],\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"sw\"] {\n display: none !important;\n}\n\n.cs-pen-shape-block.cs-editing.cs-pen-resizing .cs-resize-handle {\n display: block !important;\n}\n\n.cs-pen-shape-block.cs-pen-resizing .cs-pen-overlay {\n cursor: default;\n}\n\n/* Editing overlay — only present while the block is in edit mode. */\n.cs-pen-overlay {\n position: absolute;\n inset: 0;\n z-index: 20;\n cursor: crosshair;\n touch-action: none;\n}\n\n/* Space held → \"move whole clip-path\" mode: show the grab/hand cursor. */\n.cs-pen-overlay.cs-pen-pan {\n cursor: grab;\n}\n\n.cs-pen-overlay.cs-pen-pan:active {\n cursor: grabbing;\n}\n\n.cs-pen-overlay-svg {\n position: absolute;\n inset: 0;\n overflow: visible;\n pointer-events: none;\n}\n\n.cs-pen-anchor {\n fill: #fff;\n stroke: #5c5cff;\n stroke-width: 1.5;\n}\n\n.cs-pen-anchor.is-sel {\n fill: #5c5cff;\n}\n\n.cs-pen-anchor.is-first {\n stroke: #16a34a;\n stroke-width: 2;\n}\n\n/* Anchors of a NON-active sub-path — dimmed so the active clip-path stands out.\n Clicking such a shape's fill selects it (then its anchors become solid). */\n.cs-pen-anchor.is-dim {\n fill: rgba(255, 255, 255, 0.5);\n stroke: rgba(92, 92, 255, 0.45);\n}\n\n.cs-pen-handle {\n fill: #5c5cff;\n stroke: #fff;\n stroke-width: 1;\n}\n\n.cs-pen-handle-line {\n stroke: #5c5cff;\n stroke-width: 1;\n stroke-dasharray: 2 2;\n}\n\n.cs-pen-rubber {\n stroke: #9aa0ff;\n stroke-width: 1;\n stroke-dasharray: 4 3;\n}\n\n/* Pen-tool hover affordances on a finished shape: + (add a point on an edge),\n × (remove the point under the cursor). */\n.cs-pen-add {\n fill: rgba(36, 133, 103, 0.16);\n stroke: #248567;\n stroke-width: 1.5;\n}\n\n.cs-pen-add-mark {\n stroke: #248567;\n stroke-width: 2;\n stroke-linecap: round;\n}\n\n.cs-pen-remove {\n fill: rgba(225, 29, 72, 0.14);\n stroke: #e11d48;\n stroke-width: 1.5;\n}\n\n.cs-pen-remove-mark {\n stroke: #e11d48;\n stroke-width: 2;\n stroke-linecap: round;\n}\n\n/* Smart alignment guide — appears when a point lines up with another point's\n x or y (straight edges / equal heights / equal widths). */\n.cs-pen-guide {\n stroke: #f43f7e;\n stroke-width: 1;\n stroke-dasharray: 4 3;\n pointer-events: none;\n}\n\n/* Align / distribute toolbar (free-move block selection). */\n.cs-align-bar {\n position: fixed;\n z-index: 9996;\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 4px;\n background: #1b2030;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 8px;\n box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);\n transform: translateX(-50%);\n}\n\n.cs-align-bar button {\n width: 28px;\n height: 28px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #e5e7f0;\n cursor: pointer;\n padding: 0;\n}\n\n.cs-align-bar button:hover {\n background: rgba(92, 92, 255, 0.25);\n}\n\n.cs-align-bar svg {\n width: 16px;\n height: 16px;\n}\n\n.cs-align-bar svg rect {\n fill: currentColor;\n}\n\n.cs-align-bar svg line {\n stroke: currentColor;\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n\n.cs-align-bar__sep {\n width: 1px;\n align-self: stretch;\n margin: 2px 3px;\n background: rgba(255, 255, 255, 0.14);\n}\n\n/* Right-click context menu. */\n.cs-ctx-menu {\n position: fixed;\n z-index: 9999;\n min-width: 184px;\n padding: 6px;\n background: #ffffff;\n border: 1px solid #e2e6f0;\n border-radius: 10px;\n box-shadow: 0 18px 40px rgba(20, 24, 60, 0.18);\n font-size: 13px;\n}\n\n.cs-ctx-menu__item {\n width: 100%;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 18px;\n padding: 8px 10px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: #243047;\n cursor: pointer;\n text-align: left;\n}\n\n.cs-ctx-menu__item:hover {\n background: #eef2ff;\n}\n\n.cs-ctx-menu__item.is-danger {\n color: #dc2626;\n}\n\n.cs-ctx-menu__item.is-danger:hover {\n background: #fef2f2;\n}\n\n.cs-ctx-menu__hint {\n color: #9aa0b4;\n font-size: 11px;\n}\n\n.cs-ctx-menu__sep {\n height: 1px;\n margin: 5px 6px;\n background: #eef1f6;\n}\n\n/* Keyboard-shortcuts help overlay. */\n.cs-shortcuts {\n position: fixed;\n inset: 0;\n z-index: 10000;\n display: grid;\n place-items: center;\n}\n\n.cs-shortcuts__backdrop {\n position: absolute;\n inset: 0;\n background: rgba(20, 24, 48, 0.55);\n backdrop-filter: blur(3px);\n}\n\n.cs-shortcuts__panel {\n position: relative;\n width: min(720px, calc(100vw - 32px));\n max-height: calc(100vh - 48px);\n overflow: auto;\n background: #ffffff;\n border-radius: 14px;\n box-shadow: 0 28px 64px rgba(14, 20, 48, 0.4);\n padding: 18px 20px 22px;\n}\n\n.cs-shortcuts__head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 16px;\n font-weight: 700;\n color: #1f2937;\n margin-bottom: 14px;\n}\n\n.cs-shortcuts__close {\n border: none;\n background: #f3f4f6;\n width: 30px;\n height: 30px;\n border-radius: 8px;\n cursor: pointer;\n color: #6b7280;\n font-size: 14px;\n}\n\n.cs-shortcuts__close:hover {\n background: #e5e7eb;\n}\n\n.cs-shortcuts__cols {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n gap: 18px 28px;\n}\n\n.cs-shortcuts__title {\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: #7b8198;\n margin-bottom: 8px;\n}\n\n.cs-shortcuts__row {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 4px 0;\n font-size: 13px;\n color: #374151;\n}\n\n.cs-shortcuts__row kbd {\n flex: 0 0 auto;\n min-width: 64px;\n text-align: center;\n font-family: inherit;\n font-size: 11px;\n font-weight: 600;\n color: #243047;\n background: #f3f4f6;\n border: 1px solid #e2e6f0;\n border-radius: 5px;\n padding: 3px 7px;\n}\n\n/* Floating pen toolbar */\n.cs-pen-toolbar {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n width: max-content;\n max-width: none;\n gap: 2px;\n padding: 3px 6px;\n background: #23243d;\n border-radius: 8px;\n box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);\n z-index: 9100;\n cursor: default;\n white-space: nowrap;\n}\n\n.cs-pen-toolbar select {\n height: 24px;\n border: none;\n border-radius: 4px;\n background: #57586b;\n color: #fff;\n font-size: 11px;\n padding: 0 4px;\n cursor: pointer;\n}\n\n.cs-pen-fill-solid,\n.cs-pen-fill-gradient,\n.cs-pen-fill-image {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n\n.cs-pen-toolbar input[type=\"range\"] {\n width: 64px;\n}\n\n.cs-pen-toolbar button {\n width: 28px;\n height: 28px;\n border: none;\n border-radius: 5px;\n background: transparent;\n color: #e5e7f0;\n font-size: 15px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cs-pen-toolbar button:hover {\n background: #f48220;\n}\n\n.cs-pen-toolbar button.is-active {\n background: #248567;\n color: #fff;\n}\n\n.cs-pen-sep {\n width: 1px;\n height: 20px;\n background: #248567;\n margin: 0 3px;\n}\n\n.cs-pen-swatch,\n.cs-pen-num {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n color: #cbd0e0;\n font-size: 10px;\n padding: 0 2px;\n}\n\n.cs-pen-swatch input[type=\"color\"] {\n width: 22px;\n height: 22px;\n padding: 0;\n border: none;\n background: none;\n cursor: pointer;\n}\n\n.cs-pen-num input {\n width: 34px;\n height: 22px;\n border: none;\n border-radius: 4px;\n background: #353b4d;\n color: #fff;\n font-size: 11px;\n text-align: center;\n}\n\n/* Layers strip — one chip per shape, click to select. */\n.cs-pen-layers {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n max-width: 180px;\n overflow-x: auto;\n padding: 2px;\n}\n\n.cs-pen-layer {\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n border-radius: 4px;\n border: 1px solid rgba(255, 255, 255, 0.4);\n cursor: pointer;\n padding: 0;\n}\n\n.cs-pen-layer.is-active {\n border: 2px solid #fff;\n box-shadow: 0 0 0 1px #248567;\n}\n\n/* Gradient colour stops live inline next to the +/- buttons. */\n.cs-pen-grad-stops {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n\n/* ============================================================\n PAGE BACKGROUND SHAPE DESIGNER (full-screen modal)\n Reuses the pen tool (.cs-pen-*) on a page-sized stage.\n ============================================================ */\n.cs-page-shape-modal {\n position: fixed;\n inset: 0;\n z-index: 99999;\n display: flex;\n flex-direction: column;\n}\n\n.cs-page-shape-modal__backdrop {\n position: absolute;\n inset: 0;\n background: rgba(17, 24, 39, 0.78);\n backdrop-filter: blur(2px);\n}\n\n.cs-page-shape-modal__panel {\n position: relative;\n z-index: 1;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.cs-page-shape-modal__header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 10px 18px;\n background: #111827;\n color: #f9fafb;\n flex: 0 0 auto;\n}\n\n.cs-page-shape-modal__title {\n font-size: 14px;\n font-weight: 600;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.cs-page-shape-modal__dims {\n font-size: 11px;\n font-weight: 500;\n color: #9ca3af;\n background: #1f2937;\n padding: 3px 8px;\n border-radius: 999px;\n}\n\n.cs-page-shape-modal__pagepick {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n font-weight: 600;\n color: #9ca3af;\n margin-left: auto;\n margin-right: 16px;\n}\n\n.cs-page-shape-modal__pagepick select {\n background: #1f2937;\n color: #f9fafb;\n border: 1px solid #374151;\n border-radius: 6px;\n padding: 5px 10px;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-modal__actions {\n display: flex;\n gap: 8px;\n}\n\n.cs-page-shape-btn {\n border: none;\n border-radius: 6px;\n padding: 7px 14px;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-btn--ghost {\n background: #374151;\n color: #e5e7eb;\n}\n\n.cs-page-shape-btn--ghost:hover {\n background: #4b5563;\n}\n\n.cs-page-shape-btn--primary {\n background: #248567;\n color: #fff;\n}\n\n.cs-page-shape-btn--primary:hover {\n background: #1e6f57;\n}\n\n.cs-page-shape-modal__body {\n flex: 1 1 auto;\n display: flex;\n flex-direction: row;\n min-height: 0;\n}\n\n/* Left layers panel (Photoshop-style). */\n.cs-page-shape-layers {\n flex: 0 0 248px;\n display: flex;\n flex-direction: column;\n background: #1b2030;\n color: #e5e7f0;\n border-right: 1px solid rgba(255, 255, 255, 0.08);\n}\n\n.cs-page-shape-layers__title {\n padding: 12px 14px;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: #8aa4e6;\n border-bottom: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n.cs-page-shape-layers__list {\n flex: 1 1 auto;\n overflow-y: auto;\n padding: 8px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-width: 250px;\n}\n\n.cs-page-shape-layers__actions {\n display: flex;\n gap: 6px;\n padding: 8px;\n border-top: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n.cs-page-shape-layers__actions button {\n flex: 1 1 0;\n height: 30px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 6px;\n background: rgba(255, 255, 255, 0.05);\n color: #e5e7f0;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-layers__actions button:hover {\n background: rgba(92, 92, 255, 0.22);\n border-color: #5c5cff;\n}\n\n.cs-page-shape-layers__hint {\n padding: 8px 12px;\n font-size: 10px;\n color: #6b7280;\n border-top: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n/* One layer row */\n.cs-pen-layer-row {\n display: flex;\n align-items: center;\n gap: 4px;\n padding: 6px 7px;\n border-radius: 8px;\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid transparent;\n cursor: pointer;\n user-select: none;\n}\n\n.cs-pen-layer-row:hover {\n background: rgba(255, 255, 255, 0.07);\n}\n\n.cs-pen-layer-row.is-active {\n background: rgba(92, 92, 255, 0.18);\n border-color: #5c5cff;\n}\n\n.cs-pen-layer-row.is-multi {\n background: rgba(92, 92, 255, 0.12);\n border-color: rgba(92, 92, 255, 0.5);\n}\n\n.cs-pen-layer-row.is-hidden {\n opacity: 0.5;\n}\n\n.cs-pen-layer-row.is-locked .cs-pen-layer-row__name {\n opacity: 0.7;\n font-style: italic;\n}\n\n.cs-pen-layer-row.is-dragging {\n opacity: 0.4;\n}\n\n.cs-pen-layer-row.is-drop {\n border-color: #248567;\n box-shadow: 0 -2px 0 #248567 inset;\n}\n\n.cs-pen-layer-row__eye,\n.cs-pen-layer-row__act {\n flex: 0 0 auto;\n width: 20px;\n height: 20px;\n border: none;\n background: transparent;\n color: #cbd0e0;\n font-size: 12px;\n cursor: pointer;\n border-radius: 4px;\n line-height: 1;\n padding: 0;\n}\n\n.cs-pen-layer-row__eye:hover,\n.cs-pen-layer-row__act:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.cs-pen-layer-row__act:disabled {\n opacity: 0.3;\n cursor: default;\n}\n\n/* Keep rows compact: the action buttons (up/down/rename/dup/delete) only show\n on hover or for the active layer, so the name has room otherwise. */\n.cs-pen-layer-row__act {\n display: none;\n}\n\n.cs-pen-layer-row:hover .cs-pen-layer-row__act,\n.cs-pen-layer-row.is-active .cs-pen-layer-row__act {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n/* Checkerboard behind the thumbnail to show transparency, Photoshop-style. */\n.cs-pen-layer-row__thumb {\n flex: 0 0 auto;\n width: 34px;\n height: 34px;\n border-radius: 4px;\n border: 1px solid rgba(255, 255, 255, 0.18);\n overflow: hidden;\n background-color: #fff;\n background-image:\n linear-gradient(45deg, #cfd2dd 25%, transparent 25%),\n linear-gradient(-45deg, #cfd2dd 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, #cfd2dd 75%),\n linear-gradient(-45deg, transparent 75%, #cfd2dd 75%);\n background-size: 10px 10px;\n background-position: 0 0, 0 5px, 5px -5px, -5px 0;\n}\n\n.cs-pen-layer-thumb__svg {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.cs-pen-layer-row__name {\n flex: 1 1 auto;\n font-size: 12px;\n color: #e5e7f0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.cs-pen-layer-row__rename {\n flex: 1 1 auto;\n min-width: 0;\n font-size: 12px;\n padding: 2px 6px;\n border: 1px solid #5c5cff;\n border-radius: 4px;\n background: #0f1320;\n color: #fff;\n}\n\n/* When the side panel is active, hide the toolbar's duplicate copy of shape\n management + the compact chip strip (they live in the panel now). */\n.cs-pen-toolbar.cs-pen-has-panel .cs-pen-layers,\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"dup\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"del-shape\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"fwd\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"back\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen^=\"preset-\"] {\n display: none;\n}\n\n/* Props container: inline inside the floating toolbar by default … */\n.cs-pen-props,\n.cs-pen-props__group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n\n.cs-pen-props__label {\n display: none;\n}\n\n/* … but a stacked, labelled section when relocated to the right panel. */\n.cs-pen-props.cs-pen-props--panel {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 12px;\n padding: 12px;\n}\n\n.cs-pen-props--panel .cs-pen-props__group {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n}\n\n.cs-pen-props--panel .cs-pen-props__label {\n display: block;\n width: 100%;\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n color: #8aa4e6;\n}\n\n.cs-pen-props--panel select {\n height: 26px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n padding: 0 6px;\n cursor: pointer;\n}\n\n.cs-pen-props--panel button {\n min-width: 28px;\n height: 28px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: rgba(255, 255, 255, 0.05);\n color: #e5e7f0;\n font-size: 14px;\n cursor: pointer;\n}\n\n.cs-pen-props--panel button:hover {\n background: rgba(92, 92, 255, 0.22);\n border-color: #5c5cff;\n}\n\n.cs-pen-props--panel input[type=\"range\"] {\n flex: 1 1 80px;\n}\n\n/* Hide the toolbar separator that preceded the props block in panel mode. */\n.cs-pen-toolbar.cs-pen-has-props-panel {}\n\n/* ---------------------------------------------------------------------------\n In-canvas pen toolbar — a slim VERTICAL dock on the RIGHT of the block,\n trimmed to the essentials (Pen / Edit, Undo / Redo, solid Fill colour,\n layer order + delete + the shapes strip). The full toolbar still renders in\n the page-background modal, which carries .cs-pen-has-props-panel and is\n excluded by every :not() selector below.\n --------------------------------------------------------------------------- */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n justify-items: stretch;\n align-items: center;\n width: 180px;\n box-sizing: border-box;\n left: calc(100% + 8px);\n right: auto;\n top: 0;\n bottom: auto;\n transform: none;\n gap: 3px;\n}\n\n/* Separators, the props block and the layers strip span the full grid width so\n only the icon buttons flow 3-per-row — keeps the dock short instead of one\n long single column. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-sep,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-props,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-layers {\n grid-column: 1 / -1;\n}\n\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-sep {\n width: 100%;\n height: 1px;\n margin: 2px 0;\n}\n\n/* Drop every non-essential control. */\n/* .cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-sep,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"resize\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"snap\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"smooth\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"dup\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"clear\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"delete\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen^=\"preset-\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--transform,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--opacity,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--stroke {\n display: none !important;\n} */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-layers {\n display: none;\n}\n\n/* Fill group: keep ONLY the solid colour swatch (gradient / image fill live in\n the page-background modal's full toolbar). */\n/* .cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-props__label,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill [data-pen=\"fill-type\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-fill-gradient,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-fill-image {\n display: none !important;\n} */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-fill-solid {\n display: flex !important;\n justify-content: center;\n}\n\n/* Props block: stack its groups full-width so wide controls (gradient selects,\n colour stops, angle, blend, stroke) wrap INSIDE the dock instead of spilling\n out to the side. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 6px;\n width: 100%;\n box-sizing: border-box;\n}\n\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props__group,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-fill-solid,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-fill-gradient,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-fill-image,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-grad-stops {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 3px;\n width: 100%;\n box-sizing: border-box;\n}\n\n/* Every text/number/select control fills the row; ranges + swatches too. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props select,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props input[type=\"number\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props input[type=\"range\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-num {\n flex: 1 1 100%;\n width: 100%;\n min-width: 0;\n box-sizing: border-box;\n}\n\n/* Numeric fields keep their tiny icon label inline, input takes the rest. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-num input {\n flex: 1 1 auto;\n width: auto;\n min-width: 0;\n}\n\n/* Layers strip wraps to the column width instead of scrolling sideways. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-layers {\n flex-wrap: wrap;\n justify-content: center;\n max-width: none;\n width: 100%;\n overflow-x: visible;\n}\n\n/* Icon buttons fill their grid cell. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>button {\n width: 100%;\n min-width: 0;\n}\n\n/* Right-side shapes panel (preset library). */\n.cs-page-shape-shapes {\n flex: 0 0 250px;\n display: flex;\n flex-direction: column;\n background: #1b2030;\n color: #e5e7f0;\n border-left: 1px solid rgba(255, 255, 255, 0.08);\n overflow-y: auto;\n}\n\n.cs-page-shape-props {\n border-bottom: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n.cs-page-shape-shapes__title {\n padding: 12px 14px;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: #8aa4e6;\n border-bottom: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n/* Width / height for dropping a sized shape. */\n.cs-page-shape-size {\n display: flex;\n gap: 8px;\n padding: 12px 12px 0;\n}\n\n.cs-page-shape-size label {\n flex: 1 1 0;\n display: flex;\n align-items: center;\n gap: 4px;\n font-size: 11px;\n color: #8aa4e6;\n font-weight: 600;\n}\n\n.cs-page-shape-size input {\n width: 100%;\n min-width: 0;\n height: 26px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n padding: 0 6px;\n}\n\n.cs-page-shape-shapes__grid {\n padding: 12px;\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 8px;\n}\n\n.cs-page-shape-shapes__grid button {\n height: 42px;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 8px;\n background: rgba(255, 255, 255, 0.04);\n color: #e5e7f0;\n font-size: 20px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cs-page-shape-shapes__grid button:hover {\n background: rgba(92, 92, 255, 0.18);\n border-color: #5c5cff;\n}\n\n/* Custom in-page colour picker — replaces the native <input type=\"color\">\n popup, which clips at the screen edge when a swatch sits in the right-docked\n panel. Positioned in JS (fixed) and clamped inside the viewport. */\n.cs-pen-cpick {\n position: fixed;\n z-index: 2147483600;\n width: 208px;\n padding: 10px;\n box-sizing: border-box;\n background: #232a3d;\n border: 1px solid rgba(255, 255, 255, 0.16);\n border-radius: 10px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);\n user-select: none;\n -webkit-user-select: none;\n}\n\n.cs-pen-cpick__sv {\n position: relative;\n width: 100%;\n height: 130px;\n border-radius: 6px;\n cursor: crosshair;\n background:\n linear-gradient(to top, #000, rgba(0, 0, 0, 0)),\n linear-gradient(to right, #fff, var(--cs-cpick-hue, #f00));\n touch-action: none;\n}\n\n.cs-pen-cpick__svthumb,\n.cs-pen-cpick__huethumb {\n position: absolute;\n width: 12px;\n height: 12px;\n margin: -6px 0 0 -6px;\n border: 2px solid #fff;\n border-radius: 50%;\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);\n pointer-events: none;\n}\n\n.cs-pen-cpick__hue {\n position: relative;\n width: 100%;\n height: 14px;\n margin-top: 10px;\n border-radius: 7px;\n cursor: pointer;\n touch-action: none;\n background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);\n}\n\n.cs-pen-cpick__huethumb {\n top: 50%;\n width: 14px;\n height: 14px;\n margin: -7px 0 0 -7px;\n}\n\n.cs-pen-cpick__row {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 10px;\n}\n\n.cs-pen-cpick__hex {\n flex: 0 0 84px;\n height: 26px;\n border: 1px solid rgba(255, 255, 255, 0.16);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n text-transform: uppercase;\n padding: 0 6px;\n box-sizing: border-box;\n}\n\n.cs-pen-cpick__sw {\n flex: 1 1 auto;\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n}\n\n.cs-pen-cpick__sw button {\n width: 16px;\n height: 16px;\n padding: 0;\n border: 1px solid rgba(255, 255, 255, 0.2);\n border-radius: 4px;\n cursor: pointer;\n}\n\n/* Stage area centres the page-sized drawing block. */\n.cs-page-shape-stagewrap {\n flex: 1 1 auto;\n display: flex;\n /* `safe` keeps the stage's top-left reachable (scrollable) when it's zoomed\n larger than the viewport, instead of clipping the overflow. Plain `center`\n is the fallback for parsers without `safe`. */\n align-items: center;\n align-items: safe center;\n justify-content: center;\n justify-content: safe center;\n overflow: auto;\n padding: 24px;\n}\n\n/* Zoom control — pinned to the viewport bottom-centre (fixed ignores the\n stagewrap scroll). */\n.cs-page-shape-modal .cs-page-shape-zoom {\n position: fixed;\n bottom: 18px;\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 4px;\n background: #1b2030;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);\n z-index: 5;\n}\n\n.cs-page-shape-zoom button {\n min-width: 30px;\n height: 28px;\n padding: 0 8px;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #e5e7f0;\n font-size: 15px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-zoom button:hover {\n background: rgba(92, 92, 255, 0.22);\n}\n\n.cs-page-shape-zoom__val {\n font-size: 12px !important;\n min-width: 46px !important;\n}\n\n/* The drawing stage holds a page-aspect pen-shape block. */\n.cs-page-shape-stage {\n position: relative;\n background: #ffffff;\n box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);\n}\n\n.cs-page-shape-block {\n height: 100%;\n position: relative;\n z-index: 1;\n}\n\n/* Keep the designer's pen block transparent so the trace-reference image\n behind it stays visible while drawing. */\n.cs-page-shape-block,\n.cs-page-shape-block .cs-pen-shape,\n.cs-page-shape-block .cs-pen-svg {\n background: transparent !important;\n}\n\n/* Page designer ONLY: let the pen overlay extend a bleed margin PAST the page so\n anchors + handles can be placed and grabbed OFF-PAGE. The overlay is a\n transparent hit + marker layer; it's clipped to the scrolling stagewrap so it\n never overlaps the side panels, and the toolbar/zoom (position:fixed) stay on\n top. The rendered fill still clips to the page (off-page = bleed). Zoom out to\n see/reach the whole margin without scrolling. */\n.cs-page-shape-block .cs-pen-overlay {\n inset: -50%;\n}\n\n/* Trace-reference image: faint guide behind the pen block, clicks pass through. */\n.cs-page-shape-ref-img {\n position: absolute;\n inset: 0;\n background-repeat: no-repeat;\n background-position: center;\n background-size: 100% 100%;\n pointer-events: none;\n z-index: 0;\n display: none;\n}\n\n.cs-page-shape-ref-img.is-on {\n display: block;\n}\n\n/* Trace-reference controls (top of the right-hand shapes panel). */\n.cs-page-shape-ref {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 12px;\n}\n\n.cs-page-shape-ref__btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 8px 12px;\n border: 1px dashed rgba(255, 255, 255, 0.22);\n border-radius: 6px;\n background: rgba(255, 255, 255, 0.04);\n color: #e5e7f0;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-ref__btn:hover {\n border-color: #5c5cff;\n background: rgba(92, 92, 255, 0.16);\n}\n\n.cs-page-shape-ref__btn input {\n display: none;\n}\n\n.cs-page-shape-ref__op {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 11px;\n font-weight: 600;\n color: #8aa4e6;\n}\n\n.cs-page-shape-ref__op input {\n flex: 1 1 auto;\n min-width: 0;\n}\n\n.cs-page-shape-ref__clear {\n padding: 6px 10px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 6px;\n background: transparent;\n color: #cbd5e1;\n font-size: 12px;\n cursor: pointer;\n}\n\n.cs-page-shape-ref__clear:hover {\n border-color: #ef4444;\n color: #ef4444;\n}\n\n.cs-page-shape-ref__hint {\n margin: 0;\n font-size: 11px;\n line-height: 1.4;\n color: #94a3b8;\n}\n\n.cs-page-shape-ref__chk {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n font-size: 11px;\n line-height: 1.35;\n color: #cbd5e1;\n cursor: pointer;\n}\n\n.cs-page-shape-ref__chk input {\n margin-top: 1px;\n flex: 0 0 auto;\n}\n\n/* \"Outline only\" trace mode: show the traced clip-paths as outlines (no fill)\n while drawing, so the reference image underneath stays visible. View-only —\n the saved shape keeps its real fills (this class isn't in the captured SVG). */\n.cs-page-shape-stage.cs-trace-outline .cs-pen-fill {\n fill: none !important;\n stroke: #e11d48 !important;\n stroke-width: 1.6px !important;\n vector-effect: non-scaling-stroke;\n}\n\n/* Float the pen toolbar at the top of the modal instead of above the block\n (the block fills the stage, so the default `bottom: 100%` would clip it). */\n.cs-page-shape-modal .cs-pen-toolbar {\n position: fixed;\n top: 20px;\n bottom: auto;\n left: 50%;\n transform: translateX(-50%);\n flex-wrap: wrap;\n max-width: 92vw;\n justify-content: center;\n}\n\n/* ===========================================================================\n CustomRichEditor — inline rich-text toolbar (replaces the commercial Froala\n toolbar). Floats above the current selection while a text block is editing.\n =========================================================================== */\n.cre-toolbar {\n position: fixed;\n z-index: 9500;\n display: none;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px;\n max-width: 96vw;\n padding: 5px 6px;\n background: #1f2533;\n border: 1px solid rgba(255, 255, 255, 0.10);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n font-family: Inter, \"Segoe UI\", sans-serif;\n user-select: none;\n}\n\n.cre-toolbar.is-visible {\n display: flex;\n /* animation: slideUpBadge 0.16s ease forwards; */\n}\n\n/* Docked mode — pinned to the top of the canvas viewport as a full-width sticky\n strip (Page Settings → \"Inline text toolbar\" OFF). Overrides the floating\n inline placement; top/left are forced so any leftover inline coords lose. */\n.cre-toolbar--docked {\n /* The editor runs in an iframe that GROWS to fit every page, and the HOST\n scrolls it — so position:fixed would pin to the iframe's content-top and\n scroll out of view for blocks lower down. We use position:absolute and JS\n moves `top` to the current visible viewport top each frame (see\n CustomRichEditor.trackDockedBar). top:0 is the safe fallback. */\n position: absolute;\n top: 0 !important;\n left: 0 !important;\n right: 0 !important;\n margin: auto;\n width: 92%;\n max-width: 100%;\n justify-content: center;\n border-radius: 0;\n border-left: none;\n border-right: none;\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);\n animation: none;\n}\n\n/* Idle placeholder — the always-present docked bar shown when docked mode is ON\n and no block is being edited. Looks like the real bar but is non-interactive\n (\"select a block to use it\"). The functional bar replaces it on edit. */\n.cre-toolbar--placeholder {\n pointer-events: none;\n opacity: 0.55;\n cursor: default;\n}\n\n.cre-group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding-right: 5px;\n margin-right: 1px;\n border-right: 1px solid rgba(255, 255, 255, 0.10);\n}\n\n.cre-group:last-child {\n border-right: none;\n padding-right: 0;\n margin-right: 0;\n}\n\n.cre-toolbar button {\n min-width: 26px;\n height: 26px;\n padding: 0 5px;\n border: none;\n border-radius: 5px;\n background: transparent;\n color: #e5e7f0;\n font-size: 13px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cre-toolbar button:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.cre-toolbar button.is-active {\n background: #248567;\n color: #fff;\n}\n\n.cre-toolbar select {\n height: 26px;\n max-width: 96px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n padding: 0 4px;\n cursor: pointer;\n}\n\n.cre-color {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n border-radius: 5px;\n color: #e5e7f0;\n font-size: 13px;\n font-weight: 700;\n cursor: pointer;\n overflow: hidden;\n}\n\n.cre-color:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n/* The native colour input fills the swatch but stays invisible — the glyph\n (A / ▣) is the visible affordance. */\n.cre-color input[type=\"color\"] {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n opacity: 0;\n border: none;\n padding: 0;\n cursor: pointer;\n}\n\n/* Centre glyphs in toolbar buttons so the SVG icons (alignment + the table\n block's appended structure icons) sit crisply centred. */\n.cre-toolbar button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n.cre-toolbar button svg,\n.cre-color svg {\n display: block;\n pointer-events: none;\n}\n\n/* Lists made inside a text block: visible markers + sensible indent. The marker\n tracks the <li> font-size, which CustomRichEditor syncs to the content size\n so \"1.\" / \"•\" match large text instead of staying tiny. */\n.edit_me ol,\n.edit_me ul {\n margin: 0;\n padding-left: 1.4em;\n}\n\n.edit_me ol {\n list-style: decimal outside;\n}\n\n.edit_me ul {\n list-style: disc outside;\n}\n\n.edit_me li {\n line-height: 1.4;\n}\n\n/* Font-size dropdown in the toolbar. */\n.cre-toolbar .cre-size {\n width: 64px;\n min-width: 64px;\n}\n\n/* Headings (H1–H6) created in a text block get a clear size hierarchy. An\n explicit font-size the user applies on a run still wins over these. */\n.edit_me h1 {\n font-size: 2em;\n}\n\n.edit_me h2 {\n font-size: 1.6em;\n}\n\n.edit_me h3 {\n font-size: 1.35em;\n}\n\n.edit_me h4 {\n font-size: 1.15em;\n}\n\n.edit_me h5 {\n font-size: 1em;\n}\n\n.edit_me h6 {\n font-size: 0.85em;\n}\n\n.edit_me h1,\n.edit_me h2,\n.edit_me h3,\n.edit_me h4,\n.edit_me h5,\n.edit_me h6 {\n font-weight: 700;\n margin: 0;\n line-height: 1.2;\n}\n\n/* ===========================================================================\n Static Table block (Canva-style)\n =========================================================================== */\n.cs-table-block {\n width: 100%;\n}\n\n.cs-table {\n width: 100%;\n border-collapse: collapse;\n table-layout: fixed;\n font-size: 14px;\n color: #333;\n}\n\n/* Target every cell, not just `.cs-cell`: in legacy Froala mode Froala's own\n \"insert column/row\" creates bare <td>s without our class, and they must still\n show a border. The normalizer re-stamps `.cs-cell` afterwards, but this keeps\n the border visible regardless. */\n.cs-table .cs-cell,\n.cs-table td,\n.cs-table th {\n border: 1px solid #d0d5e2;\n padding: 8px 10px;\n vertical-align: top;\n min-width: 24px;\n /* On a <td>, `height` is a MINIMUM — keeps empty cells from collapsing to a\n thin strip while still growing with content. */\n height: 36px;\n word-break: break-word;\n overflow-wrap: break-word;\n}\n\n.cs-table .cs-cell--head {\n background: #f3f5fb;\n font-weight: 700;\n color: #1f2937;\n}\n\n/* Editing affordances. */\n.cs-table-block.cs-editing .cs-cell {\n outline: none;\n}\n\n/* The cell holding the caret gets a clear highlight. */\n.cs-table .cs-cell:focus {\n box-shadow: inset 0 0 0 1px #248567;\n background: rgba(92, 92, 255, 0.06);\n}\n\n/* Canva-style cell selection: a soft tint on each cell + one crisp rectangle\n (.cs-tbl-selrect) drawn around the whole selection. */\n.cs-table .cs-cell--selected {\n background: rgba(92, 92, 255, 0.18);\n}\n\n.cs-tbl-selrect {\n position: fixed;\n z-index: 9450;\n display: none;\n pointer-events: none;\n border: 1px solid #248567;\n border-radius: 2px;\n box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);\n}\n\n/* Red delete-preview (shown while hovering a Delete menu item). */\n.cs-table .cs-cell--danger {\n box-shadow: inset 0 0 0 1px #e5484d;\n background: rgba(229, 72, 77, 0.16) !important;\n}\n\n/* Right-click context menu. */\n.cs-tbl-menu {\n position: fixed;\n z-index: 9700;\n min-width: 190px;\n padding: 5px;\n background: #fff;\n border: 1px solid #e5e7eb;\n border-radius: 10px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);\n font-family: Inter, \"Segoe UI\", sans-serif;\n user-select: none;\n}\n\n.cs-tbl-menu__item {\n display: flex;\n align-items: center;\n width: 100%;\n padding: 8px 12px;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #1f2937;\n font-size: 13px;\n text-align: left;\n cursor: pointer;\n white-space: nowrap;\n}\n\n.cs-tbl-menu__item:hover {\n background: #eef2ff;\n}\n\n.cs-tbl-menu__item--danger {\n color: #e5484d;\n}\n\n.cs-tbl-menu__item--danger:hover {\n background: #fdecec;\n}\n\n.cs-tbl-menu__sep {\n height: 1px;\n margin: 4px 6px;\n background: #eceef3;\n}\n\n/* Floating table toolbar (lives in <body>, never exported). */\n.cs-tbl-toolbar {\n position: fixed;\n z-index: 9600;\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px;\n max-width: 96vw;\n padding: 5px 6px;\n background: #1f2533;\n border: 1px solid rgba(255, 255, 255, 0.10);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n font-family: Inter, \"Segoe UI\", sans-serif;\n user-select: none;\n animation: slideUpBadge 0.16s ease forwards;\n}\n\n.cs-tbl-group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding-right: 5px;\n margin-right: 1px;\n border-right: 1px solid rgba(255, 255, 255, 0.10);\n}\n\n.cs-tbl-group:last-child {\n border-right: none;\n padding-right: 0;\n margin-right: 0;\n}\n\n.cs-tbl-toolbar button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 28px;\n height: 26px;\n padding: 0 6px;\n border: none;\n border-radius: 5px;\n background: transparent;\n color: #e5e7f0;\n font-size: 12px;\n line-height: 1;\n cursor: pointer;\n}\n\n/* Crisp SVG glyphs that follow the button's text colour. */\n.cs-tbl-toolbar button svg,\n.cs-tbl-color svg {\n display: block;\n pointer-events: none;\n}\n\n.cs-tbl-toolbar button:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n/* Font family / size / line-height / letter-spacing dropdowns. */\n.cs-tbl-toolbar select {\n height: 26px;\n max-width: 112px;\n padding: 0 4px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3142;\n color: #e5e7f0;\n font-size: 12px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cs-tbl-toolbar select:hover {\n border-color: rgba(255, 255, 255, 0.30);\n}\n\n.cs-tbl-toolbar select:focus {\n outline: none;\n border-color: #5b8cff;\n}\n\n.cs-tbl-color {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n border-radius: 5px;\n color: #e5e7f0;\n font-size: 13px;\n cursor: pointer;\n overflow: hidden;\n}\n\n.cs-tbl-color:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.cs-tbl-color input[type=\"color\"] {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n opacity: 0;\n border: none;\n padding: 0;\n cursor: pointer;\n}\n\n/* ===================== List block (synchronised columns) ================== */\n\n/* Three selectable tiers: the List (outer), each Column (\"Container\"), and the\n blocks inside. Each tier gets a little padding so there's a clickable band to\n select it without hitting the tier below.\n Neutralise the leaked `.cs_block_s` base (width:250px / border / max-content)\n so the List always spans its column full-width. */\n.cs-synclist-block {\n width: 100% !important;\n max-width: 100% !important;\n height: auto !important;\n /* border: none !important; */\n padding: 10px !important;\n box-sizing: border-box;\n}\n\n/* Wix-repeater-style wrapping row, optimised for SMOOTH px resize. Before any\n resize columns split the row equally (flex: 1 1 0). Once a column is resized\n they all take that exact px width (--col-item-w) via .cs-synclist--sized — so\n the drag is 1:1/smooth — pack from the left with a consistent gap, and wrap\n to the next line when they no longer fit (partial last row stays left-\n aligned). Row height is uniform via --col-item-h. */\n.cs-synclist {\n display: flex;\n flex-wrap: wrap;\n align-items: stretch;\n align-content: flex-start;\n justify-content: space-between;\n gap: 5px;\n width: 100%;\n box-sizing: border-box;\n}\n\n/* A column is a flex item AND a free canvas (position:relative) — its blocks\n are absolutely positioned and drag/resize freely, bounded to it. */\n.cs-synclist__col {\n /* Default: split the row equally. */\n flex: 1 1 0;\n min-width: 0;\n /* Floor the height (the leaked .cs_block_s height:max-content would collapse\n the column to ~0 since its blocks are absolutely positioned).\n --col-item-h is inherited and bumped on height-resize. */\n min-height: var(--col-item-h, 240px);\n /* Override the leaked width:250px / height:max-content. */\n width: auto;\n height: auto;\n position: relative;\n display: block;\n padding: 0;\n border-left: 1px solid #e6e8f2;\n box-sizing: border-box;\n}\n\n/* After a resize: every column is the SAME exact px width/height (smooth, 1:1\n with the drag); flex-wrap pushes them to the next line when the row is full. */\n.cs-synclist--sized .cs-synclist__col {\n flex: 0 0 var(--col-item-w, 200px);\n width: var(--col-item-w, 200px);\n height: var(--col-item-h, 240px);\n}\n\n\n/* Free-floating content block — keep its own size, just cap width to the\n column so it can't spill out sideways. */\n.cs-synclist__col>.cs_block_s {\n max-width: 100%;\n margin: 0 !important;\n}\n\n.cs-synclist__col img {\n max-width: 100%;\n height: auto;\n}\n\n/* Light column guides on hover — editing aid, harmless in export. */\n.cs-synclist-block:hover .cs-synclist__col {\n box-shadow: inset 0 0 0 1px #eef0f6;\n}\n\n/* The Container resizes with the right (e), bottom (s) and bottom-right (se)\n handles — drag them to size the column (width sticks via the flex-basis\n mirror in synclist.js; height stretches every column via the flex row).\n The other handles are hidden. Content blocks keep all 8 handles. */\n.cs-synclist__col>.cs-resize-handle {\n display: none !important;\n}\n\n.cs-synclist__col>.cs-resize-handle[data-dir=\"e\"],\n.cs-synclist__col>.cs-resize-handle[data-dir=\"s\"],\n.cs-synclist__col>.cs-resize-handle[data-dir=\"se\"] {\n display: block !important;\n}\n\n.cs-synclist__col.cs-editing,\n.cs-synclist__col.cs-selected {\n border: 1px solid #248567;\n}\n\n/* Drop highlight while dragging a sidebar block into a Container. */\n.cs-synclist__col--dropping {\n box-shadow: inset 0 0 0 2px #5c5cff !important;\n background: rgba(92, 92, 255, 0.06);\n}\n\n/* ===========================================================================\n Aiden — AI writing assistant block (chrome; stripped from exports)\n =========================================================================== */\n.cs-aiden-block .cs-aiden-text {\n min-height: 1.5em;\n outline: none;\n}\n\n/* While the AI flow is open, the block becomes a single row: the editable text\n on the left and the action bar docked on the right — all INSIDE the input\n box. */\n/* High specificity (+ [data-block-type]) so it beats the cover-page rule\n `.cs-cover-canvas > .cs_block_s[data-cs-in-section=\"1\"] { display:block !important }`\n — otherwise the editable + bar stack onto two lines instead of one row. */\n.cs_block_s.cs-aiden-block.cs-aiden--active[data-block-type=\"aiden\"] {\n display: flex !important;\n flex-direction: row !important;\n flex-wrap: nowrap !important;\n align-items: center;\n gap: 10px;\n box-shadow: 0 0 0 1.5px #8b5cf6, 0 8px 24px rgba(124, 58, 237, 0.14);\n border-radius: 4px;\n}\n\n.cs_block_s.cs-aiden-block.cs-aiden--active .cs-aiden-text {\n flex: 1 1 auto;\n min-width: 0;\n}\n\n/* When a multi-line result is showing, dock the buttons to the top-right\n instead of vertically centring them against tall text. */\n.cs_block_s.cs-aiden-block.cs-aiden--active[data-aiden-phase=\"result\"] {\n align-items: flex-start;\n}\n\n/* Action bar docked inside the block, right-aligned. */\n.cs-aiden-bar {\n position: relative;\n flex: 0 0 auto;\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 13px;\n}\n\n.cs-aiden-bar__sp {\n display: none;\n}\n\n.cs-aiden-btn {\n border: none;\n border-radius: 7px;\n padding: 7px 14px;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n white-space: nowrap;\n line-height: 1;\n background: transparent;\n transition: background 120ms ease, opacity 120ms ease, color 120ms ease;\n}\n\n.cs-aiden-btn--primary {\n color: #fff;\n background: linear-gradient(90deg, #7c3aed, #d9772b);\n}\n\n.cs-aiden-btn--primary:hover {\n opacity: 0.92;\n}\n\n.cs-aiden-btn--ghost {\n color: #2563eb;\n}\n\n.cs-aiden-btn--ghost:hover {\n background: #eef2ff;\n}\n\n.cs-aiden-btn--stop {\n color: #fff;\n background: #6b7280;\n}\n\n.cs-aiden-btn--stop:hover {\n background: #4b5563;\n}\n\n.cs-aiden-btn--link {\n color: #2563eb;\n padding: 7px 8px;\n}\n\n.cs-aiden-btn--link:hover {\n background: #eef2ff;\n}\n\n.cs-aiden-status {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n color: #c026a8;\n font-weight: 600;\n}\n\n.cs-aiden-spin {\n width: 14px;\n height: 14px;\n border: 2px solid rgba(192, 38, 168, 0.25);\n border-top-color: #c026a8;\n border-radius: 50%;\n animation: cs-aiden-spin 0.7s linear infinite;\n}\n\n@keyframes cs-aiden-spin {\n to {\n transform: rotate(360deg);\n }\n}\n\n.cs-aiden-flash {\n color: #dc2626;\n font-weight: 600;\n}\n\n/* Adjust-tone popup — anchored above the action bar. */\n.cs-aiden-pop {\n position: absolute;\n right: 0;\n bottom: calc(100% + 10px);\n z-index: 9997;\n display: none;\n flex-direction: column;\n gap: 10px;\n width: 250px;\n padding: 14px;\n background: #ffffff;\n border: 1px solid #ececf3;\n border-radius: 10px;\n box-shadow: 0 16px 36px rgba(20, 24, 60, 0.18);\n font-size: 13px;\n text-align: left;\n}\n\n.cs-aiden-pop.is-open {\n display: flex;\n}\n\n.cs-aiden-pop__title {\n font-weight: 700;\n color: #1f2937;\n}\n\n.cs-aiden-pop__row {\n display: flex;\n align-items: center;\n gap: 8px;\n color: #374151;\n cursor: pointer;\n}\n\n.cs-aiden-pop__foot {\n display: flex;\n justify-content: flex-end;\n}\n</style>\n</head>\n\n<body>\n\n\n\n <div id=\"place_everything\" class=\"notranslate\">\n <div class=\"page_container\">\n <!--\n Canvas mount point. The outer .cs_paper is host-owned (rendered\n by this HTML). flow-canvas.js will inject .cs_margin pages directly\n into this .cs_paper — it must NOT create a second one inside the\n canvas.\n -->\n <div dnddropzone=\"\" class=\"cs_paper\">\n <div class=\"cs_page custom-form-design centercontent\" style=\"visibility: visible;\">\n </div>\n </div>\n </div>\n </div>\n\n <script src=\"https://code.jquery.com/jquery-3.7.1.min.js\"><\/script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/froala-editor/4.3.1/js/froala_editor.pkgd.min.js\"><\/script>\n <script data-src=\"./js/font-config.js\">\n/**\n * Font family configuration for Froala editor\n * Includes Google Fonts and system fonts with CDN links\n */\n(function () {\n // Google Fonts imports - add to <head> dynamically\n const GOOGLE_FONTS = [\n 'https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Raleway:wght@300;400;500;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap',\n ];\n\n // Font family definitions - mapped as CSS value => Display name\n // (Froala uses keys as dropdown display text, values as CSS values)\n const FONT_FAMILIES = {\n // System fonts\n 'Arial': 'Arial',\n 'Helvetica': 'Helvetica',\n 'Times New Roman': 'Times New Roman',\n 'Courier New': 'Courier New',\n 'Georgia': 'Georgia',\n 'Verdana': 'Verdana',\n\n // Google Fonts - Modern/Sans-serif\n \"'Roboto', sans-serif\": 'Roboto',\n \"'Poppins', sans-serif\": 'Poppins',\n \"'Sora', sans-serif\": 'Sora',\n \"'Open Sans', sans-serif\": 'Open Sans',\n \"'Lato', sans-serif\": 'Lato',\n \"'Montserrat', sans-serif\": 'Montserrat',\n \"'Raleway', sans-serif\": 'Raleway',\n \"'Inter', sans-serif\": 'Inter',\n \"'Nunito', sans-serif\": 'Nunito',\n \"'Source Sans Pro', sans-serif\": 'Source Sans Pro',\n\n // Google Fonts - Serif/Display\n \"'Playfair Display', serif\": 'Playfair Display',\n };\n\n // Load Google Fonts into the document\n function loadGoogleFonts() {\n GOOGLE_FONTS.forEach(fontUrl => {\n const link = document.createElement('link');\n link.rel = 'stylesheet';\n link.href = fontUrl;\n link.data_font_config = 'true'; // Mark as auto-loaded\n document.head.appendChild(link);\n });\n }\n\n // Initialize fonts when DOM is ready\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', loadGoogleFonts);\n } else {\n loadGoogleFonts();\n }\n\n // Expose for Froala configuration\n window.FROALA_FONTS = FONT_FAMILIES;\n})();\n\n<\/script>\n <script data-src=\"./editor/rich-text-editor.js\">\n/**\n * @fileoverview CustomRichEditor — a dependency-free inline rich-text editor.\n *\n * Built to REPLACE the commercial Froala editor for in-canvas text blocks\n * (Title / Heading / Body, etc.) so the project carries no third-party editor\n * licence. It is a DROP-IN for the Froala instance used by inline-editor.js:\n *\n * const ed = new CustomRichEditor(target, opts);\n * ed.commands.exec('bold'); // ← same call shape froala-style-handler uses\n * ed.commands.exec('textColor', ['#f00']);\n * ed.destroy();\n *\n * It edits the element in place (contenteditable) — exactly like Froala did —\n * so the rest of the app (HTML export, style panel, save/load) needs no change.\n *\n * Toolbar (inline, floats above the selection):\n * bold · italic · underline · strikethrough · sub · super\n * heading (inline) · font family · font size · line height\n * letter spacing · text case (UPPER / Capitalize / lower / as typed)\n * text colour · highlight colour\n * align L/C/R/justify\n * ordered / unordered list · outdent / indent\n * link · unlink · clear formatting\n * undo · redo\n *\n * Exposes: window.CustomRichEditor\n */\n(function () {\n 'use strict';\n\n const DEFAULT_SIZES = ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '88', '96'];\n const DEFAULT_FONTS = {\n 'Arial': 'Arial',\n \"'Roboto', sans-serif\": 'Roboto',\n \"'Poppins', sans-serif\": 'Poppins',\n \"'Open Sans', sans-serif\": 'Open Sans',\n \"'Lato', sans-serif\": 'Lato',\n \"'Montserrat', sans-serif\": 'Montserrat',\n \"'Inter', sans-serif\": 'Inter',\n \"'Playfair Display', serif\": 'Playfair Display',\n 'Georgia, serif': 'Georgia',\n \"'Courier New', monospace\": 'Courier New',\n };\n\n // Heading levels map to inline font-size + weight (NOT block <h1> tags) so a\n // heading styles only the SELECTED run — e.g. make \"balan\" H1 without turning\n // the rest of the line into a heading. Shared by apply + toolbar sync.\n const HEADING_SPEC = {\n h1: { fontSize: '32px', fontWeight: '700' },\n h2: { fontSize: '24px', fontWeight: '700' },\n h3: { fontSize: '19px', fontWeight: '700' },\n h4: { fontSize: '16px', fontWeight: '700' },\n h5: { fontSize: '13px', fontWeight: '700' },\n h6: { fontSize: '11px', fontWeight: '700' },\n };\n\n // Proper text-alignment icons (rows of lines, like a word processor) drawn as\n // inline SVG so they read clearly instead of the ambiguous arrow glyphs.\n const alignSvg = (rows) => {\n // rows: array of [x, width] for each of the 4 lines (viewBox 16 wide).\n const bars = rows.map((r, i) =>\n `<rect x=\"${r[0]}\" y=\"${2 + i * 4}\" width=\"${r[1]}\" height=\"2\" rx=\"1\"/>`).join('');\n return `<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\">${bars}</svg>`;\n };\n\n // SVG/text glyphs for toolbar buttons (no icon font needed).\n const ICON = {\n bold: 'B', italic: 'I', underline: 'U', strike: 'S',\n sub: 'x₂', sup: 'x²',\n alignLeft: alignSvg([[1, 14], [1, 8], [1, 14], [1, 8]]),\n alignCenter: alignSvg([[1, 14], [4, 8], [1, 14], [4, 8]]),\n alignRight: alignSvg([[1, 14], [7, 8], [1, 14], [7, 8]]),\n alignJustify: alignSvg([[1, 14], [1, 14], [1, 14], [1, 14]]),\n ol: '1.', ul: '•', outdent: '⇤', indent: '⇥',\n link: '🔗', unlink: '⛓', clear: '⌫', undo: '↶', redo: '↷',\n };\n\n let uid = 0;\n\n // The toolbar markup is shared by the live (functional) editor bar and the\n // docked placeholder bar, so it's built from one template.\n function toolbarInnerHTML(fonts, sizes) {\n const fontOpts = Object.entries(fonts || DEFAULT_FONTS)\n .map(([val, label]) => `<option value=\"${val.replace(/\"/g, '&quot;')}\">${label}</option>`).join('');\n const sizeOpts = (sizes || DEFAULT_SIZES).map((s) => `<option value=\"${s}\">${s}</option>`).join('');\n return `\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"bold\" title=\"Bold\" style=\"font-weight:700\">${ICON.bold}</button>\n <button type=\"button\" data-cmd=\"italic\" title=\"Italic\" style=\"font-style:italic\">${ICON.italic}</button>\n <button type=\"button\" data-cmd=\"underline\" title=\"Underline\" style=\"text-decoration:underline\">${ICON.underline}</button>\n <button type=\"button\" data-cmd=\"strikeThrough\" title=\"Strikethrough\" style=\"text-decoration:line-through\">${ICON.strike}</button>\n <button type=\"button\" data-cmd=\"subscript\" title=\"Subscript\">${ICON.sub}</button>\n <button type=\"button\" data-cmd=\"superscript\" title=\"Superscript\">${ICON.sup}</button>\n </div>\n <div class=\"cre-group\">\n <select data-sel=\"format\" title=\"Paragraph / heading\">\n <option value=\"\">Normal</option>\n <option value=\"h1\">H1</option>\n <option value=\"h2\">H2</option>\n <option value=\"h3\">H3</option>\n <option value=\"h4\">H4</option>\n <option value=\"h5\">H5</option>\n <option value=\"h6\">H6</option>\n </select>\n <select data-sel=\"font\" title=\"Font family\"><option value=\"\">Font</option>${fontOpts}</select>\n <select data-sel=\"size\" class=\"cre-size\" title=\"Font size\"><option value=\"\">Size</option>${sizeOpts}</select>\n <select data-sel=\"lineheight\" title=\"Line height\">\n <option value=\"\">↕ LH</option>\n <option value=\"1\">1.0</option>\n <option value=\"1.15\">1.15</option>\n <option value=\"1.3\">1.3</option>\n <option value=\"1.5\">1.5</option>\n <option value=\"2\">2.0</option>\n <option value=\"2.5\">2.5</option>\n <option value=\"3\">3.0</option>\n </select>\n <select data-sel=\"letterspacing\" title=\"Letter spacing\">\n <option value=\"\">⇿ LS</option>\n <option value=\"normal\">Normal</option>\n <option value=\"0.5px\">0.5</option>\n <option value=\"1px\">1</option>\n <option value=\"2px\">2</option>\n <option value=\"3px\">3</option>\n <option value=\"4px\">4</option>\n <option value=\"6px\">6</option>\n <option value=\"8px\">8</option>\n </select>\n <select data-sel=\"textcase\" title=\"Text case\">\n <option value=\"\">Aa Case</option>\n <option value=\"none\">As typed</option>\n <option value=\"uppercase\">UPPERCASE</option>\n <option value=\"capitalize\">Capitalize Each</option>\n <option value=\"lowercase\">lowercase</option>\n </select>\n </div>\n <div class=\"cre-group\">\n <label class=\"cre-color\" title=\"Text colour\">A<input type=\"color\" data-color=\"fore\" value=\"#000000\"></label>\n <label class=\"cre-color cre-color--bg\" title=\"Highlight colour\">▣<input type=\"color\" data-color=\"back\" value=\"#ffff00\"></label>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"justifyLeft\" title=\"Align left\">${ICON.alignLeft}</button>\n <button type=\"button\" data-cmd=\"justifyCenter\" title=\"Align center\">${ICON.alignCenter}</button>\n <button type=\"button\" data-cmd=\"justifyRight\" title=\"Align right\">${ICON.alignRight}</button>\n <button type=\"button\" data-cmd=\"justifyFull\" title=\"Justify\">${ICON.alignJustify}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"insertOrderedList\" title=\"Numbered list\">${ICON.ol}</button>\n <button type=\"button\" data-cmd=\"insertUnorderedList\" title=\"Bullet list\">${ICON.ul}</button>\n <button type=\"button\" data-cmd=\"outdent\" title=\"Decrease indent\">${ICON.outdent}</button>\n <button type=\"button\" data-cmd=\"indent\" title=\"Increase indent\">${ICON.indent}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-act=\"link\" title=\"Insert / edit link\">${ICON.link}</button>\n <button type=\"button\" data-cmd=\"unlink\" title=\"Remove link\">${ICON.unlink}</button>\n <button type=\"button\" data-cmd=\"removeFormat\" title=\"Clear formatting\">${ICON.clear}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"undo\" title=\"Undo\">${ICON.undo}</button>\n <button type=\"button\" data-cmd=\"redo\" title=\"Redo\">${ICON.redo}</button>\n </div>`;\n }\n\n // All currently-alive editor instances (normally 0 or 1). Used to decide when\n // the docked placeholder bar should show (only when nothing is being edited).\n const liveEditors = new Set();\n // Set true by non-rich editors (e.g. the table block) that show their OWN\n // docked bar, so the placeholder hides and two bars never stack at the top.\n let externalDockedActive = false;\n\n // Persistent docked toolbar: a non-interactive copy of the bar that stays\n // pinned to the top of the canvas whenever docked mode is ON and no block is\n // being edited. As soon as a text block is edited, the real (functional) bar\n // takes its place; on teardown the placeholder returns. So in docked mode a\n // bar is ALWAYS visible — never hidden.\n const DockedPlaceholder = {\n el: null,\n ensure(doc) {\n if (this.el && this.el.ownerDocument === doc && doc.body.contains(this.el)) return this.el;\n const tb = doc.createElement('div');\n tb.className = 'cre-toolbar cre-toolbar--docked cre-toolbar--placeholder';\n tb.setAttribute('data-cs-chrome', '');\n tb.setAttribute('aria-hidden', 'true');\n tb.innerHTML = toolbarInnerHTML(DEFAULT_FONTS, DEFAULT_SIZES);\n // Inert: swallow any interaction so it can't steal focus / fire commands.\n tb.addEventListener('mousedown', (e) => e.preventDefault());\n doc.body.appendChild(tb);\n this.el = tb;\n return tb;\n },\n sync(doc) {\n doc = doc || document;\n const win = doc.defaultView || window;\n const docked = !!(typeof win.isRichToolbarDocked === 'function' && win.isRichToolbarDocked());\n if (!docked) { if (this.el) this.el.classList.remove('is-visible'); return; }\n this.ensure(doc);\n // Show the placeholder only while no real editor bar is up — and not while\n // another editor (e.g. the table block) is showing its own docked bar.\n const show = liveEditors.size === 0 && !externalDockedActive;\n this.el.classList.toggle('is-visible', show);\n // Follow the host scroll like any docked bar (or stop when hidden).\n if (show) CustomRichEditor.trackDockedBar(this.el);\n else CustomRichEditor.untrackDockedBar(this.el);\n }\n };\n\n // Global hook: when the Page Settings toggle flips docked mode (and on first\n // load), update the placeholder even if no editor instance is alive.\n document.addEventListener('canvas:rich-toolbar-mode', () => DockedPlaceholder.sync(document));\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => DockedPlaceholder.sync(document));\n } else {\n DockedPlaceholder.sync(document);\n }\n\n class CustomRichEditor {\n constructor(target, opts = {}) {\n this.target = target;\n this.doc = target.ownerDocument || document;\n this.win = this.doc.defaultView || window;\n this.opts = opts;\n this.fonts = opts.fonts || DEFAULT_FONTS;\n this.sizes = opts.fontSizes || DEFAULT_SIZES;\n this.id = ++uid;\n this.lastRange = null;\n this.destroyed = false;\n\n // Froala-compatible no-op surfaces (inline-editor calls these defensively).\n this.popups = { hideAll: () => this._hideToolbar() };\n this.toolbar = { hide: () => this._hideToolbar() };\n // The command surface the style panel / froala-style-handler talk to.\n this.commands = { exec: (name, args) => this._exec(name, args) };\n\n this._init();\n }\n\n /* ------------------------------- lifecycle ------------------------------ */\n _init() {\n const t = this.target;\n // Anchor the toolbar to the BLOCK (stable) rather than the live caret —\n // otherwise it jumps around as the selection/text moves while typing.\n this.anchor = t.closest('.cs_block_s') || t;\n t.setAttribute('contenteditable', 'true');\n t.setAttribute('spellcheck', 'false');\n t.classList.add('cre-editable');\n\n this._buildToolbar();\n\n // Bound handlers (so destroy can remove the exact same refs).\n this._onSelChange = () => this._syncFromSelection();\n this._onFocus = () => this._showToolbar();\n this._onBlur = () => this._maybeHideToolbar();\n this._onReflow = () => { if (this._toolbarVisible) this._positionToolbar(); };\n // Page Settings → \"Inline text toolbar\" toggle flips inline ↔ docked while\n // a block is open; re-place the live bar and refresh the placeholder.\n this._onDockMode = () => {\n if (this._toolbarVisible) this._positionToolbar();\n DockedPlaceholder.sync(this.doc);\n };\n this._onKey = (e) => this._onKeydown(e);\n // Keep the block hugging its content so new lines (Enter) expand the box\n // rather than overflowing a fixed height.\n this._onInputGrow = () => {\n this.anchor.style.height = 'auto';\n t.style.height = 'auto';\n if (this._toolbarVisible) this._positionToolbar();\n };\n\n this.doc.addEventListener('selectionchange', this._onSelChange);\n t.addEventListener('focus', this._onFocus);\n t.addEventListener('blur', this._onBlur);\n t.addEventListener('keydown', this._onKey);\n t.addEventListener('input', this._onInputGrow);\n this._onInputGrow();\n this.win.addEventListener('scroll', this._onReflow, true);\n this.win.addEventListener('resize', this._onReflow);\n this.doc.addEventListener('canvas:rich-toolbar-mode', this._onDockMode);\n\n // Editing has begun: register this instance and hide the docked\n // placeholder (the real, functional bar takes over).\n liveEditors.add(this);\n DockedPlaceholder.sync(this.doc);\n\n // Match Froala's behaviour: focus immediately on init.\n try { t.focus(); } catch (e) { /* */ }\n this._showToolbar();\n }\n\n destroy() {\n if (this.destroyed) return;\n this.destroyed = true;\n const t = this.target;\n this.doc.removeEventListener('selectionchange', this._onSelChange);\n t.removeEventListener('focus', this._onFocus);\n t.removeEventListener('blur', this._onBlur);\n t.removeEventListener('keydown', this._onKey);\n t.removeEventListener('input', this._onInputGrow);\n this.win.removeEventListener('scroll', this._onReflow, true);\n this.win.removeEventListener('resize', this._onReflow);\n this.doc.removeEventListener('canvas:rich-toolbar-mode', this._onDockMode);\n t.removeAttribute('contenteditable');\n t.removeAttribute('spellcheck');\n t.classList.remove('cre-editable');\n if (this._toolbar) { CustomRichEditor.untrackDockedBar(this._toolbar); this._toolbar.remove(); }\n this._toolbar = null;\n // Editing finished: bring the docked placeholder back so a bar stays\n // visible at the top in docked mode.\n liveEditors.delete(this);\n DockedPlaceholder.sync(this.doc);\n }\n\n /* ------------------------------- toolbar -------------------------------- */\n _buildToolbar() {\n const tb = this.doc.createElement('div');\n tb.className = 'cre-toolbar';\n tb.setAttribute('data-cs-chrome', ''); // never exported / never starts a drag\n\n tb.innerHTML = toolbarInnerHTML(this.fonts, this.sizes);\n\n // Keep focus/selection in the text while pressing a toolbar control.\n tb.addEventListener('mousedown', (e) => {\n // Selects + colour inputs NEED focus to open; everything else must not\n // steal it (so execCommand applies to the live selection).\n if (!e.target.closest('select, input')) e.preventDefault();\n });\n\n tb.addEventListener('click', (e) => {\n const cmdBtn = e.target.closest('button[data-cmd]');\n if (cmdBtn) { e.preventDefault(); this._runCommand(cmdBtn.dataset.cmd); return; }\n const actBtn = e.target.closest('button[data-act]');\n if (actBtn) { e.preventDefault(); this._runAction(actBtn.dataset.act); return; }\n });\n\n // Font family — keep the chosen value shown (no reset) so the dropdown\n // reflects the selected text's font.\n tb.querySelector('[data-sel=\"font\"]').addEventListener('change', (e) => {\n if (e.target.value) this._wrapStyle({ fontFamily: e.target.value });\n });\n // Font size — dropdown (like font family). Any current/custom size is\n // injected as an option by _syncFontControls so it still displays.\n tb.querySelector('[data-sel=\"size\"]').addEventListener('change', (e) => {\n const v = parseInt(e.target.value, 10);\n if (v > 0) this._wrapStyle({ fontSize: v + 'px' });\n });\n // Paragraph / heading format (H1–H6, Normal).\n tb.querySelector('[data-sel=\"format\"]').addEventListener('change', (e) => this._applyFormatBlock(e.target.value));\n // Line height.\n tb.querySelector('[data-sel=\"lineheight\"]').addEventListener('change', (e) => {\n if (e.target.value) this._setLineHeight(e.target.value);\n });\n // Letter spacing.\n tb.querySelector('[data-sel=\"letterspacing\"]').addEventListener('change', (e) => {\n if (e.target.value) this._setLetterSpacing(e.target.value);\n });\n // Text case (CSS text-transform).\n tb.querySelector('[data-sel=\"textcase\"]').addEventListener('change', (e) => {\n if (e.target.value) this._setTextCase(e.target.value);\n });\n tb.querySelector('[data-color=\"fore\"]').addEventListener('input', (e) => this._setForeColor(e.target.value));\n tb.querySelector('[data-color=\"back\"]').addEventListener('input', (e) => this._setBackColor(e.target.value));\n\n this.doc.body.appendChild(tb);\n this._toolbar = tb;\n this._toolbarVisible = false;\n }\n\n _showToolbar() {\n if (!this._toolbar) return;\n this._toolbar.classList.add('is-visible');\n this._toolbarVisible = true;\n this._positionToolbar();\n this._syncActiveStates();\n }\n\n _hideToolbar() {\n if (!this._toolbar) return;\n this._toolbar.classList.remove('is-visible');\n this._toolbarVisible = false;\n CustomRichEditor.untrackDockedBar(this._toolbar);\n }\n\n // Hide only if focus truly left the editor AND the toolbar (a click on a\n // toolbar select/colour input blurs the text but should keep the bar up).\n _maybeHideToolbar() {\n // Docked mode: the bar lives at the top for the whole edit session — never\n // hide it on blur (teardown/destroy hands back to the placeholder instead).\n const docked = (typeof this.win.isRichToolbarDocked === 'function') ? this.win.isRichToolbarDocked() : false;\n if (docked) return;\n this.win.setTimeout(() => {\n if (this.destroyed) return;\n const a = this.doc.activeElement;\n if (a && (a === this.target || this.target.contains(a) || (this._toolbar && this._toolbar.contains(a)))) return;\n this._hideToolbar();\n }, 80);\n }\n\n _positionToolbar() {\n const tb = this._toolbar;\n if (!tb) return;\n\n // Docked mode: pin the bar to the top of the canvas viewport as a\n // full-width sticky strip (CSS drives layout; trackDockedBar follows the\n // host scroll). We clear the inline left left over from inline mode.\n const docked = (typeof this.win.isRichToolbarDocked === 'function')\n ? this.win.isRichToolbarDocked() : false;\n tb.classList.toggle('cre-toolbar--docked', docked);\n if (docked) {\n tb.style.left = '';\n CustomRichEditor.trackDockedBar(tb);\n return;\n }\n CustomRichEditor.untrackDockedBar(tb);\n\n // Inline mode — anchor to the block (stable) — not the selection — so the\n // bar holds its place while the user types or moves the caret.\n const rect = (this.anchor || this.target).getBoundingClientRect();\n const tbw = tb.offsetWidth, tbh = tb.offsetHeight;\n const vw = this.win.innerWidth, vh = this.win.innerHeight;\n let top = rect.top - tbh - 8;\n if (top < 8) top = Math.min(rect.bottom + 8, vh - tbh - 8);\n let left = rect.left + (rect.width / 2) - (tbw / 2);\n if (left + tbw > vw - 8) left = vw - tbw - 8;\n if (left < 8) left = 8;\n tb.style.top = top + 'px';\n tb.style.left = left + 'px';\n }\n\n /* ----------------------------- selection -------------------------------- */\n _selectionRect() {\n const sel = this.doc.getSelection();\n if (!sel || !sel.rangeCount) return null;\n const r = sel.getRangeAt(0);\n if (!this._inEditor(r.commonAncestorContainer)) return null;\n const rect = r.getBoundingClientRect();\n if (rect && (rect.width || rect.height || rect.top)) return rect;\n return null;\n }\n\n _inEditor(node) {\n return !!node && (node === this.target || this.target.contains(node));\n }\n\n // Remember the live range so colour/select changes (which blur the text)\n // can be re-applied to what the user had selected.\n _syncFromSelection() {\n if (this.destroyed) return;\n const sel = this.doc.getSelection();\n if (sel && sel.rangeCount && this._inEditor(sel.getRangeAt(0).commonAncestorContainer)) {\n this.lastRange = sel.getRangeAt(0).cloneRange();\n // Refresh button active-states only — DON'T reposition (keeps the bar\n // anchored to the block instead of chasing the caret).\n if (this._toolbarVisible) this._syncActiveStates();\n }\n }\n\n _restoreSelection() {\n this.target.focus();\n if (!this.lastRange) return;\n const sel = this.doc.getSelection();\n sel.removeAllRanges();\n sel.addRange(this.lastRange);\n }\n\n _syncActiveStates() {\n if (!this._toolbar) return;\n const map = {\n bold: 'bold', italic: 'italic', underline: 'underline', strikeThrough: 'strikeThrough',\n justifyLeft: 'justifyLeft', justifyCenter: 'justifyCenter', justifyRight: 'justifyRight',\n justifyFull: 'justifyFull', insertOrderedList: 'insertOrderedList', insertUnorderedList: 'insertUnorderedList',\n };\n this._toolbar.querySelectorAll('button[data-cmd]').forEach((btn) => {\n const q = map[btn.dataset.cmd];\n if (!q) return;\n let on = false;\n try { on = this.doc.queryCommandState(q); } catch (e) { /* */ }\n btn.classList.toggle('is-active', on);\n });\n this._syncFontControls();\n }\n\n // Reflect the selected text's actual font size / family / line-height /\n // heading in the toolbar so the dropdowns SHOW the current style.\n _syncFontControls() {\n if (!this._toolbar) return;\n const el = this._currentEl();\n let cs = null;\n try { cs = this.win.getComputedStyle(el.nodeType === 1 ? el : el.parentElement); } catch (e) { /* */ }\n if (!cs) return;\n\n const sizeSel = this._toolbar.querySelector('[data-sel=\"size\"]');\n if (sizeSel) {\n const px = Math.round(parseFloat(cs.fontSize));\n // Drop any previously-injected custom option so they don't pile up.\n sizeSel.querySelectorAll('option[data-dynamic=\"1\"]').forEach((o) => o.remove());\n if (!isNaN(px)) {\n const val = String(px);\n // Make sure the current size exists as an option so it shows even if\n // it isn't one of the presets (e.g. 70), then select it.\n if (!Array.from(sizeSel.options).some((o) => o.value === val)) {\n const opt = this.doc.createElement('option');\n opt.value = val; opt.textContent = val;\n opt.dataset.dynamic = '1';\n sizeSel.appendChild(opt);\n }\n sizeSel.value = val;\n } else {\n sizeSel.value = '';\n }\n }\n\n const fontSel = this._toolbar.querySelector('[data-sel=\"font\"]');\n if (fontSel) {\n const cur = this._famKey(cs.fontFamily);\n let val = '';\n for (const opt of fontSel.options) { if (opt.value && this._famKey(opt.value) === cur) { val = opt.value; break; } }\n fontSel.value = val;\n }\n\n const lhSel = this._toolbar.querySelector('[data-sel=\"lineheight\"]');\n if (lhSel) {\n const fs = parseFloat(cs.fontSize), lh = parseFloat(cs.lineHeight);\n let val = '';\n if (!isNaN(fs) && !isNaN(lh) && fs) {\n const ratio = lh / fs;\n for (const opt of lhSel.options) { if (opt.value && Math.abs(parseFloat(opt.value) - ratio) < 0.09) { val = opt.value; break; } }\n }\n lhSel.value = val;\n }\n\n const fmtSel = this._toolbar.querySelector('[data-sel=\"format\"]');\n if (fmtSel) {\n const blk = this._closestBlock();\n const tag = (blk && blk !== this.target) ? blk.tagName.toLowerCase() : '';\n let val = /^h[1-6]$/.test(tag) ? tag : '';\n // Headings are applied as inline size+weight, so reflect the level by\n // matching the computed (bold) size back to a preset.\n if (!val) {\n const px = Math.round(parseFloat(cs.fontSize));\n const bold = (parseInt(cs.fontWeight, 10) || 400) >= 600;\n if (bold) {\n for (const [lvl, spec] of Object.entries(HEADING_SPEC)) {\n if (Math.round(parseFloat(spec.fontSize)) === px) { val = lvl; break; }\n }\n }\n }\n fmtSel.value = val;\n }\n\n const lsSel = this._toolbar.querySelector('[data-sel=\"letterspacing\"]');\n if (lsSel) {\n const ls = cs.letterSpacing;\n const norm = (!ls || ls === 'normal') ? '' : (parseFloat(ls) + 'px');\n let val = '';\n for (const opt of lsSel.options) { if (opt.value && opt.value === norm) { val = opt.value; break; } }\n lsSel.value = val;\n }\n\n const tcSel = this._toolbar.querySelector('[data-sel=\"textcase\"]');\n if (tcSel) {\n const tt = (cs.textTransform && cs.textTransform !== 'none') ? cs.textTransform : '';\n let val = '';\n for (const opt of tcSel.options) { if (opt.value && opt.value === tt) { val = opt.value; break; } }\n tcSel.value = val;\n }\n }\n\n _famKey(f) {\n return String(f || '').split(',')[0].replace(/['\"]/g, '').trim().toLowerCase();\n }\n\n // The element holding the current caret/selection start (within the editor).\n // IMPORTANT: when the range starts BEFORE a child element — which is exactly\n // what happens after we re-select a freshly-wrapped <span> (setStartBefore)\n // — startContainer is the PARENT. We must descend into childNodes[offset]\n // (the span) so reads hit the styled element, not the parent. Without this,\n // the toolbar shows the parent's size (e.g. 14) and changing the font family\n // would capture+restore 14, wiping the 70 the user had set.\n _currentEl() {\n const sel = this.doc.getSelection();\n if (!sel || !sel.rangeCount) return this.target;\n const range = sel.getRangeAt(0);\n let n = range.startContainer;\n if (n && n.nodeType === 1) {\n n = n.childNodes[range.startOffset] || n.childNodes[range.startOffset - 1] || n;\n }\n if (n && n.nodeType === 3) n = n.parentElement;\n return (n && n.nodeType === 1 && this._inEditor(n)) ? n : this.target;\n }\n\n // Nearest block-level element around the selection, capped at the editor.\n _closestBlock() {\n for (let n = this._currentEl(); n && n !== this.target.parentElement; n = n.parentElement) {\n if (n === this.target) return this.target;\n if (n.tagName && /^(P|DIV|LI|H[1-6]|BLOCKQUOTE|PRE)$/.test(n.tagName)) return n;\n }\n return this.target;\n }\n\n // First explicit inline value of `prop` on the chain from the selection up\n // to (and including) the editor — used to keep size/family/weight when one\n // of the others is being changed.\n _currentStyleProp(prop) {\n for (let n = this._currentEl(); n && n !== this.target.parentElement; n = n.parentElement) {\n if (n.style && n.style[prop]) return n.style[prop];\n if (n === this.target) break;\n }\n return '';\n }\n\n /* ------------------------------ commands -------------------------------- */\n _runCommand(cmd) {\n // Indent/outdent ourselves with margin-left. Native execCommand('outdent')\n // won't reverse a CSS-margin indent, so \"decrease indent\" did nothing\n // after an indent or an align change. Doing both directions by hand keeps\n // them symmetric and reliable. (User-reported.)\n if (cmd === 'indent') return this._changeIndent(1);\n if (cmd === 'outdent') return this._changeIndent(-1);\n this._restoreSelection();\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n try { this.doc.execCommand(cmd, false, null); } catch (e) { /* */ }\n this._afterChange();\n }\n\n // Step the current block's left indent by ±40px, clamped at 0.\n _changeIndent(dir) {\n this._restoreSelection();\n const STEP = 40;\n const el = this._closestBlock();\n const cur = parseFloat(this.win.getComputedStyle(el).marginLeft) || 0;\n const next = Math.max(0, cur + dir * STEP);\n if (next <= 0) el.style.removeProperty('margin-left');\n else el.style.marginLeft = next + 'px';\n this._afterChange();\n }\n\n // Letter spacing — to the selection if there is one, else the whole block\n // (mirrors line height). 'normal' clears it.\n _setLetterSpacing(value) {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n if (sel && sel.rangeCount && !sel.isCollapsed) {\n this._wrapStyle({ letterSpacing: value });\n } else {\n this.target.style.letterSpacing = value;\n this.target.querySelectorAll('[style*=\"letter-spacing\"]').forEach((el) => el.style.removeProperty('letter-spacing'));\n this._afterChange();\n }\n }\n\n // Text case via CSS text-transform: none (as typed) / uppercase /\n // capitalize / lowercase. Non-destructive — the underlying text is unchanged.\n _setTextCase(value) {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n if (sel && sel.rangeCount && !sel.isCollapsed) {\n this._wrapStyle({ textTransform: value });\n } else {\n this.target.style.textTransform = value;\n this.target.querySelectorAll('[style*=\"text-transform\"]').forEach((el) => el.style.removeProperty('text-transform'));\n this._afterChange();\n }\n }\n\n _runAction(act) {\n if (act === 'link') this._insertLink();\n }\n\n _insertLink() {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n const existing = this._closestTag('a');\n const url = this.win.prompt('Link URL:', existing ? existing.getAttribute('href') : 'https://');\n if (url === null) return;\n if (url === '') { try { this.doc.execCommand('unlink'); } catch (e) { /* */ } this._afterChange(); return; }\n if (sel && sel.isCollapsed && !existing) {\n // No selection — insert the URL as its own link text.\n const a = this.doc.createElement('a');\n a.href = url; a.textContent = url;\n sel.getRangeAt(0).insertNode(a);\n } else {\n try { this.doc.execCommand('createLink', false, url); } catch (e) { /* */ }\n }\n this._afterChange();\n }\n\n _closestTag(tag) {\n const sel = this.doc.getSelection();\n let n = sel && sel.rangeCount ? sel.getRangeAt(0).commonAncestorContainer : null;\n for (; n && n !== this.target; n = n.parentNode) {\n if (n.nodeType === 1 && n.tagName.toLowerCase() === tag) return n;\n }\n return null;\n }\n\n _setForeColor(hex) {\n this._restoreSelection();\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n try { this.doc.execCommand('foreColor', false, hex); } catch (e) { /* */ }\n this._afterChange();\n }\n\n _setBackColor(hex) {\n this._restoreSelection();\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n // hiliteColor is the standard; backColor is the WebKit fallback.\n let ok = false;\n try { ok = this.doc.execCommand('hiliteColor', false, hex); } catch (e) { /* */ }\n if (!ok) { try { this.doc.execCommand('backColor', false, hex); } catch (e) { /* */ } }\n this._afterChange();\n }\n\n // Apply arbitrary inline CSS (font-size / font-family / font-weight) to the\n // current selection. Uses the classic `fontSize=7` wrapper trick so the\n // exact selected run gets wrapped, then rewrites each wrapper to a <span>\n // carrying the requested style — works across multi-node selections.\n //\n // The trick's `fontSize` command WIPES any existing font-size on the run, so\n // before wrapping we capture the current size/family/weight the caller is\n // NOT changing and re-apply them — otherwise picking a new font family would\n // silently reset a font-size the user had set (e.g. 70 → back to default).\n _wrapStyle(styleObj) {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n if (!sel || !sel.rangeCount) return;\n if (sel.isCollapsed) return; // nothing selected → nothing to style\n\n const keep = {};\n ['fontSize', 'fontFamily', 'fontWeight'].forEach((p) => {\n if (styleObj[p] != null) return;\n const v = this._currentStyleProp(p);\n if (v && !(p === 'fontWeight' && (v === '400' || v === 'normal'))) keep[p] = v;\n });\n\n try { this.doc.execCommand('styleWithCSS', false, false); } catch (e) { /* */ }\n try { this.doc.execCommand('fontSize', false, '7'); } catch (e) { /* */ }\n const spans = [];\n this.target.querySelectorAll('font[size=\"7\"]').forEach((f) => {\n const span = this.doc.createElement('span');\n Object.assign(span.style, styleObj);\n Object.keys(keep).forEach((k) => { if (!span.style[k]) span.style[k] = keep[k]; });\n // Carry over any colour the trick may have set on the <font>.\n if (f.getAttribute('color')) span.style.color = f.getAttribute('color');\n while (f.firstChild) span.appendChild(f.firstChild);\n f.replaceWith(span);\n spans.push(span);\n });\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n // Keep the just-styled text selected so the user can apply more changes\n // (font + size + colour …) without re-selecting every time.\n this._reselect(spans);\n this._afterChange();\n }\n\n // Re-select a list of nodes (from first to last) and remember the range.\n _reselect(nodes) {\n if (!nodes || !nodes.length) return;\n try {\n const range = this.doc.createRange();\n range.setStartBefore(nodes[0]);\n range.setEndAfter(nodes[nodes.length - 1]);\n const sel = this.doc.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n this.lastRange = range.cloneRange();\n } catch (e) { /* */ }\n }\n\n // Apply a heading level. With a real text selection it styles ONLY the\n // selected run (inline size + weight) so \"balan\" can become H1 without\n // turning \"mani\" in the same line into a heading too — a true block <h1>\n // would swallow the whole line. With just a caret (nothing selected) we\n // fall back to a block-level heading/paragraph for the whole line.\n _applyFormatBlock(tag) {\n this._restoreSelection();\n const t = (tag && /^h[1-6]$/i.test(tag)) ? tag.toLowerCase() : '';\n const sel = this.doc.getSelection();\n const hasSelection = sel && sel.rangeCount && !sel.isCollapsed;\n\n if (hasSelection) {\n if (t) {\n this._wrapStyle(HEADING_SPEC[t]);\n } else {\n // Normal → strip the heading look from the selection. Set explicit\n // base size + normal weight (an empty value would just inherit the\n // surrounding heading span, so it must be explicit).\n const base = this.win.getComputedStyle(this.target).fontSize;\n this._wrapStyle({ fontSize: base, fontWeight: '400' });\n }\n return;\n }\n\n // Caret only → turn the whole line into a block heading / <p>, then strip\n // explicit font-size so the heading's own size shows.\n try { this.doc.execCommand('formatBlock', false, '<' + (t ? t.toUpperCase() : 'P') + '>'); } catch (e) { /* */ }\n const blk = this._closestBlock();\n if (blk && blk !== this.target) {\n blk.style.removeProperty('font-size');\n blk.querySelectorAll('[style*=\"font-size\"]').forEach((el) => el.style.removeProperty('font-size'));\n }\n this._afterChange();\n }\n\n // Line-height applies to the WHOLE text block and clears any per-element\n // line-height so a new value always takes effect (re-setting works).\n _setLineHeight(value) {\n this._restoreSelection();\n this.target.style.lineHeight = value;\n this.target.querySelectorAll('[style*=\"line-height\"]').forEach((el) => el.style.removeProperty('line-height'));\n this._afterChange();\n }\n\n _setAlign(align) {\n const map = { left: 'justifyLeft', center: 'justifyCenter', right: 'justifyRight', justify: 'justifyFull' };\n this._runCommand(map[align] || 'justifyLeft');\n }\n\n _setParagraphWeight(styleName) {\n const w = {\n 'font-weight-light': '300', 'normal': '400', 'font-weight-medium': '500',\n 'font-weight-semi-bold': '600', 'font-weight-bold': '700', 'bold': '700',\n }[styleName] || styleName;\n this._wrapStyle({ fontWeight: String(w) });\n }\n\n // List markers (1. 2. / bullets) use the <li>'s OWN font-size, but our font\n // controls size a nested <span>, so the marker stays tiny next to big text.\n // Bring each <li> up to the largest font-size found in its content.\n _syncListMarkers() {\n this.target.querySelectorAll('li').forEach((li) => {\n let max = 0, found = '';\n li.querySelectorAll('[style]').forEach((el) => {\n const fs = el.style && el.style.fontSize;\n if (!fs) return;\n const px = parseFloat(fs);\n if (!isNaN(px) && px > max) { max = px; found = fs; }\n });\n if (found) li.style.fontSize = found;\n });\n }\n\n _afterChange() {\n // Re-sync state + let the app know content changed (mirrors a user edit).\n this._syncListMarkers();\n this._syncActiveStates();\n try {\n this.target.dispatchEvent(new this.win.Event('input', { bubbles: true }));\n } catch (e) { /* */ }\n }\n\n _onKeydown(e) {\n // Native browser shortcuts already cover bold/italic/underline/undo/redo;\n // we just refresh button states afterwards.\n if (e.key === 'Escape') { this.target.blur(); return; }\n this.win.setTimeout(() => this._syncActiveStates(), 0);\n }\n\n /* -------- Froala-compatible command bridge (froala-style-handler) -------- */\n _exec(name, rawArgs) {\n const args = Array.isArray(rawArgs) ? rawArgs : (rawArgs === undefined ? [] : [rawArgs]);\n switch (name) {\n case 'bold': case 'italic': case 'underline':\n case 'strikeThrough': case 'subscript': case 'superscript':\n case 'insertOrderedList': case 'insertUnorderedList':\n case 'outdent': case 'indent': case 'undo': case 'redo':\n return this._runCommand(name);\n case 'removeFormat':\n this._runCommand('removeFormat'); try { this.doc.execCommand('unlink'); } catch (e) { /* */ } return;\n case 'textColor': return this._setForeColor(args[0]);\n case 'backgroundColor': return this._setBackColor(args[0]);\n case 'fontSize': {\n const v = String(args[0] || '');\n return this._wrapStyle({ fontSize: /px|em|rem|%/.test(v) ? v : (v + 'px') });\n }\n case 'fontFamily': return this._wrapStyle({ fontFamily: args[0] });\n case 'letterSpacing': {\n const v = String(args[0] || 'normal');\n return this._setLetterSpacing(/px|em|rem|%/.test(v) || v === 'normal' ? v : (v + 'px'));\n }\n case 'align': return this._setAlign(args[0]);\n case 'paragraphStyle': return this._setParagraphWeight(args[0]);\n default:\n // Best-effort passthrough for any other execCommand name.\n try { this._runCommand(name); } catch (e) { /* */ }\n }\n }\n }\n\n // Let other editors (the table block) suppress the docked placeholder while\n // they display their own docked toolbar — keeps a single bar at the top.\n CustomRichEditor.setExternalDockedActive = (on) => {\n externalDockedActive = !!on;\n DockedPlaceholder.sync(document);\n };\n\n // Shared toolbar markup so the table block can render the IDENTICAL text-format\n // bar (and just append its own table-structure group), instead of maintaining\n // a second look-alike toolbar.\n CustomRichEditor.toolbarInnerHTML = (fonts, sizes) => toolbarInnerHTML(fonts, sizes);\n\n // --- Docked bar: follow the host's scroll --------------------------------\n // The editor lives in an iframe that GROWS to fit ALL pages; the HOST window\n // scrolls it. A position:fixed bar therefore pins to the iframe's content-top\n // and scrolls off-screen for blocks lower down (that's the \"toolbar hides at\n // the top\" bug). We keep the bar IN the iframe (position:absolute) and, since\n // we're same-origin, read our own <iframe> element to find where the visible\n // viewport currently starts, moving the bar there each animation frame.\n const dockedVisibleTop = () => {\n try {\n const fe = window.frameElement; // same-origin → readable; null if standalone\n if (fe) return Math.max(0, -fe.getBoundingClientRect().top);\n } catch (e) { /* cross-origin — fall through to own scroll */ }\n return window.scrollY || window.pageYOffset || 0;\n };\n const dockedBars = new Set();\n let lastDockTop = -1;\n const applyDockedTop = () => {\n const t = dockedVisibleTop();\n if (t === lastDockTop) return;\n lastDockTop = t;\n dockedBars.forEach((el) => { if (el && el.isConnected) el.style.top = t + 'px'; });\n };\n // rAF-throttle the scroll/resize bursts to one reposition per frame.\n let dockScheduled = false;\n const onDockScroll = () => {\n if (dockScheduled) return;\n dockScheduled = true;\n requestAnimationFrame(() => { dockScheduled = false; applyDockedTop(); });\n };\n let dockListenersOn = false;\n const ensureDockListeners = () => {\n if (dockListenersOn) return;\n dockListenersOn = true;\n // Capture phase catches scrolling of ANY element in the host (the window OR\n // a scroll container — we can't predict which). Same-origin lets us reach\n // the parent document; wrapped in try/catch for the cross-origin/standalone\n // case where we just watch our own scroll.\n try { if (window.parent && window.parent !== window) { window.parent.document.addEventListener('scroll', onDockScroll, true); window.parent.addEventListener('resize', onDockScroll); } } catch (e) { /* */ }\n window.addEventListener('scroll', onDockScroll, true);\n window.addEventListener('resize', onDockScroll);\n };\n CustomRichEditor.trackDockedBar = (el) => {\n if (!el) return;\n ensureDockListeners();\n dockedBars.add(el);\n el.style.top = dockedVisibleTop() + 'px';\n };\n CustomRichEditor.untrackDockedBar = (el) => {\n if (!el) return;\n dockedBars.delete(el);\n el.style.top = '';\n };\n\n window.CustomRichEditor = CustomRichEditor;\n console.log('rich-text-editor: CustomRichEditor ready');\n})();\n\n<\/script>\n <script data-src=\"./editor/inline-editor.js\">\n/**\n * Block interaction state machine.\n *\n * idle ──click──▶ selected ──click again──▶ editing\n * ▲ │ │\n * └──── click outside / Esc ────────────────────┘\n *\n * - selected: shows badge (move handle + menu), block is draggable via the handle\n * - editing : shows badge (label only) + 8 resize handles, Froala inline toolbar active\n */\n(function () {\n const RESIZE_DIRS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];\n const blockEditors = new WeakMap();\n\n let selectedBlock = null;\n let editingBlock = null;\n // Set whenever clearAll runs. The very next surface click is forced to\n // enterSelected, regardless of DOM classes — prevents a single user click\n // from doing both teardown AND edit-mode entry in one gesture.\n let forceFreshSelect = false;\n // In-progress rename of a block's badge label (Ctrl+R). Holds the block, the\n // contenteditable label span, and its original text so we can save or cancel.\n let labelEdit = null;\n // True when the most recent press landed inside the active block. A drag to\n // select text can start inside the editor and release (mouseup) outside the\n // page; the browser then fires `click` on a common ancestor outside the\n // canvas, which would otherwise look like an \"outside click\" and tear down\n // editing mid-selection. We use this to skip that teardown.\n let pressStartedInActive = false;\n\n const isFroalaAvailable = () => typeof FroalaEditor !== 'undefined';\n\n /**\n * Swallow Froala 4's async teardown error: \"Cannot read properties of\n * undefined (reading 'top')\". It fires from popup handlers that run after\n * destroy(), on detached DOM. Harmless, but pollutes the console.\n */\n window.addEventListener('error', (event) => {\n const msg = (event.message || '').toLowerCase();\n const src = (event.filename || '').toLowerCase();\n if (src.includes('froala') && msg.includes(\"reading 'top'\")) {\n event.preventDefault();\n return false;\n }\n });\n\n /* ----------------------------- badge / chrome ----------------------------- */\n\n const buildBadge = (block) => {\n const label = block.getAttribute('custom-name') || block.getAttribute('data') || 'Block';\n const badge = document.createElement('div');\n badge.className = 'cs-block-badge';\n badge.setAttribute('data-cs-chrome', '');\n // Up/Down reorder is meaningless for free-move blocks (cover page /\n // flexible) — their position is absolute, not a flow order — so omit those\n // buttons there and keep just the move handle, duplicate and delete.\n const reorderBtns = isFreeFormBlock(block) ? '' : `\n <button type=\"button\" class=\"cs-block-badge__btn\" data-cs-action=\"move-up\" title=\"Move up\">&#x25B2;</button>\n <button type=\"button\" class=\"cs-block-badge__btn\" data-cs-action=\"move-down\" title=\"Move down\">&#x25BC;</button>`;\n badge.innerHTML = `\n <span class=\"cs-block-badge__handle\" data-cs-move title=\"Drag to move\">&#x2725;</span>\n <span class=\"cs-block-badge__label\">${label}</span>\n <span class=\"cs-block-badge__actions\">${reorderBtns}\n <button type=\"button\" class=\"cs-block-badge__btn\" data-cs-action=\"duplicate\" title=\"Duplicate\">&#x2398;</button>\n <button type=\"button\" class=\"cs-block-badge__btn cs-block-badge__btn--danger\" data-cs-action=\"delete\" title=\"Delete\">&#x2715;</button>\n </span>\n `;\n return badge;\n };\n\n /* ----------------------------- rename (Ctrl+R) ----------------------------- */\n // Renaming edits ONLY the friendly `custom-name` attribute (shown in the\n // badge). The `data` attribute — the block-type identifier the rest of the\n // app reads — is never touched.\n\n const onLabelKeydown = (event) => {\n // Keep the keystroke inside the label: don't trigger the block-level\n // shortcuts (Escape teardown, arrow nudge, Ctrl+R again, copy/paste).\n event.stopPropagation();\n if (event.key === 'Enter') {\n event.preventDefault();\n commitLabelEdit(true);\n } else if (event.key === 'Escape') {\n event.preventDefault();\n commitLabelEdit(false);\n }\n };\n\n const onLabelBlur = () => commitLabelEdit(true);\n\n // Finish an in-progress rename. save=true writes the new name to custom-name;\n // save=false (or empty input) restores the original. Idempotent — safe to call\n // from Enter, blur, or a teardown (clearAll) without double-applying.\n function commitLabelEdit(save) {\n if (!labelEdit) return;\n const { block, label, original } = labelEdit;\n labelEdit = null;\n\n label.removeEventListener('keydown', onLabelKeydown);\n label.removeEventListener('blur', onLabelBlur);\n label.removeAttribute('contenteditable');\n label.classList.remove('cs-block-badge__label--editing');\n\n const next = (label.textContent || '').replace(/\\s+/g, ' ').trim();\n if (save && next) {\n block.setAttribute('custom-name', next); // data attribute stays untouched\n label.textContent = next;\n } else {\n label.textContent = original;\n }\n }\n\n // Make the selected block's badge label editable, focused, and fully selected.\n const startLabelEdit = (block) => {\n if (!block || labelEdit) return;\n const badge = block.querySelector(':scope > .cs-block-badge');\n const label = badge && badge.querySelector('.cs-block-badge__label');\n if (!label) return;\n\n labelEdit = { block, label, original: label.textContent };\n label.setAttribute('contenteditable', 'true');\n label.classList.add('cs-block-badge__label--editing');\n label.addEventListener('keydown', onLabelKeydown);\n label.addEventListener('blur', onLabelBlur);\n\n label.focus();\n const range = document.createRange();\n range.selectNodeContents(label);\n const sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n };\n\n // Run a badge action button. The button carries data-cs-action; the owning\n // block is resolved from the badge's parent. All actions delegate to the\n // FlowCanvas helpers so behaviour stays consistent with keyboard shortcuts.\n const runBadgeAction = (action, block) => {\n if (!block) return;\n const FC = window.FlowCanvas || {};\n switch (action) {\n case 'move-up': FC.moveBlock?.(block, 'up'); break;\n case 'move-down': FC.moveBlock?.(block, 'down'); break;\n case 'duplicate': FC.duplicateBlock?.(block); break;\n case 'delete': clearAll(); FC.deleteBlock?.(block); break;\n }\n };\n\n const buildResizeHandles = () => {\n const frag = document.createDocumentFragment();\n RESIZE_DIRS.forEach((dir) => {\n const h = document.createElement('div');\n h.className = 'cs-resize-handle';\n h.setAttribute('data-dir', dir);\n h.setAttribute('data-cs-chrome', '');\n frag.appendChild(h);\n });\n return frag;\n };\n\n const removeChrome = (block) => {\n block.querySelectorAll('[data-cs-chrome]').forEach((el) => {\n // The pen-shape tool manages its own overlay lifecycle (it tags the\n // overlay data-cs-chrome only so export/insert logic treats it as chrome).\n // Leave it alone — otherwise the editing UI gets wiped on attachChrome.\n if (el.classList.contains('cs-pen-overlay')) return;\n // Aiden's in-block action bar / tone popup own their own lifecycle too\n // (removed when the AI session ends) — don't let chrome teardown wipe them\n // mid-session.\n if (el.classList.contains('cs-aiden-bar') || el.classList.contains('cs-aiden-pop')) return;\n el.remove();\n });\n };\n\n /* ----------------------------- editor lifecycle ----------------------------- */\n\n const findEditTarget = (block) =>\n block.querySelector('.edit_me') || block.querySelector('.canvas-block__content') || null;\n\n const startFroala = (block) => {\n // The List block and its columns are structural containers — they have no\n // text of their own, and any `.edit_me` matches belong to nested cells.\n // Never start an editor on them (that would hijack a cell's text editor).\n if (block.dataset.blockType === 'sync-list' || block.dataset.blockType === 'sync-list-col') return;\n\n const target = findEditTarget(block);\n if (!target) return;\n\n // Section containers use custom markup and should not be initialized with Froala.\n if (block.dataset.blockType === 'section-container') {\n target.setAttribute('contenteditable', 'true');\n target.focus();\n return;\n }\n\n // Scrub any stale Froala state before re-init (defensive: handles edge cases\n // where the user click-storms between blocks faster than destroy() finishes).\n hardCleanFroala(block);\n\n // Lock the block's WIDTH at its rendered value before the editor wraps\n // things — otherwise the box can collapse. Force HEIGHT to auto (clearing\n // any pinned/resized height) so the block grows as the user types / hits\n // Enter, instead of overflowing a fixed-height box.\n const rect = block.getBoundingClientRect();\n block.style.width = `${rect.width}px`;\n block.style.maxWidth = 'none';\n block.style.height = 'auto';\n const editTarget = findEditTarget(block);\n if (editTarget) editTarget.style.height = 'auto';\n\n // Engine switch (CanvasConfig.editor.useFroala): false → our custom editor,\n // true → legacy Froala. See canvas-config.js.\n const useFroala = (typeof window.isFroalaEditor === 'function') ? window.isFroalaEditor() : false;\n\n // NEW custom editor (default). Dependency-free, edits in place, and exposes\n // the same `.commands.exec()` / `.destroy()` surface so froala-style-handler\n // + the style panel keep working.\n if (!useFroala && typeof window.CustomRichEditor === 'function') {\n try {\n const editor = new window.CustomRichEditor(target, {\n placeholder: target.getAttribute('placeholder') || 'Enter text here',\n fonts: window.FROALA_FONTS || null,\n fontSizes: ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '88', '96'],\n });\n blockEditors.set(block, editor);\n return;\n } catch (err) {\n console.warn('CustomRichEditor init failed, falling back:', err);\n }\n }\n\n // Froala needs the element to be contenteditable-friendly; it handles that itself.\n if (isFroalaAvailable()) {\n try {\n const editor = new FroalaEditor(target, {\n toolbarInline: true,\n toolbarVisibleWithoutSelection: false,\n charCounterCount: false,\n wordCounterCount: false,\n quickInsertEnabled: false,\n attribution: false,\n key: '',\n toolbarButtons: [\n ['bold', 'italic', 'underline', 'strikeThrough', 'subscript', 'superscript'],\n ['fontSize', 'fontFamily', 'textColor', 'backgroundColor'],\n ['align', 'formatOL', 'formatUL', 'outdent', 'indent'],\n ['insertLink', 'insertImage', 'insertTable', 'insertVideo'],\n ['removeFormat', 'clearFormatting', 'html'],\n ['undo', 'redo'],\n ['selectAll', 'copy', 'cut', 'paste'],\n ['quote', 'insertHR', 'lineHeight', 'letterSpacing', 'paragraphStyle'],\n ['spellChecker']\n ],\n fontSize: ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '88', '96', '100'],\n fontSizeSelection: true,\n // Font family configuration with Google Fonts + System fonts\n // Format: CSS value => Display name (keys shown in dropdown)\n fontFamily: window.FROALA_FONTS || {\n 'Arial': 'Arial',\n \"'Roboto', sans-serif\": 'Roboto',\n \"'Poppins', sans-serif\": 'Poppins',\n \"'Sora', sans-serif\": 'Sora',\n \"'Open Sans', sans-serif\": 'Open Sans',\n \"'Lato', sans-serif\": 'Lato',\n \"'Montserrat', sans-serif\": 'Montserrat',\n \"'Raleway', sans-serif\": 'Raleway',\n \"'Playfair Display', serif\": 'Playfair Display',\n \"'Inter', sans-serif\": 'Inter',\n },\n paragraphStyles: {\n 'font-weight-light': 'Light (300)',\n 'font-weight-medium': 'Medium (500)',\n 'font-weight-bold': 'Bold (700)'\n },\n placeholderText: target.getAttribute('placeholder') || 'Enter text here',\n events: {\n initialized: function () {\n this.events.focus();\n },\n contentChanged: function () {\n // Froala's built-in table insert column/row creates bare <td>s\n // that lack our `cs-cell` class (→ no border) and a junk\n // `style=\"null;…\"`. Re-stamp any static-table cells it touched.\n try {\n const root = this.el;\n if (root && window.TableBlock && typeof window.TableBlock.normalizeCells === 'function') {\n root.querySelectorAll('table.cs-table').forEach((t) => window.TableBlock.normalizeCells(t));\n }\n } catch (err) { /* normalization is best-effort */ }\n }\n }\n });\n blockEditors.set(block, editor);\n return;\n } catch (err) {\n console.warn('Froala init failed, falling back to contenteditable:', err);\n }\n }\n\n // Last resort: if the preferred engine wasn't available (e.g. Froala mode\n // but Froala didn't load), use the custom editor before bare contenteditable.\n if (typeof window.CustomRichEditor === 'function') {\n try {\n const editor = new window.CustomRichEditor(target, {\n placeholder: target.getAttribute('placeholder') || 'Enter text here',\n fonts: window.FROALA_FONTS || null,\n });\n blockEditors.set(block, editor);\n return;\n } catch (err) { /* fall through */ }\n }\n\n // Fallback\n target.setAttribute('contenteditable', 'true');\n target.focus();\n };\n\n /**\n * Strips every artifact Froala leaves behind so a future init starts clean.\n * Froala 4.x sometimes leaves the .fr-element class, contenteditable, and\n * (rarely) wrapper nodes. If we don't scrub these, the next `new FroalaEditor(target)`\n * silently no-ops and the block appears to \"skip\" the selected state.\n */\n const hardCleanFroala = (block) => {\n if (!block) return;\n\n // 1. Unwrap any .fr-box / .fr-wrapper Froala created around the edit target.\n // On a clean destroy these are gone, but we belt-and-suspender it.\n block.querySelectorAll('.fr-box').forEach((box) => {\n const inner = box.querySelector('.fr-element');\n if (inner && box.parentNode) {\n box.parentNode.replaceChild(inner, box);\n }\n });\n\n // 2. Remove any Froala UI nodes accidentally left inside the block.\n block.querySelectorAll(\n '.fr-toolbar, .fr-popup, .fr-modal, .fr-overlay, .fr-second-toolbar, .fr-placeholder, .fr-tooltip'\n ).forEach((el) => el.remove());\n\n // 3. Reset the edit target back to a plain .edit_me div.\n const target = block.querySelector('.edit_me, .fr-element');\n if (target) {\n target.removeAttribute('contenteditable');\n target.removeAttribute('spellcheck');\n target.removeAttribute('dir');\n target.classList.remove('fr-element', 'fr-view', 'fr-box');\n if (!target.classList.contains('edit_me')) {\n target.classList.add('edit_me');\n }\n // Strip every Froala data-* attribute\n Array.from(target.attributes).forEach((attr) => {\n if (attr.name.startsWith('data-fr-') || attr.name.startsWith('fr-')) {\n target.removeAttribute(attr.name);\n }\n });\n // Strip Froala-injected inline sizing (min-height, height, padding etc.)\n // that otherwise survives destroy and squashes the block on re-edit.\n ['min-height', 'height', 'max-height', 'padding', 'padding-top', 'padding-bottom',\n 'padding-left', 'padding-right', 'margin', 'overflow', 'display'].forEach((prop) => {\n target.style.removeProperty(prop);\n });\n }\n };\n\n const stopFroala = (block) => {\n const editor = blockEditors.get(block);\n if (editor) {\n // Hide UI before destroy so Froala's async popup-cleanup handlers don't\n // try to read .offset().top on a detached node (the 'top' of undefined error).\n try { editor.popups && editor.popups.hideAll && editor.popups.hideAll(); } catch (e) { }\n try { editor.toolbar && editor.toolbar.hide && editor.toolbar.hide(); } catch (e) { }\n try { editor.destroy(); } catch (e) { /* noop */ }\n blockEditors.delete(block);\n }\n // Let Froala finish its own DOM unwrap before we forcibly clean. If we\n // hardClean synchronously, we race with Froala's mouseup/blur handlers.\n hardCleanFroala(block);\n\n document.querySelectorAll(\n 'body > .fr-toolbar, body > .fr-popup, body > .fr-modal, body > .fr-overlay, body > .fr-tooltip'\n ).forEach((el) => el.remove());\n };\n\n /* ----------------------------- state transitions ----------------------------- */\n\n const clearAll = ({ internal = false } = {}) => {\n // Save an in-progress rename before the badge (and its label) is removed —\n // a blur event isn't guaranteed once the focused node is detached.\n commitLabelEdit(true);\n\n const stoppedBlock = editingBlock;\n if (stoppedBlock) {\n stopFroala(stoppedBlock);\n editingBlock = null;\n }\n selectedBlock = null;\n // Only flag fresh-select for clearAlls triggered by user input (not for\n // internal calls from enterSelected/enterEditing).\n if (!internal) forceFreshSelect = true;\n\n // Sweep the DOM for any orphaned chrome (other blocks). Skip the one we just\n // stopped — hardCleanFroala already ran on it inside stopFroala, and running\n // it again can race with Froala's async popup teardown.\n document.querySelectorAll('.cs_block_s.cs-editing, .cs_block_s.cs-selected').forEach((b) => {\n b.classList.remove('cs-editing', 'cs-selected');\n removeChrome(b);\n if (b !== stoppedBlock) {\n hardCleanFroala(b);\n }\n });\n\n // Final safety: kill any Froala UI still floating in <body>. Catches the case\n // where destroy() threw before completing.\n document.querySelectorAll(\n 'body > .fr-toolbar, body > .fr-popup, body > .fr-modal, body > .fr-overlay, body > .fr-tooltip'\n ).forEach((el) => el.remove());\n };\n\n const enterSelected = (block) => {\n if (selectedBlock === block && !editingBlock) return;\n clearAll({ internal: true });\n block.classList.add('cs-selected');\n block.appendChild(buildBadge(block));\n selectedBlock = block;\n // On a cover page the inline \"+\" line only shows while idle, so hide it the\n // instant a block is selected instead of waiting for the next pointermove\n // (refreshHover also guards on .cs-selected, but only on the next move).\n if (block.closest?.('[data-cs-cover=\"1\"]')) {\n window.FlowCanvas?.hideInlineInsert?.();\n }\n };\n\n // Drop the caret at viewport coords (x, y) inside the block's edit target.\n // Entering editing makes the element contenteditable only AFTER the click was\n // dispatched, so the browser never placed a native caret from that click and\n // the editor's focus() leaves it at the very start. We re-create the caret the\n // user aimed at from the click coordinates. No-op if the point misses the\n // editable text (e.g. the click landed on padding).\n const placeCaretFromPoint = (block, x, y) => {\n const target = findEditTarget(block);\n if (!target) return;\n\n let range = null;\n if (document.caretRangeFromPoint) {\n range = document.caretRangeFromPoint(x, y); // WebKit / Blink\n } else if (document.caretPositionFromPoint) {\n const pos = document.caretPositionFromPoint(x, y); // Firefox\n if (pos) {\n range = document.createRange();\n range.setStart(pos.offsetNode, pos.offset);\n }\n }\n if (!range || !target.contains(range.startContainer)) return;\n\n range.collapse(true);\n const sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n };\n\n const enterEditing = (block, caretPoint = null) => {\n if (editingBlock === block) return;\n // Re-use the selected chrome; just upgrade it\n if (selectedBlock && selectedBlock !== block) {\n clearAll({ internal: true });\n }\n // Ensure badge exists (we kept .cs-selected on; remove it because cs-editing replaces it visually)\n removeChrome(block);\n block.classList.remove('cs-selected');\n block.classList.add('cs-editing');\n\n // Drop the inline \"+\" insert indicator right away so it never overlaps the\n // editing surface (refreshHover also guards on .cs-editing, but that only\n // fires on the next pointermove — this hides it instantly on entry).\n window.FlowCanvas?.hideInlineInsert?.();\n\n editingBlock = block;\n selectedBlock = null;\n\n // Init Froala FIRST. Add chrome AFTER an rAF tick so Froala's async init\n // (including its `initialized` event handler) finishes mutating DOM before\n // we append the badge + resize handles. Without this delay, Froala's\n // post-init DOM work can wipe siblings on the second edit cycle.\n startFroala(block);\n\n // The editor focuses the target and parks the caret at the start. If the\n // user clicked into existing text, move it to where they clicked. Runs after\n // startFroala so the element is already contenteditable + focused.\n if (caretPoint) placeCaretFromPoint(block, caretPoint.x, caretPoint.y);\n\n const attachChrome = () => {\n if (editingBlock !== block) return; // user already moved on\n removeChrome(block);\n block.appendChild(buildBadge(block));\n block.appendChild(buildResizeHandles());\n };\n requestAnimationFrame(() => requestAnimationFrame(attachChrome));\n };\n\n /* ----------------------------- drag / move (selected only) ----------------------------- */\n\n // The editor surface hosts the click / move / resize listeners. Prefer the\n // multi-page board (.cs_paper) so EVERY page is covered — including added\n // pages and cover pages, which live in their own `.custom-form-design`\n // siblings rather than under page 1's wrapper. Falls back to the single\n // canvas when there's no multi-page board (e.g. embedded web component).\n const dropSurface = () =>\n document.querySelector('.cs_paper') || document.querySelector('.custom-form-design');\n\n const syncFlexibleContentBounds = (block) => {\n window.FlowCanvas?.syncFlexibleContentBounds?.(block);\n };\n\n const getFlexibleMoveBounds = (parent, block) => {\n const parentWidth = parent?.clientWidth ?? 0;\n const parentHeight = parent?.clientHeight ?? 0;\n const blockWidth = block?.offsetWidth ?? 0;\n const blockHeight = block?.offsetHeight ?? 0;\n const minVisible = 40;\n const overflowX = blockWidth - parentWidth;\n const overflowY = blockHeight - parentHeight;\n\n return {\n minLeft: overflowX > 0 ? (minVisible - blockWidth) : 0,\n maxLeft: overflowX > 0 ? Math.max(0, parentWidth - minVisible) : Math.max(0, parentWidth - blockWidth),\n minTop: overflowY > 0 ? (minVisible - blockHeight) : 0,\n maxTop: overflowY > 0 ? Math.max(0, parentHeight - minVisible) : Math.max(0, parentHeight - blockHeight)\n };\n };\n\n const readRenderedPosition = (block, axis) => {\n const inlineValue = parseFloat(block.style[axis]);\n if (!Number.isNaN(inlineValue)) return inlineValue;\n\n const computedValue = parseFloat(window.getComputedStyle(block)[axis]);\n return Number.isNaN(computedValue) ? 0 : computedValue;\n };\n\n let move = null;\n let wasDragged = false;\n\n /* --------- live position / size readout (free-move blocks only) ---------\n * While dragging or resizing a free-positioned block (cover page or flexible\n * container), show the live X/Y (move) or W/H (resize) right where the title\n * badge sits, then restore the title on release. */\n const metricState = { label: null, orig: null };\n\n const isFreeFormBlock = (block) =>\n !!block && (block.dataset.csInSection === '1'\n || block.classList.contains('cs-flexible-block')\n || !!block.closest?.('[data-cs-cover=\"1\"]'));\n\n const showMetric = (block, text) => {\n const badge = block.querySelector(':scope > .cs-block-badge');\n const label = badge && badge.querySelector('.cs-block-badge__label');\n if (!label) return;\n // New gesture / different block: restore the previous one and snapshot this\n // label's real title so we can put it back when the gesture ends.\n if (metricState.label !== label) {\n restoreMetric();\n metricState.label = label;\n metricState.orig = label.textContent;\n }\n label.textContent = text;\n label.classList.add('cs-block-badge__label--metric');\n };\n\n const restoreMetric = () => {\n if (metricState.label && metricState.orig != null) {\n metricState.label.textContent = metricState.orig;\n metricState.label.classList.remove('cs-block-badge__label--metric');\n }\n metricState.label = null;\n metricState.orig = null;\n };\n\n /* --------- smart alignment guides for free-move (cover / section) blocks ----\n * While dragging or resizing a free block we snap its edges/centre to the\n * page edges/centre and to other blocks' edges/centres (within a few px) and\n * draw pink guide lines — so blocks line up straight, at equal heights, and\n * share widths without guesswork. The guide overlay is editor-only chrome. */\n const ALIGN_TOL = 3; // px\n\n // Candidate snap lines in the parent: page edges + centre, and every sibling\n // block's left/centre/right (vx) and top/middle/bottom (hy).\n const alignLines = (parent, block) => {\n const vx = [0, parent.clientWidth / 2, parent.clientWidth];\n const hy = [0, parent.clientHeight / 2, parent.clientHeight];\n Array.from(parent.children).forEach((c) => {\n if (c === block || !c.matches || !c.matches('.cs_block_s')) return;\n const l = c.offsetLeft, t = c.offsetTop, w = c.offsetWidth, h = c.offsetHeight;\n vx.push(l, l + w / 2, l + w);\n hy.push(t, t + h / 2, t + h);\n });\n return { vx, hy };\n };\n\n // Best snap for the moving box [left,top,w,h]; returns adjusted left/top plus\n // the guide coordinates to draw (or null). `edges` limits which of the box's\n // own anchors may snap (used by resize so only the dragged edge snaps).\n const snapAlign = (parent, block, left, top, w, h, edges) => {\n const { vx, hy } = alignLines(parent, block);\n const ex = edges || { l: true, c: true, r: true, t: true, m: true, b: true };\n let bV = null, bH = null;\n const vAnchors = [];\n if (ex.l) vAnchors.push(0); if (ex.c) vAnchors.push(w / 2); if (ex.r) vAnchors.push(w);\n const hAnchors = [];\n if (ex.t) hAnchors.push(0); if (ex.m) hAnchors.push(h / 2); if (ex.b) hAnchors.push(h);\n vAnchors.forEach((off) => vx.forEach((gx) => {\n const d = Math.abs((left + off) - gx);\n if (d <= ALIGN_TOL && (!bV || d < bV.d)) bV = { d, guide: gx, newLeft: gx - off };\n }));\n hAnchors.forEach((off) => hy.forEach((gy) => {\n const d = Math.abs((top + off) - gy);\n if (d <= ALIGN_TOL && (!bH || d < bH.d)) bH = { d, guide: gy, newTop: gy - off };\n }));\n return {\n left: bV ? bV.newLeft : left,\n top: bH ? bH.newTop : top,\n vGuide: bV ? bV.guide : null,\n hGuide: bH ? bH.guide : null,\n };\n };\n\n let alignGuideEl = null;\n const showAlignGuides = (parent, vGuide, hGuide) => {\n if (vGuide == null && hGuide == null) { clearAlignGuides(); return; }\n if (!alignGuideEl || alignGuideEl.parentElement !== parent) {\n clearAlignGuides();\n alignGuideEl = document.createElement('div');\n alignGuideEl.className = 'cs-align-guides';\n alignGuideEl.setAttribute('data-cs-chrome', '');\n parent.appendChild(alignGuideEl);\n }\n alignGuideEl.innerHTML = '';\n if (vGuide != null) {\n const v = document.createElement('div');\n v.className = 'cs-align-guide cs-align-guide--v';\n v.style.left = `${vGuide}px`;\n alignGuideEl.appendChild(v);\n }\n if (hGuide != null) {\n const hl = document.createElement('div');\n hl.className = 'cs-align-guide cs-align-guide--h';\n hl.style.top = `${hGuide}px`;\n alignGuideEl.appendChild(hl);\n }\n };\n const clearAlignGuides = () => { if (alignGuideEl) { alignGuideEl.remove(); alignGuideEl = null; } };\n\n const onMoveDown = (event) => {\n wasDragged = false;\n // Let resize handles operate freely\n if (event.target.closest('.cs-resize-handle')) return;\n // Badge action buttons are clicks, not drags — never start a move on them.\n if (event.target.closest('[data-cs-action]')) return;\n // Renaming the badge label: clicks place the caret, they don't drag.\n if (event.target.closest('.cs-block-badge__label[contenteditable=\"true\"]')) return;\n\n const block = event.target.closest('.cs_block_s');\n if (!block) return;\n\n // Locked layers (set from the Layers panel) can't be moved.\n if (block.closest('[data-cs-locked=\"1\"]')) return;\n\n // Group containers (and dragging the whole multi-selection) are owned by\n // group.js — it manages those drags with a movement threshold so a clean\n // click can still drill into a child. Inline-editor must not start a move\n // or capture the pointer for a group, or it hijacks that click.\n if (block.classList.contains('cs-group-block')) return;\n\n // Flow canvas owns layout for top-level blocks (in a row/col). Only\n // in-section children use absolute drag.\n if (block.closest('.cs-flow-canvas') && !block.dataset.csInSection) return;\n\n // Check if what they clicked was the badge handle directly\n const isHandle = !!event.target.closest('[data-cs-move]');\n\n // If block is selected, allow dragging from ANYWHERE inside.\n // If block is actively being edited, ONLY allow dragging from the dedicated move badge handle.\n if (block.classList.contains('cs-selected') || (block.classList.contains('cs-editing') && isHandle)) {\n event.preventDefault();\n event.stopPropagation();\n\n const parent = block.offsetParent || dropSurface();\n const parentRect = parent.getBoundingClientRect();\n const blockRect = block.getBoundingClientRect();\n\n move = {\n block,\n parent,\n parentRect,\n offsetX: event.clientX - blockRect.left,\n offsetY: event.clientY - blockRect.top,\n startX: event.clientX,\n startY: event.clientY\n };\n\n const captureNode = isHandle ? event.target.closest('[data-cs-move]') : block;\n captureNode.setPointerCapture?.(event.pointerId);\n }\n };\n\n const onMoveMove = (event) => {\n if (!move) return;\n const { block, parent, parentRect, offsetX, offsetY } = move;\n const { minLeft, maxLeft, minTop, maxTop } = getFlexibleMoveBounds(parent, block);\n\n // If the parent is a section container content, we might not want to strictly constrain the bottom edge if it grows\n // But for bounding logic, using the clientHeight prevents breaking out.\n\n if (Math.abs(event.clientX - move.startX) > 3 || Math.abs(event.clientY - move.startY) > 3) {\n wasDragged = true;\n }\n\n let left = Math.min(Math.max(event.clientX - parentRect.left - offsetX, minLeft), maxLeft);\n let top = Math.min(Math.max(event.clientY - parentRect.top - offsetY, minTop), maxTop);\n\n // Smart-guide snapping (free blocks): align edges/centre to page + siblings.\n if (isFreeFormBlock(block)) {\n const a = snapAlign(parent, block, left, top, block.offsetWidth, block.offsetHeight);\n left = a.left; top = a.top;\n showAlignGuides(parent, a.vGuide, a.hGuide);\n }\n\n block.style.left = `${left}px`;\n block.style.top = `${top}px`;\n\n // Live X/Y readout in the title badge for free-move blocks.\n if (isFreeFormBlock(block)) {\n showMetric(block, `X: ${Math.round(left)} Y: ${Math.round(top)}`);\n }\n };\n\n const onMoveUp = () => {\n restoreMetric();\n clearAlignGuides();\n const moved = move?.block;\n move = null;\n // A child moved inside a group → grow/shrink the group to wrap its children.\n const group = moved?.closest?.('.cs-group-block');\n if (group && group !== moved) window.FlowCanvas?.refitGroupToChildren?.(group);\n // Decay the drag flag after a split second so future regular clicks are guaranteed clean\n setTimeout(() => { wasDragged = false; }, 100);\n };\n\n /* ----------------------------- resize (editing only) ----------------------------- */\n\n let resize = null;\n\n const onResizeDown = (event) => {\n const handle = event.target.closest('.cs-resize-handle');\n if (!handle) return;\n const block = handle.closest('.cs_block_s');\n // Trust the DOM class, not the in-memory ref (which can drift after a\n // destroy race).\n if (!block || !block.classList.contains('cs-editing')) return;\n // Locked layers can't be resized.\n if (block.closest('[data-cs-locked=\"1\"]')) return;\n\n // Flow canvas owns block width for top-level blocks. Section containers\n // and in-section blocks still allow pixel resize (height adjust for sections,\n // free-position for in-section children).\n const isInSection = !!block.dataset.csInSection;\n const isSectionContainer = block.dataset.blockType === 'section-container' ||\n block.getAttribute('data') === 'Section Container';\n\n // We allow resize on normal flow blocks as well, so we do NOT early return here anymore.\n // The onResizeMove handler correctly constraints them (skipping absolute left/top).\n\n event.preventDefault();\n event.stopPropagation();\n\n const rect = block.getBoundingClientRect();\n const parent = block.offsetParent || dropSurface();\n const parentRect = parent.getBoundingClientRect();\n\n resize = {\n block,\n dir: handle.getAttribute('data-dir'),\n startX: event.clientX,\n startY: event.clientY,\n startW: rect.width,\n startH: rect.height,\n startLeft: rect.left - parentRect.left,\n startTop: rect.top - parentRect.top,\n parent,\n parentRect\n };\n\n handle.setPointerCapture?.(event.pointerId);\n };\n\n const onResizeMove = (event) => {\n if (!resize) return;\n const { block, dir, startX, startY, startW, startH, startLeft, startTop } = resize;\n const dx = event.clientX - startX;\n const dy = event.clientY - startY;\n\n let newW = startW;\n let newH = startH;\n let newLeft = startLeft;\n let newTop = startTop;\n\n const MIN = 40;\n // Free-form blocks (flexible containers + their absolutely-positioned\n // in-section children) can be sized much smaller than a normal flow block,\n // so use the configurable flexible minimums for both width and height.\n const isFlexibleBlock =\n block.dataset.blockType === 'flexible' || block.classList.contains('cs-flexible-block');\n const isFreeForm = isFlexibleBlock || !!block.dataset.csInSection;\n const flexCfg = window.CanvasConfig?.flexible || {};\n const MIN_W = isFreeForm ? (flexCfg.minWidth ?? 20) : MIN;\n let MIN_H = isFreeForm ? (flexCfg.minHeight ?? 20) : MIN;\n\n // For a TEXT block, never shrink the height below the text's natural height\n // — otherwise the box clips and the text overflows it (the box would be\n // shorter than the content). The edit target is height:auto, so its\n // scrollHeight is the true content height regardless of the box size.\n const editEl = block.querySelector('.edit_me');\n if (editEl && editEl.closest('.cs_block_s') === block) {\n const csb = getComputedStyle(block);\n const extra = (parseFloat(csb.paddingTop) || 0) + (parseFloat(csb.paddingBottom) || 0)\n + (parseFloat(csb.borderTopWidth) || 0) + (parseFloat(csb.borderBottomWidth) || 0);\n MIN_H = Math.max(MIN_H, Math.ceil(editEl.scrollHeight + extra));\n }\n\n if (dir.includes('e')) newW = Math.max(MIN_W, startW + dx);\n if (dir.includes('s')) newH = Math.max(MIN_H, startH + dy);\n if (dir.includes('w')) {\n newW = Math.max(MIN_W, startW - dx);\n newLeft = startLeft + (startW - newW);\n }\n if (dir.includes('n')) {\n newH = Math.max(MIN_H, startH - dy);\n newTop = startTop + (startH - newH);\n }\n\n // A vertical resize must PIN the height as a min-height floor, not just set\n // `height`. Both the editor's auto-grow (_onInputGrow) and edit-entry\n // (startFroala) force `height:auto`, which would otherwise collapse a\n // manually-enlarged but empty/short block back to its content height the\n // next time it's edited. min-height survives `height:auto` while still\n // letting the box grow when the content is taller.\n const pinHeight = dir.includes('n') || dir.includes('s');\n\n // Flow-mode blocks (sections in a column) aren't absolutely positioned —\n // skip left/top, cap width to parent column.\n const isFlowBlock = block.closest('.cs-flow-canvas') && !block.dataset.csInSection;\n if (isFlowBlock) {\n block.style.height = `${newH}px`;\n if (pinHeight) block.style.minHeight = `${newH}px`;\n\n // Section containers and Flexible blocks rely on their inner content wrapper for visual height.\n // We must explicitly stretch the wrapper's minimum height to match the manual resize.\n const sectionContent = block.querySelector(':scope > .section-container-content, :scope > .cs-flexible-content');\n if (sectionContent) {\n sectionContent.style.minHeight = `${newH}px`;\n }\n\n if (dir.includes('e') || dir.includes('w')) {\n const parent = block.parentElement;\n const maxW = parent ? parent.clientWidth : newW;\n block.style.width = `${Math.min(newW, maxW)}px`;\n }\n syncFlexibleContentBounds(block);\n } else {\n // Smart-guide snapping for the dragged edge(s): line up with the page or\n // sibling blocks, keeping the opposite edge fixed.\n if (isFreeForm && block.offsetParent) {\n const { vx, hy } = alignLines(block.offsetParent, block);\n const near = (val, cands) => { let b = null; cands.forEach((g) => { const d = Math.abs(val - g); if (d <= ALIGN_TOL && (!b || d < b.d)) b = { d, g }; }); return b; };\n let vG = null, hG = null;\n if (dir.includes('e')) { const s = near(newLeft + newW, vx); if (s && (s.g - newLeft) >= MIN_W) { newW = s.g - newLeft; vG = s.g; } }\n else if (dir.includes('w')) { const s = near(newLeft, vx); if (s && ((newLeft + newW) - s.g) >= MIN_W) { newW = (newLeft + newW) - s.g; newLeft = s.g; vG = s.g; } }\n if (dir.includes('s')) { const s = near(newTop + newH, hy); if (s && (s.g - newTop) >= MIN_H) { newH = s.g - newTop; hG = s.g; } }\n else if (dir.includes('n')) { const s = near(newTop, hy); if (s && ((newTop + newH) - s.g) >= MIN_H) { newH = (newTop + newH) - s.g; newTop = s.g; hG = s.g; } }\n showAlignGuides(block.offsetParent, vG, hG);\n }\n block.style.width = `${newW}px`;\n block.style.height = `${newH}px`;\n if (pinHeight) block.style.minHeight = `${newH}px`;\n // Only update left/top if the resize direction includes that corner\n // This preserves position for non-corner resizes\n if (dir.includes('w') || dir.includes('e')) {\n // Horizontal resize - may need to update left if from west\n if (dir.includes('w')) {\n block.style.left = `${newLeft}px`;\n }\n }\n if (dir.includes('n') || dir.includes('s')) {\n // Vertical resize - may need to update top if from north\n if (dir.includes('n')) {\n block.style.top = `${newTop}px`;\n }\n }\n syncFlexibleContentBounds(block);\n }\n // Drop the max-width cap so the block actually grows\n block.style.maxWidth = 'none';\n\n // Live W/H readout in the title badge for free-form blocks.\n if (isFreeForm) {\n showMetric(block, `W: ${Math.round(newW)} H: ${Math.round(newH)}`);\n }\n };\n\n const onResizeUp = () => {\n restoreMetric();\n clearAlignGuides();\n const resized = resize?.block;\n resize = null;\n // A child resized inside a group → grow the group so it wraps all children.\n const group = resized?.closest?.('.cs-group-block');\n if (group && group !== resized) window.FlowCanvas?.refitGroupToChildren?.(group);\n };\n\n /* ----------------------------- click routing ----------------------------- */\n\n const onSurfaceClick = (event) => {\n if (wasDragged) {\n wasDragged = false;\n return;\n }\n\n // Ignore clicks on our own chrome (handled by their own listeners)\n if (event.target.closest('[data-cs-chrome]')) return;\n\n // Innermost block under the click selects directly — a child inside a group\n // selects the child, clicking the group's own area selects the group.\n const block = event.target.closest('.cs_block_s');\n\n if (!block) {\n // No block under the click. If this is the tail of a drag that began\n // inside the active block (text selection released on empty page area or\n // the .cs_paper gutter outside the page), the user never meant to click\n // away — keep editing + the selection. A real click on empty canvas has\n // its press start outside the block, so the flag is false and we tear\n // down as normal. Do NOT reset the flag here: onDocumentClick fires for\n // this same click and must read the same value — onCaptureMouseDown is\n // the sole owner and re-sets it on the next press.\n if (pressStartedInActive) {\n return;\n }\n clearAll();\n return;\n }\n\n const domSaysEditing = block.classList.contains('cs-editing');\n const domSaysSelected = block.classList.contains('cs-selected');\n\n // If a teardown just happened in this same user gesture, force fresh-select.\n // Otherwise a single click could trigger both teardown + immediate edit-mode.\n if (forceFreshSelect) {\n forceFreshSelect = false;\n enterSelected(block);\n return;\n }\n\n if (domSaysEditing) return;\n if (domSaysSelected) {\n // A group is a move-only container — never enter text editing on it.\n if (block.classList.contains('cs-group-block')) return;\n // Pass the click point so the caret lands where the user clicked.\n enterEditing(block, { x: event.clientX, y: event.clientY });\n return;\n }\n enterSelected(block);\n };\n\n const isFroalaUi = (node) => {\n // Froala renders toolbars / popups / dropdowns into document.body. Any element\n // whose class starts with \"fr-\" should be treated as part of the active editor.\n for (let el = node; el && el !== document; el = el.parentElement) {\n if (el.classList && Array.from(el.classList).some((c) => c.startsWith('fr-'))) {\n return true;\n }\n }\n return false;\n };\n\n /**\n * Captures pointerdown/mousedown BEFORE Froala / other listeners. If the user\n * is pressing down on something that isn't the current editing block (and isn't\n * Froala UI), tear down so the subsequent click lands on a clean DOM. Uses\n * pointerdown AND mousedown: Froala doesn't capture pointerdown, so even if\n * its mousedown handler stops propagation, our pointerdown still runs.\n */\n const onCaptureMouseDown = (event) => {\n // selectedBlock OR editingBlock — both should tear down on outside click\n if (!editingBlock && !selectedBlock) {\n pressStartedInActive = false;\n return;\n }\n\n const target = event.target;\n const activeBlock = editingBlock || selectedBlock;\n\n // Remember whether this gesture began inside the active block so the trailing\n // `click` (which may land outside the page if the user drag-selected past the\n // block edge) isn't mistaken for an outside click that exits edit mode.\n pressStartedInActive = activeBlock.contains(target);\n\n // Inside the currently active block — leave alone (Froala / move-handle owns it)\n if (activeBlock.contains(target)) return;\n\n // Inside our own chrome (resize handle, badge) — leave alone\n if (target.closest && target.closest('[data-cs-chrome]')) return;\n\n // Inside Froala's floating UI (toolbar/popup/dropdown rendered to body)\n if (isFroalaUi(target)) return;\n\n // Pressing on another block or empty canvas → tear down now\n clearAll();\n };\n\n const onDocumentClick = (event) => {\n // If the click target is still attached to the document, use it as-is.\n // Otherwise — Froala may have reparented/destroyed it during a state change\n // mid-click. In that case use clientX/clientY to hit-test where the click\n // ACTUALLY landed, so we don't false-positive an \"outside\" click.\n let target = event.target;\n const detached = !document.contains(target);\n if (detached && typeof event.clientX === 'number') {\n target = document.elementFromPoint(event.clientX, event.clientY) || target;\n }\n\n if (target.closest && target.closest('.custom-form-design, [data-cs-chrome]')) return;\n if (isFroalaUi(target)) return;\n\n // Tail of a text-selection drag that began inside the block and released\n // fully outside the page: the browser fires `click` on a common ancestor\n // (e.g. .cs_paper) outside the canvas. The user never meant to click away,\n // so keep editing. Read-only: onSurfaceClick may have already handled this\n // same click — the flag is owned by onCaptureMouseDown, which re-sets it on\n // the next press, so neither click handler must consume it here.\n if (pressStartedInActive) {\n return;\n }\n\n clearAll();\n };\n\n const onKeydown = (event) => {\n // A rename is in progress — the label's own listeners own the keyboard.\n if (labelEdit) return;\n\n if (event.key === 'Escape') {\n clearAll();\n return;\n }\n\n // Ctrl/Cmd+R on the active block → rename it (edit the badge label).\n // preventDefault stops the browser's reload while a block is active.\n if ((event.ctrlKey || event.metaKey) && (event.key === 'r' || event.key === 'R')) {\n const block = selectedBlock || editingBlock;\n if (block) {\n event.preventDefault();\n startLabelEdit(block);\n }\n return;\n }\n\n // Arrow-key nudge — only for in-flexible blocks in selected state\n const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];\n if (!arrowKeys.includes(event.key)) return;\n\n const block = selectedBlock;\n if (!block) return;\n\n // Shift + Up/Down on a flow block → reorder it up/down (same as the badge\n // move buttons). In-section (absolute) blocks keep the nudge behaviour below.\n if (event.shiftKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown') && !block.dataset.csInSection) {\n event.preventDefault();\n window.FlowCanvas?.moveBlock?.(block, event.key === 'ArrowUp' ? 'up' : 'down');\n return;\n }\n\n if (!block.dataset.csInSection) return;\n\n event.preventDefault();\n\n const step = event.shiftKey ? 10 : 1;\n const parent = block.offsetParent;\n const { minLeft, maxLeft, minTop, maxTop } = getFlexibleMoveBounds(parent, block);\n\n let left = readRenderedPosition(block, 'left');\n let top = readRenderedPosition(block, 'top');\n\n if (event.key === 'ArrowLeft') left -= step;\n if (event.key === 'ArrowRight') left += step;\n if (event.key === 'ArrowUp') top -= step;\n if (event.key === 'ArrowDown') top += step;\n\n block.style.left = `${Math.min(Math.max(left, minLeft), maxLeft)}px`;\n block.style.top = `${Math.min(Math.max(top, minTop), maxTop)}px`;\n };\n\n /* ----------------------------- init ----------------------------- */\n\n const init = () => {\n const surface = dropSurface();\n if (!surface) return;\n\n // Capture-phase: runs BEFORE Froala's own handlers. This is what lets us\n // tear down the editing block the moment the user presses on another block.\n // Use BOTH mousedown and pointerdown — Froala may intercept mousedown, but\n // it doesn't capture pointerdown, so this guarantees we always fire.\n document.addEventListener('mousedown', onCaptureMouseDown, true);\n document.addEventListener('pointerdown', onCaptureMouseDown, true);\n\n // HTML5 DnD from the parent sidebar never fires mousedown in this iframe.\n // Listen wide (document, capture) for dragenter/dragover/drop so we tear\n // down the active editor the moment a new block drag enters the canvas.\n // Use throttle flag — dragover fires many times per second.\n let dragTeardownDone = false;\n const onDragSignal = () => {\n if (dragTeardownDone) return;\n if (editingBlock || selectedBlock) {\n clearAll();\n dragTeardownDone = true;\n }\n };\n const resetDragFlag = () => { dragTeardownDone = false; };\n document.addEventListener('dragenter', onDragSignal, true);\n document.addEventListener('dragover', onDragSignal, true);\n document.addEventListener('drop', (e) => { onDragSignal(); resetDragFlag(); }, true);\n document.addEventListener('dragend', resetDragFlag, true);\n document.addEventListener('dragleave', resetDragFlag, true);\n\n // Bulletproof safety net: if a new .cs_block_s appears in the canvas while\n // an editor is active on a DIFFERENT block, tear down. Catches every path\n // that creates a block — drag/drop, programmatic insertion, paste, etc.\n const observer = new MutationObserver((mutations) => {\n if (!editingBlock && !selectedBlock) return;\n for (const m of mutations) {\n for (const node of m.addedNodes) {\n if (node.nodeType !== 1) continue;\n const isBlock = node.classList && node.classList.contains('cs_block_s');\n const newBlock = isBlock ? node : (node.querySelector && node.querySelector('.cs_block_s'));\n if (newBlock && newBlock !== editingBlock && newBlock !== selectedBlock) {\n clearAll();\n return;\n }\n }\n }\n });\n observer.observe(surface, { childList: true, subtree: true });\n\n // Badge action buttons (move/duplicate/delete). Capture phase + stop\n // propagation so the click never reaches onSurfaceClick (which would toggle\n // edit mode) or starts a drag.\n document.addEventListener('click', (event) => {\n const btn = event.target.closest?.('[data-cs-action]');\n if (!btn) return;\n event.preventDefault();\n event.stopPropagation();\n const block = btn.closest('.cs_block_s');\n runBadgeAction(btn.dataset.csAction, block);\n }, true);\n\n surface.addEventListener('click', onSurfaceClick);\n document.addEventListener('click', onDocumentClick);\n document.addEventListener('keydown', onKeydown);\n\n // Pointer events for move + resize (delegated, captured at surface)\n surface.addEventListener('pointerdown', onMoveDown);\n document.addEventListener('pointermove', onMoveMove);\n document.addEventListener('pointerup', onMoveUp);\n document.addEventListener('pointercancel', onMoveUp);\n\n surface.addEventListener('pointerdown', onResizeDown);\n document.addEventListener('pointermove', onResizeMove);\n document.addEventListener('pointerup', onResizeUp);\n document.addEventListener('pointercancel', onResizeUp);\n };\n\n let lastSelectionRange = null;\n const updateSelectionRange = () => {\n const sel = document.getSelection();\n if (sel && sel.rangeCount > 0) {\n lastSelectionRange = sel.getRangeAt(0).cloneRange();\n }\n };\n\n document.addEventListener('selectionchange', updateSelectionRange);\n\n const insertTextAtCursor = (text) => {\n const doc = document;\n const selection = doc.getSelection();\n let range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;\n if (!range && lastSelectionRange) {\n range = lastSelectionRange.cloneRange();\n }\n if (!range) return false;\n\n range.deleteContents();\n range.insertNode(doc.createTextNode(text));\n range.collapse(false);\n if (selection) {\n selection.removeAllRanges();\n selection.addRange(range);\n }\n return true;\n };\n\n window.EditorManager = {\n init,\n clearAll,\n // Programmatically select a block (used by the panel's \"Choose parent\"\n // buttons). Mirrors a fresh user click → idle → selected.\n select: (block) => {\n if (!block || !block.classList || !block.classList.contains('cs_block_s')) return;\n enterSelected(block);\n try { block.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (e) { /* */ }\n },\n getSelected: () => selectedBlock,\n getEditing: () => editingBlock,\n getFroalaEditor: () => {\n if (!editingBlock) return null;\n return blockEditors.get(editingBlock) || null;\n },\n isInteracting: () => !!(move || resize),\n insertTextAtCursor,\n // Debug: prints what state the editor thinks it's in vs. the DOM.\n debug: () => {\n const selectedDom = document.querySelectorAll('.cs_block_s.cs-selected');\n const editingDom = document.querySelectorAll('.cs_block_s.cs-editing');\n const froalaUiInBody = document.querySelectorAll(\n 'body > .fr-toolbar, body > .fr-popup, body > .fr-modal, body > .fr-overlay, body > .fr-tooltip'\n );\n const frElements = document.querySelectorAll('.fr-element, .fr-box');\n console.log('[EditorManager.debug]', {\n ref_selectedBlock: selectedBlock,\n ref_editingBlock: editingBlock,\n dom_selected: selectedDom.length,\n dom_editing: editingDom.length,\n froala_ui_in_body: froalaUiInBody.length,\n leftover_fr_elements: frElements.length\n });\n }\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./editor/froala-style-handler.js\">\n/**\n * @fileoverview Froala Editor Style Commands Handler\n *\n * Provides a clean API to apply block styles through Froala editor commands\n * when a block is in editing mode. This ensures proper undo/redo integration\n * and consistency with Froala's internal state management.\n *\n * Exposes:\n * window.FroalaStyleHandler.applyColor(hexColor)\n * window.FroalaStyleHandler.applyBackgroundColor(hexColor)\n * window.FroalaStyleHandler.applyFontSize(sizeWithUnit)\n * window.FroalaStyleHandler.applyFontWeight(weightValue)\n * window.FroalaStyleHandler.applyTextAlign(alignValue)\n * window.FroalaStyleHandler.applyBold()\n * window.FroalaStyleHandler.applyItalic()\n * window.FroalaStyleHandler.applyUnderline()\n * window.FroalaStyleHandler.removeFormat()\n * window.FroalaStyleHandler.hasActiveEditor()\n * window.FroalaStyleHandler.getActiveEditor()\n */\n\n(function () {\n // Get the currently editing block's Froala editor instance\n // Uses EditorManager.getFroalaEditor() which is the authoritative source\n const getActiveFroalaEditor = () => {\n const manager = window.EditorManager;\n if (!manager || !manager.getFroalaEditor) return null;\n return manager.getFroalaEditor();\n };\n\n const applyStyleCommand = (commandName, ...args) => {\n const editor = getActiveFroalaEditor();\n if (!editor || !editor.commands) {\n console.warn(`FroalaStyleHandler: No active editor for command ${commandName}`);\n return false;\n }\n\n try {\n editor.commands.exec(commandName, args);\n return true;\n } catch (e) {\n console.error(`FroalaStyleHandler: Error executing ${commandName}:`, e);\n return false;\n }\n };\n\n window.FroalaStyleHandler = {\n /**\n * Apply text color via Froala color command\n * @param {string} hexColor - hex color code like '#FF0000'\n */\n applyColor(hexColor) {\n return applyStyleCommand('textColor', hexColor);\n },\n\n /**\n * Apply background color via Froala backgroundColor command\n * @param {string} hexColor - hex color code like '#FFFF00'\n */\n applyBackgroundColor(hexColor) {\n return applyStyleCommand('backgroundColor', hexColor);\n },\n\n /**\n * Apply font size via Froala fontSize command\n * @param {string} sizeWithUnit - like '16px' or '1.2rem'\n */\n applyFontSize(sizeWithUnit) {\n return applyStyleCommand('fontSize', sizeWithUnit);\n },\n\n /**\n * Apply font weight via Froala command\n * @param {string|number} weight - '400', '500', '600', '700' or 'normal', 'bold'\n */\n applyFontWeight(weight) {\n // Map numeric weights to Froala paragraph style names if needed\n const styleMap = {\n '300': 'font-weight-light',\n '400': 'normal',\n '500': 'font-weight-medium',\n '600': 'font-weight-semi-bold',\n '700': 'font-weight-bold',\n '800': 'bold'\n };\n\n const styleValue = styleMap[weight] || weight;\n\n // Apply via bold command for heavy weights\n if (weight === '700' || weight === '800') {\n return applyStyleCommand('bold');\n }\n\n // For paragraph styles, use paragraphStyle command\n if (styleValue in styleMap) {\n return applyStyleCommand('paragraphStyle', styleValue);\n }\n\n return false;\n },\n\n /**\n * Apply text alignment\n * @param {string} align - 'left', 'center', 'right', 'justify'\n */\n applyTextAlign(align) {\n return applyStyleCommand('align', align);\n },\n\n /**\n * Apply bold formatting\n */\n applyBold() {\n return applyStyleCommand('bold');\n },\n\n /**\n * Apply italic formatting\n */\n applyItalic() {\n return applyStyleCommand('italic');\n },\n\n /**\n * Apply underline formatting\n */\n applyUnderline() {\n return applyStyleCommand('underline');\n },\n\n /**\n * Remove all formatting\n */\n removeFormat() {\n return applyStyleCommand('removeFormat');\n },\n\n /**\n * Check if there's an active Froala editor\n */\n hasActiveEditor() {\n return !!getActiveFroalaEditor();\n },\n\n /**\n * Get the active Froala editor instance (for advanced usage)\n */\n getActiveEditor() {\n return getActiveFroalaEditor();\n },\n\n /**\n * Debug: Print current editor state\n */\n debug() {\n const editor = getActiveFroalaEditor();\n const manager = window.EditorManager;\n console.log('FroalaStyleHandler Debug:', {\n hasEditor: !!editor,\n hasEditorManager: !!manager,\n editingBlock: manager?.getEditing?.(),\n froalaEditor: editor ? 'Active' : 'Inactive'\n });\n }\n };\n\n console.log('froala-style-handler: initialized');\n})();\n\n<\/script>\n <script data-src=\"./js/block-creator.js\">\n/**\n * @fileoverview Block creator for custom form editor\n * Generates DOM elements matching the TextBlocks.js pattern from /var/www/html/cse3/\n */\n\nclass BlockCreator {\n constructor() {\n // this.utils = new Utils();\n }\n\n /**\n * Creates a unique hash for element IDs\n */\n generateHash() {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n // Fallback for environments without crypto.randomUUID\n return Math.random().toString(16).slice(2) + '-' + Math.random().toString(16).slice(2);\n }\n\n /**\n * Base wrapper element (cs_block_s)\n * @param {string} blockType - The block type (data attribute value, e.g., 'Title')\n * @param {string} additionalClasses - Extra CSS classes\n * @returns {HTMLElement}\n */\n getCsBlockSmall(blockType, additionalClasses = '') {\n const element = document.createElement('div');\n const classList = `cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block ${additionalClasses}`;\n\n element.setAttribute('class', classList);\n element.setAttribute('data', blockType);\n element.setAttribute('custom-name', blockType);\n element.setAttribute('id', 'block_' + this.generateHash());\n if (blockType == 'Title' || blockType == 'Textarea') {\n element.style.setProperty('padding-left', '10px');\n }\n\n return element;\n }\n\n /**\n * Wraps content in a cs_block_s wrapper\n * @param {HTMLElement|HTMLElement[]} contentElement\n * @param {string} blockType\n * @param {string} additionalClasses\n * @returns {HTMLElement}\n */\n addElementToCSBlock(contentElement, blockType, additionalClasses = '') {\n const block = this.getCsBlockSmall(blockType, additionalClasses);\n\n if (Array.isArray(contentElement)) {\n block.append(...contentElement);\n } else {\n block.append(contentElement);\n }\n\n return block;\n }\n\n /**\n * Creates an editable heading/title element\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createEditableHeading(options = {}) {\n const {\n text = 'Heading 2',\n className = 'add-heading-two',\n fontSize = '32px',\n // fontWeight = 100,\n placeholder = null\n } = options;\n\n const wrapper = document.createElement('div');\n wrapper.className = `edit_me ${className}`;\n wrapper.id = `dynamic_${this.generateHash()}`;\n wrapper.setAttribute('placeholder', placeholder);\n wrapper.setAttribute('default-style-id', '');\n wrapper.style.fontSize = fontSize;\n // wrapper.style.fontWeight = fontWeight;\n wrapper.style.borderColor = 'rgb(89, 91, 101)';\n\n return wrapper;\n }\n\n /**\n * Creates a body text paragraph element\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createBodyParagraph(options = {}) {\n const {\n text = '',\n fontSize = '14px',\n // fontWeight = 400,\n placeholder = 'Enter text here...'\n } = options;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'edit_me fr-element fr-view resize';\n wrapper.id = `dynamic_${this.generateHash()}`;\n wrapper.setAttribute('placeholder', placeholder);\n wrapper.style.fontSize = fontSize;\n // wrapper.style.fontWeight = fontWeight;\n if (text) {\n wrapper.innerHTML = text;\n }\n\n return wrapper;\n }\n\n /**\n * Creates a complete Title block (heading)\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createTitleBlock(options = {}) {\n const {\n text = 'Heading 2',\n className = 'add-heading-two',\n fontSize = '32px',\n // fontWeight = 100,\n position = { left: '96px', top: '75px' },\n width = 'auto',\n maxWidth = '692px'\n } = options;\n\n const content = this.createEditableHeading({\n text,\n className,\n fontSize,\n // fontWeight,\n placeholder: text\n });\n\n const block = this.addElementToCSBlock(content, 'Title');\n\n // Apply positioning and sizing\n block.style.position = 'absolute';\n block.style.left = position.left;\n block.style.top = position.top;\n if (width !== 'auto') {\n block.style.width = width;\n }\n block.style.maxWidth = maxWidth;\n\n return block;\n }\n\n /**\n * Creates a complete Body Text block (textarea/paragraph)\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createBodyTextBlock(options = {}) {\n const {\n text = '',\n fontSize = '14px',\n // fontWeight = 400,\n position = { left: '96px', top: '140px' },\n width = 'auto',\n maxWidth = '692px'\n } = options;\n\n const content = this.createBodyParagraph({\n text,\n fontSize,\n // fontWeight,\n placeholder: 'Enter text here...'\n });\n\n const block = this.addElementToCSBlock(content, 'Textarea');\n\n // Apply positioning and sizing\n block.style.position = 'absolute';\n block.style.left = position.left;\n block.style.top = position.top;\n if (width !== 'auto') {\n block.style.width = width;\n }\n block.style.maxWidth = maxWidth;\n\n return block;\n }\n\n createTableBase({ headerBg = '#F8F9F9', headerColor = '#000', wrapperClass = 'edit_me fr-element fr-view' } = {}) {\n const editWrapper = document.createElement('div');\n editWrapper.className = wrapperClass;\n editWrapper.id = `dynamic_${this.generateHash()}`;\n\n const tableContainer = document.createElement('div');\n tableContainer.className = 'fr-element fr-view froala-table normal-table-width editor-table-container';\n\n const table = document.createElement('table');\n\n const thead = document.createElement('thead');\n const headerRow = document.createElement('tr');\n Object.assign(headerRow.style, {\n color: headerColor,\n });\n\n ['', '', '', ''].forEach(() => {\n const th = document.createElement('th');\n th.textContent = '';\n headerRow.appendChild(th);\n });\n thead.appendChild(headerRow);\n\n const tbody = document.createElement('tbody');\n const rows = [\n ['', '', '', ''],\n ['', '', '', ''],\n ['', '', '', '']\n ];\n\n rows.forEach((rowData) => {\n const tr = document.createElement('tr');\n rowData.forEach(() => {\n const td = document.createElement('td');\n td.textContent = '';\n tr.appendChild(td);\n });\n tbody.appendChild(tr);\n });\n\n table.appendChild(thead);\n table.appendChild(tbody);\n tableContainer.appendChild(table);\n editWrapper.appendChild(tableContainer);\n\n return editWrapper;\n }\n\n createWhiteHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#F8F9F9', headerColor: '#000' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createBlueHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#3883C1', headerColor: '#FFF' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createLightBlueHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#6493B5', headerColor: '#FFF' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createGrayHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#6B7A85', headerColor: '#FFF' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createSectionContainerBlock(options = {}) {\n const {\n title = 'Section heading goes here',\n body = 'Drop more blocks around this area to visually frame content groups.',\n titleFontSize = '24px',\n bodyFontSize = '14px',\n width = 'auto',\n maxWidth = '760px'\n } = options;\n\n const content = document.createElement('div');\n content.className = 'section-container-content';\n content.id = `dynamic_${this.generateHash()}`;\n\n\n const block = this.addElementToCSBlock(content, 'Section Container');\n block.style.width = width !== 'auto' ? width : '';\n block.style.maxWidth = maxWidth;\n block.style.position = 'absolute';\n return block;\n }\n\n /**\n * Creates both a title and body text block together\n * @param {Object} options\n * @returns {Object} { titleBlock, bodyBlock }\n */\n createTitleAndBodyBlock(options = {}) {\n const {\n titleText = 'Heading 2',\n titleClass = 'add-heading-two',\n titleFontSize = '32px',\n bodyText = 'Body text goes here',\n bodyFontSize = '14px',\n titlePosition = { left: '96px', top: '75px' },\n bodyPosition = { left: '96px', top: '140px' },\n spacing = 65 // gap between title and body\n } = options;\n\n const titleBlock = this.createTitleBlock({\n text: titleText,\n className: titleClass,\n fontSize: titleFontSize,\n // fontWeight: 700,\n position: titlePosition,\n maxWidth: '692px'\n });\n\n const bodyBlock = this.createBodyTextBlock({\n text: bodyText,\n fontSize: bodyFontSize,\n // fontWeight: 400,\n position: {\n left: bodyPosition.left,\n top: bodyPosition.top\n },\n maxWidth: '692px'\n });\n\n return { titleBlock, bodyBlock };\n }\n\n /* ===============================\n IMAGE / VIDEO\n =============================== */\n\n createImageWrapper(dynamicClass) {\n const el = document.createElement('div');\n el.className = `${dynamicClass} image-container`;\n el.id = `image_${this.generateHash()}`;\n return el;\n }\n\n createImageButton(type = '') {\n const btn = document.createElement('div');\n btn.className = 'img-btn resize';\n btn.id = type === 'image' ? `image_1` : `video_1`;\n\n const icongroup = document.createElement('div');\n icongroup.className = 'icon-group';\n\n const iconLayer = document.createElement('div');\n iconLayer.className = 'icon-layer';\n const icon = document.createElement('i');\n if (type === 'image') {\n icon.className = 'fa-regular fa-image plus-img-icon';\n } else {\n icon.className = 'fa-brands fa-youtube plus-img-icon';\n }\n iconLayer.appendChild(icon);\n\n const iconTitle = document.createElement('div');\n iconTitle.className = 'img-btn-txt';\n iconTitle.textContent = type === 'image' ? 'Click to select image' : 'Click to select video';\n\n icongroup.append(iconLayer, iconTitle);\n btn.appendChild(icongroup);\n\n return btn;\n }\n\n createSquareImageBlock() {\n const imageWrapper = this.createImageWrapper('square-image');\n const imageButton = this.createImageButton('image');\n imageWrapper.appendChild(imageButton);\n const block = this.addElementToCSBlock(imageWrapper, 'Image', 'cs-image-block');\n imageWrapper.style.setProperty('height', '100px', 'important');\n imageWrapper.style.setProperty('aspect-ratio', 'auto', 'important');\n return block;\n }\n\n createVideoBlock() {\n const iframe = this.createImageButton('video');\n const block = this.addElementToCSBlock(iframe, 'Video', 'cs-video-block');\n iframe.style.setProperty('height', '100px', 'important');\n return block;\n }\n}\n\n// Export or attach to window\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = BlockCreator;\n} else {\n window.BlockCreator = BlockCreator;\n}\n\n<\/script>\n <script data-src=\"./js/canvas-config.js\">\n/**\n * @fileoverview Canvas-level configuration.\n *\n * Centralized settings for the flow canvas. Tune these to change page size,\n * column behavior, drop-zone sensitivity, etc. without touching the canvas\n * logic. Loaded BEFORE flow-canvas.js so module code can read window.CanvasConfig.\n */\n(function () {\n // Page-size catalog. Keys are the IDs used everywhere (editor dropdown,\n // pdfSettings.pageSize, PDF_PAGE_SIZE env var). Width / height are in\n // CSS px at 96 dpi — these are the physical paper dimensions, so the\n // canvas .cs_margin matches what the printed PDF page will be.\n const PageSizes = {\n 'A4': { label: 'A4 Portrait', width: 794, height: 1123, format: 'A4', landscape: false },\n 'A4-Landscape': { label: 'A4 Landscape', width: 1123, height: 794, format: 'A4', landscape: true },\n 'Letter': { label: 'Letter Portrait', width: 816, height: 1056, format: 'Letter', landscape: false },\n 'Letter-Landscape': { label: 'Letter Landscape', width: 1056, height: 816, format: 'Letter', landscape: true },\n };\n\n const DEFAULT_PAGE_KEY = 'A4';\n\n const Config = {\n /** Page dimensions — controls the visible .cs_margin box. */\n page: {\n sizeKey: DEFAULT_PAGE_KEY,\n width: PageSizes[DEFAULT_PAGE_KEY].width,\n minHeight: PageSizes[DEFAULT_PAGE_KEY].height,\n paddingTop: 16,\n paddingRight: 16,\n paddingBottom: 16,\n paddingLeft: 16,\n background: '#ffffff',\n backgroundImage: '',\n borderColor: '#cfd4f6',\n borderWidth: 1,\n borderRadius: 4,\n shadow: '0 4px 20px rgba(0, 0, 0, 0.08)'\n },\n\n /** Row defaults. */\n row: {\n gap: 0, // px between columns (excluding divider)\n marginBottom: 8, // px between consecutive rows\n minHeight: 40 // px — empty row visual height\n },\n\n /** Column defaults. */\n column: {\n minWidth: 60, // px — smallest a column can shrink to during resize\n minHeight: 40, // px — empty column visual height\n padding: 4 // px — interior padding\n },\n\n /** Section container (mini-canvas inside a column). */\n section: {\n minHeight: 160, // px — default min-height after first child drop\n background: '#e5e7e7',\n defaultWidth: null // null = fill column. Set a number to cap.\n },\n\n /** Flexible (free-form) block — and its absolutely-positioned children. */\n flexible: {\n defaultHeight: 80, // px — height of a freshly dropped empty flexible block\n minHeight: 20, // px — smallest height it can be resized to\n minWidth: 20 // px — smallest width a free-form block can be resized to\n },\n\n /** Drop-zone detection sensitivity. */\n dropZone: {\n rowEdgeGap: 12, // px — distance from row edge to count as \"between rows\"\n colEdgeGap: 24 // px — distance from col edge to count as \"new column\"\n },\n\n /** Visual indicator while dragging. */\n indicator: {\n color: '#5c5cff',\n thickness: 3,\n glowAlpha: 0.25\n },\n\n /** Inline \"+\" insert control. */\n inlineInsert: {\n enabled: true\n },\n\n /**\n * Editor engine switch — flip this one boolean to swap the whole editor.\n * useFroala: false → NEW custom logic (CustomRichEditor for text blocks +\n * the custom static Table block). The default.\n * useFroala: true → LEGACY Froala editor for text; the custom Table\n * engine is turned off (Froala-era behaviour).\n */\n editor: {\n useFroala: false,\n // Placement of the CustomRichEditor (useFroala:false) toolbar while a text\n // block is being edited:\n // dockRichToolbar: false → INLINE — bar floats above the active block\n // (default; follows the caret's block).\n // dockRichToolbar: true → DOCKED — bar pins to the top of the canvas\n // viewport as a full-width sticky strip.\n // Toggled live from the Angular \"Page Settings\" panel (Inline text\n // toolbar switch) via the 'rich-toolbar:dock' postMessage.\n dockRichToolbar: false\n }\n };\n\n // Make available on window so flow-canvas.js and CSS-via-JS can read it.\n window.CanvasConfig = Config;\n window.CanvasPageSizes = PageSizes;\n\n // Convenience accessor used by inline-editor.js / table-block.js to decide\n // which editor engine to run. Reads the live config each call.\n window.isFroalaEditor = () => !!(window.CanvasConfig && window.CanvasConfig.editor && window.CanvasConfig.editor.useFroala);\n\n // True when the CustomRichEditor toolbar should dock to the top of the canvas\n // (vs. float inline above the active block). Read live by rich-text-editor.js.\n window.isRichToolbarDocked = () => !!(window.CanvasConfig && window.CanvasConfig.editor && window.CanvasConfig.editor.dockRichToolbar);\n\n // Flip the toolbar placement at runtime and notify any open editor so the live\n // toolbar re-positions immediately (without needing to re-open the block).\n window.setRichToolbarDocked = function (docked) {\n if (!window.CanvasConfig || !window.CanvasConfig.editor) return;\n window.CanvasConfig.editor.dockRichToolbar = !!docked;\n document.dispatchEvent(new CustomEvent('canvas:rich-toolbar-mode', { detail: { docked: !!docked } }));\n };\n\n // Switch the editor canvas to a different paper size at runtime.\n // Re-applies the CSS vars so every .cs_margin updates in place. Existing\n // block widths (set inline by the user) are preserved on purpose.\n window.setCanvasPageSize = function (sizeKey) {\n const size = PageSizes[sizeKey];\n if (!size) {\n console.warn('[CanvasConfig] unknown page size:', sizeKey);\n return false;\n }\n Config.page.sizeKey = sizeKey;\n Config.page.width = size.width;\n Config.page.minHeight = size.height;\n applyPageVars();\n // Notify listeners (overflow indicator, etc.) so they can recompute.\n document.dispatchEvent(new CustomEvent('canvas:page-size-changed', {\n detail: { sizeKey, width: size.width, height: size.height }\n }));\n return true;\n };\n\n // Set the page background image. Accepts a URL or base64 data URL.\n window.setCanvasPageBackground = function (imageUrl) {\n Config.page.backgroundImage = imageUrl || '';\n applyPageVars();\n };\n\n // Apply page styles to .cs_margin as CSS custom properties so the stylesheet\n // can pick them up without hardcoding values.\n const applyPageVars = () => {\n const root = document.documentElement;\n root.style.setProperty('--cs-page-width', `${Config.page.width}px`);\n root.style.setProperty('--cs-page-min-height', `${Config.page.minHeight}px`);\n root.style.setProperty('--cs-page-padding',\n `${Config.page.paddingTop}px ${Config.page.paddingRight}px ${Config.page.paddingBottom}px ${Config.page.paddingLeft}px`);\n root.style.setProperty('--cs-page-bg', Config.page.background);\n root.style.setProperty('--cs-page-bg-image', Config.page.backgroundImage ? `url(\"${Config.page.backgroundImage}\")` : 'none');\n root.style.setProperty('--cs-page-border', `${Config.page.borderWidth}px solid ${Config.page.borderColor}`);\n root.style.setProperty('--cs-page-radius', `${Config.page.borderRadius}px`);\n root.style.setProperty('--cs-page-shadow', Config.page.shadow);\n root.style.setProperty('--row-item-margin-bottom', `${Config.row.marginBottom}px`);\n root.style.setProperty('--row-item-min-height', `${Config.row.minHeight}px`);\n root.style.setProperty('--col-item-min-width', `${Config.column.minWidth}px`);\n root.style.setProperty('--col-item-min-height', `${Config.column.minHeight}px`);\n root.style.setProperty('--col-item-padding', `${Config.column.padding}px`);\n root.style.setProperty('--cs-section-min-height', `${Config.section.minHeight}px`);\n root.style.setProperty('--cs-section-bg', Config.section.background);\n root.style.setProperty('--cs-indicator-color', Config.indicator.color);\n root.style.setProperty('--cs-indicator-thickness', `${Config.indicator.thickness}px`);\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', applyPageVars);\n } else {\n applyPageVars();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/custom-form.js\">\nconst dragStoreKey = '__BROCHURE_FLOW_DRAG__';\nconst dropSurface = document.querySelector('.custom-form-design');\nconst emptyState = null; // No empty state in new structure\nconst blockCreator = new BlockCreator(); // Initialize BlockCreator\n\nconst blockPresets = {\n 'hero-section': {\n label: 'Hero Section',\n width: 640,\n html: `\n <div class=\"block-card block-card--hero\">\n <span class=\"canvas-block__tag\">Hero</span>\n <h2>Build brochure sections visually</h2>\n <p>Combine content blocks, reposition them freely, and prepare a polished export layout without leaving the canvas.</p>\n <div class=\"block-actions\">\n <span class=\"block-pill block-pill--primary\">Primary CTA</span>\n <span class=\"block-pill\">Secondary CTA</span>\n </div>\n </div>\n `\n },\n 'multi-column': {\n label: 'Multi-Column',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Columns</span>\n <div class=\"block-columns\">\n <div class=\"block-column\">\n <strong>Left Column</strong>\n <p class=\"block-paragraph\">Use this space for supporting brochure copy or highlights.</p>\n </div>\n <div class=\"block-column\">\n <strong>Right Column</strong>\n <p class=\"block-paragraph\">Drop other elements nearby and arrange the layout visually.</p>\n </div>\n </div>\n </div>\n `\n },\n 'image-text': {\n label: 'Image + Text',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Media</span>\n <div class=\"block-media\">\n <div class=\"block-image\"></div>\n <div class=\"block-copy\">\n <h3>Image with supporting content</h3>\n <p class=\"block-paragraph\">Pair visuals with concise descriptive text for product, service, or campaign sections.</p>\n </div>\n </div>\n </div>\n `\n },\n 'pricing-block': {\n label: 'Pricing Block',\n width: 650,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Pricing</span>\n <div class=\"block-pricing\">\n <div class=\"block-price-card\">\n <h3>Starter</h3>\n <strong>$19</strong>\n <p class=\"block-paragraph\">Simple intro package.</p>\n </div>\n <div class=\"block-price-card\">\n <h3>Growth</h3>\n <strong>$49</strong>\n <p class=\"block-paragraph\">Popular brochure option.</p>\n </div>\n <div class=\"block-price-card\">\n <h3>Scale</h3>\n <strong>$99</strong>\n <p class=\"block-paragraph\">Advanced presentation tier.</p>\n </div>\n </div>\n </div>\n `\n },\n footer: {\n label: 'Footer',\n width: 640,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Footer</span>\n <div class=\"block-footer\">\n <strong>BrochureFlow</strong>\n <div class=\"block-footer__links\">\n <span>About</span>\n <span>Contact</span>\n <span>Support</span>\n </div>\n </div>\n </div>\n `\n },\n heading: {\n label: 'Heading',\n width: 420,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Heading</span>\n <h2 class=\"block-heading\">Section heading goes here</h2>\n </div>\n `\n },\n 'body-text': {\n label: 'Body Text',\n width: 420,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Paragraph</span>\n <p class=\"block-paragraph\">Use this body text block for descriptive copy, feature explanations, or brochure summaries.</p>\n </div>\n `\n },\n 'label-tag': {\n label: 'Label / Tag',\n width: 220,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Label</span>\n <span class=\"block-label\">Featured</span>\n </div>\n `\n },\n image: {\n label: 'Image',\n width: 320,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Image</span>\n <div class=\"block-image\"></div>\n </div>\n `\n },\n button: {\n label: 'Button',\n width: 220,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Button</span>\n <span class=\"block-button\">Call to Action</span>\n </div>\n `\n },\n divider: {\n label: 'Divider',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Divider</span>\n <div class=\"block-divider\"></div>\n </div>\n `\n },\n spacer: {\n label: 'Spacer',\n width: 420,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Spacer</span>\n <div class=\"block-spacer\"></div>\n </div>\n `\n },\n 'section-container': {\n label: 'Section Container',\n width: 640,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Container</span>\n <div class=\"block-container\">\n <strong>Reusable section container</strong>\n <p class=\"block-paragraph\">Drop more blocks around this area to visually frame content groups.</p>\n </div>\n </div>\n `\n },\n 'table-repeater': {\n label: 'Table Repeater',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Table Repeater</span>\n <div class=\"block-table\">\n <div class=\"block-table__row\">\n <span>Item Name</span>\n <span>Qty</span>\n <span>Price</span>\n </div>\n <div class=\"block-table__row\">\n <span>Dynamic Row</span>\n <span>1</span>\n <span>$24</span>\n </div>\n </div>\n </div>\n `\n },\n 'data-field': {\n label: 'Data Field',\n width: 300,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Data Field</span>\n <div class=\"block-input\">{{ customer.name }}</div>\n </div>\n `\n },\n 'list-repeater': {\n label: 'List Repeater',\n width: 340,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">List Repeater</span>\n <ul class=\"block-list\">\n <li>Dynamic list item one</li>\n <li>Dynamic list item two</li>\n <li>Dynamic list item three</li>\n </ul>\n </div>\n `\n },\n rectangle: {\n label: 'Rectangle',\n width: 320,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Rectangle</span>\n <div class=\"block-shape block-shape--rectangle\"></div>\n </div>\n `\n },\n circle: {\n label: 'Circle',\n width: 220,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Circle</span>\n <div class=\"block-shape block-shape--circle\"></div>\n </div>\n `\n },\n 'icon-badge': {\n label: 'Icon Badge',\n width: 160,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Icon Badge</span>\n <div class=\"block-icon-badge\">★</div>\n </div>\n `\n }\n};\n\nlet selectedBlock = null;\nlet activeMove = null;\n\nconst clamp = (value, min, max) => Math.min(Math.max(value, min), max);\n\nconst getParentPayload = () => {\n try {\n return window.parent?.[dragStoreKey] ?? null;\n } catch (error) {\n return null;\n }\n};\n\nconst parsePayload = (value) => {\n if (!value) {\n return null;\n }\n\n try {\n return JSON.parse(value);\n } catch (error) {\n return null;\n }\n};\n\nconst getDragPayload = (event) => {\n const directPayload =\n parsePayload(event.dataTransfer?.getData('application/x-brochure-block')) ||\n parsePayload(event.dataTransfer?.getData('text/plain'));\n\n if (directPayload?.blockType) {\n console.log('custom-form: direct payload', directPayload);\n return directPayload;\n }\n\n const fallbackPayload = getParentPayload();\n console.log('custom-form: fallback payload', fallbackPayload);\n return fallbackPayload?.blockType ? fallbackPayload : null;\n};\n\nconst getParentBindingData = () => {\n try {\n // First, try to use the getter function if available\n const getter = window.parent?.__BROCHURE_FLOW_GET_BINDING_DATA__;\n if (typeof getter === 'function') {\n const data = getter();\n console.log('custom-form: Got binding data from getter:', data);\n return data;\n }\n\n // Fallback to direct property access\n const data = window.parent?.__BROCHURE_FLOW_BINDING_DATA__;\n if (data) {\n console.log('custom-form: Got binding data from property:', data);\n return data;\n }\n\n console.warn('custom-form: Binding data not found on parent window');\n console.log('custom-form: Parent window keys:', Object.keys(window.parent || {}));\n return null;\n } catch (error) {\n console.error('custom-form: Failed to get binding data:', error);\n return null;\n }\n};\n\n// Walk the binding data and collect array paths. When topLevelOnly is true we\n// stop the moment we find an array — nested arrays inside it stay hidden and\n// only surface later when the user drops a child block inside the configured\n// section (scoped arrays via computeScopedArrays).\nconst buildBindingArrays = (data, prefix = '', topLevelOnly = false) => {\n const arrays = [];\n\n if (Array.isArray(data)) {\n const preview = data.length && data[0] && typeof data[0] === 'object'\n ? Object.keys(data[0]).slice(0, 3).join(', ')\n : String(data[0] ?? '');\n\n if (prefix) {\n arrays.push({\n path: prefix,\n count: data.length,\n preview,\n scope: 'root'\n });\n }\n\n if (topLevelOnly) return arrays;\n\n if (data.length && data[0] && typeof data[0] === 'object') {\n arrays.push(...buildBindingArrays(data[0], prefix, topLevelOnly));\n }\n return arrays;\n }\n\n if (data && typeof data === 'object') {\n Object.keys(data).forEach((key) => {\n const nextPrefix = prefix ? `${prefix}.${key}` : key;\n arrays.push(...buildBindingArrays(data[key], nextPrefix, topLevelOnly));\n });\n }\n\n return arrays;\n};\n\nlet sectionBindingModal = null;\nlet sectionBindingTarget = null;\nlet sectionBindingSelection = null;\nlet sectionBindingAlias = 'section';\nlet sectionBindingSelectCallback = null;\n\nconst populateSectionBindingList = (modal, items) => {\n const listElement = modal.querySelector('.section-binding-list');\n if (!listElement) {\n return;\n }\n\n listElement.innerHTML = '';\n if (!items.length) {\n const empty = document.createElement('div');\n empty.className = 'section-binding-empty';\n empty.textContent = 'No arrays could be detected from the current JSON binding source.';\n listElement.appendChild(empty);\n return;\n }\n\n items.forEach((item) => {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'section-binding-array-item';\n button.dataset.path = item.path;\n button.innerHTML = `\n <div class=\"section-binding-array-item__row\">\n <span class=\"section-binding-array-item__path\">${item.path}</span>\n <span class=\"section-binding-array-item__count\">${item.count} items</span>\n </div>\n <div class=\"section-binding-array-item__preview\">${item.preview}</div>\n `;\n button.addEventListener('click', () => {\n sectionBindingSelectCallback?.(item);\n });\n listElement.appendChild(button);\n });\n};\n\nconst hideSectionBindingModal = () => {\n if (!sectionBindingModal) {\n return;\n }\n sectionBindingModal.hidden = true;\n sectionBindingTarget = null;\n sectionBindingSelection = null;\n};\n\nconst createSectionBindingModal = () => {\n if (sectionBindingModal) {\n return sectionBindingModal;\n }\n\n const modal = document.createElement('div');\n modal.className = 'section-binding-modal';\n modal.hidden = true;\n modal.innerHTML = `\n <div class=\"section-binding-backdrop\"></div>\n <div class=\"section-binding-card\">\n <header class=\"section-binding-header\">\n <div>\n <div class=\"section-binding-title\">Bind Section Loop</div>\n <div class=\"section-binding-subtitle\">Choose which JSON array this section should repeat over</div>\n </div>\n <button type=\"button\" class=\"section-binding-close\" aria-label=\"Close\">×</button>\n </header>\n <div class=\"section-binding-grid\">\n <div class=\"section-binding-list-card\">\n <div class=\"section-binding-list-title\">Detected arrays <span class=\"section-binding-badge\"></span></div>\n <div class=\"section-binding-list\"></div>\n </div>\n <div class=\"section-binding-config-card\">\n <div class=\"section-binding-field-label\">Selected array path</div>\n <div class=\"section-binding-field section-binding-field--readonly\" data-selected-path>← Select an array on the left</div>\n <label class=\"section-binding-field-label\">Loop variable name (alias)</label>\n <input type=\"text\" class=\"section-binding-input\" value=\"section\" />\n <div class=\"section-binding-generated-code\">\n <div class=\"section-binding-code-title\">Generated Twig</div>\n <pre class=\"section-binding-code\">Select an array to see generated code</pre>\n </div>\n </div>\n </div>\n <div class=\"section-binding-footer\">\n <button type=\"button\" class=\"section-binding-skip\">Skip — I’ll configure later</button>\n <button type=\"button\" class=\"section-binding-apply\" disabled>Apply Binding</button>\n </div>\n </div>\n `;\n\n document.body.appendChild(modal);\n\n const listElement = modal.querySelector('.section-binding-list');\n const selectedPathElement = modal.querySelector('[data-selected-path]');\n const aliasInput = modal.querySelector('.section-binding-input');\n const codeElement = modal.querySelector('.section-binding-code');\n const badgeElement = modal.querySelector('.section-binding-badge');\n const applyButton = modal.querySelector('.section-binding-apply');\n const closeButton = modal.querySelector('.section-binding-close');\n const skipButton = modal.querySelector('.section-binding-skip');\n const backdrop = modal.querySelector('.section-binding-backdrop');\n\n const renderCodePreview = () => {\n if (!sectionBindingSelection) {\n codeElement.textContent = 'Select an array to see generated code';\n return;\n }\n\n codeElement.textContent = `\\{% for ${sectionBindingAlias} in ${sectionBindingSelection.path} %}\\n {{ ${sectionBindingAlias}.field }}\\n\\{% endfor %}`;\n };\n\n const updateSelection = (item) => {\n sectionBindingSelection = item;\n selectedPathElement.textContent = item.path;\n applyButton.disabled = false;\n renderCodePreview();\n sectionBindingModal.querySelectorAll('.section-binding-array-item').forEach((button) => {\n button.classList.toggle('section-binding-array-item--selected', button.dataset.path === item.path);\n });\n };\n\n sectionBindingSelectCallback = updateSelection;\n\n aliasInput.addEventListener('input', () => {\n sectionBindingAlias = aliasInput.value.trim() || 'section';\n renderCodePreview();\n });\n\n applyButton.addEventListener('click', (event) => {\n event.preventDefault();\n event.stopPropagation();\n\n if (!sectionBindingTarget || !sectionBindingSelection) {\n return;\n }\n\n sectionBindingTarget.dataset.repeatPath = sectionBindingSelection.path;\n sectionBindingTarget.dataset.repeatAlias = sectionBindingAlias;\n sectionBindingTarget.dataset.repeatLabel = sectionBindingSelection.path;\n\n let info = sectionBindingTarget.querySelector('.section-binding-info');\n if (!info) {\n info = document.createElement('div');\n info.className = 'section-binding-info';\n sectionBindingTarget.appendChild(info);\n }\n info.textContent = `Repeats ${sectionBindingSelection.path}`;\n handleClose();\n });\n\n const handleClose = (event) => {\n if (event) {\n event.preventDefault();\n event.stopPropagation();\n }\n console.log('custom-form: Modal close triggered');\n hideSectionBindingModal();\n };\n\n closeButton.addEventListener('click', handleClose, true);\n skipButton.addEventListener('click', handleClose, true);\n backdrop.addEventListener('click', handleClose, true);\n\n modal.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') {\n handleClose(event);\n }\n });\n\n sectionBindingModal = modal;\n return sectionBindingModal;\n};\n\n// Find every block in the SAME scope as the freshly-dropped block that already\n// has a repeat binding. Root drop → search whole doc. Inside a section → search\n// within that section only. We use this to dim already-bound paths in the modal\n// so the user can't bind two siblings to the same array.\nconst collectSiblingBoundPaths = (block) => {\n const paths = new Set();\n if (!block) return paths;\n\n let searchRoot = null;\n let cur = block.parentElement || null;\n while (cur) {\n if (cur.dataset?.repeatPath) { searchRoot = cur; break; }\n if (cur.classList?.contains('cs_margin') || cur.tagName === 'BODY') {\n searchRoot = cur;\n break;\n }\n cur = cur.parentElement;\n }\n if (!searchRoot) searchRoot = document.querySelector('.cs_margin') || document.body;\n\n searchRoot.querySelectorAll('[data-repeat-path]').forEach((el) => {\n if (el === block) return;\n const path = el.dataset.repeatPath;\n if (path) paths.add(path);\n });\n return paths;\n};\n\nconst showSectionBindingModal = (block) => {\n // Modal UI lives in the parent Angular app so it can cover the full page\n // with a backdrop. We just send a message identifying which block needs a\n // binding, plus the list of detectable arrays for the user to pick from.\n if (!block.id) {\n block.id = 'block_' + Math.random().toString(36).substr(2, 9);\n }\n\n const bindingData = getParentBindingData();\n\n // Tree-aware modal:\n // - Block has ancestor repeater → arrays = full nested tree under the\n // innermost ancestor's iteration (each row carries the for-loop chain\n // needed to reach it).\n // - Root canvas drop → arrays = full nested tree from root (top-level\n // arrays + every nested array inside them).\n let scopedArrays = [];\n let ancestorAlias = '';\n if (window.FlowCanvas?.computeScopedArrays) {\n const scoped = window.FlowCanvas.computeScopedArrays(block, bindingData);\n if (scoped) {\n scopedArrays = scoped.arrays || [];\n ancestorAlias = scoped.alias || '';\n }\n }\n\n let arrays;\n if (ancestorAlias) {\n arrays = scopedArrays;\n } else if (window.FlowCanvas?.buildRootArrayTree) {\n arrays = window.FlowCanvas.buildRootArrayTree(bindingData);\n } else {\n arrays = bindingData ? buildBindingArrays(bindingData, '', true) : [];\n }\n\n const disabledPaths = [];\n\n const blockType = block.dataset.blockType ||\n block.getAttribute('data') ||\n 'block';\n\n // No arrays available → modal skip pannidu, block mattum drop aagattum.\n if (!arrays.length) {\n console.log('custom-form: no arrays available for binding, skipping modal');\n return;\n }\n\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'binding-modal:open',\n data: {\n blockId: block.id,\n blockType,\n arrays,\n ancestorAlias,\n disabledPaths\n }\n }, '*');\n } catch (e) { console.warn('Failed to open parent binding modal', e); }\n};\n\nconst updateModalWithArrays = (modal, bindingData) => {\n const arrays = bindingData ? buildBindingArrays(bindingData) : [];\n\n console.log('updateModalWithArrays: arrays =', arrays, 'count =', arrays.length);\n\n const badge = modal.querySelector('.section-binding-badge');\n badge.textContent = `${arrays.length} found`;\n const aliasInput = modal.querySelector('.section-binding-input');\n const selectedPathElement = modal.querySelector('[data-selected-path]');\n const codeElement = modal.querySelector('.section-binding-code');\n const applyButton = modal.querySelector('.section-binding-apply');\n\n aliasInput.value = 'section';\n selectedPathElement.textContent = '← Select an array on the left';\n codeElement.textContent = 'Select an array to see generated code';\n applyButton.disabled = true;\n populateSectionBindingList(modal, arrays);\n};\n\nconst setEmptyStateVisibility = () => {\n if (!emptyState || !dropSurface) {\n return;\n }\n\n emptyState.hidden = dropSurface.querySelectorAll('.canvas-block').length > 0;\n};\n\nconst clearSelection = () => {\n if (selectedBlock) {\n selectedBlock.classList.remove('canvas-block--selected');\n selectedBlock = null;\n }\n};\n\nconst selectBlock = (block) => {\n if (selectedBlock === block) {\n return;\n }\n\n clearSelection();\n selectedBlock = block;\n selectedBlock.classList.add('canvas-block--selected');\n};\n\nconst constrainBlockToSurface = (block, intendedLeft, intendedTop) => {\n const width = block.offsetWidth;\n const height = block.offsetHeight;\n const maxLeft = Math.max(0, dropSurface.clientWidth - width);\n const maxTop = Math.max(0, dropSurface.clientHeight - height);\n\n // For BlockCreator blocks, ensure they have position: absolute\n if (block.classList.contains('cs_block_s')) {\n block.style.position = 'absolute';\n }\n\n block.style.left = `${clamp(intendedLeft, 0, maxLeft)}px`;\n block.style.top = `${clamp(intendedTop, 0, maxTop)}px`;\n};\n\nconst createBlockElement = (payload) => {\n // Use BlockCreator for Title and Textarea blocks\n if (payload.blockType === 'heading' || payload.blockType === 'heading-two') {\n return blockCreator.createTitleBlock({\n text: 'New Heading',\n className: 'add-heading-two',\n fontSize: '14px'\n });\n }\n\n if (payload.blockType === 'body-text') {\n return blockCreator.createBodyTextBlock({\n text: 'Enter your text here',\n fontSize: '14px'\n });\n }\n\n const preset = blockPresets[payload.blockType] || blockPresets['body-text'];\n const contentWidth = Math.min(preset.width, dropSurface.clientWidth - 32);\n\n // For section/table blocks, use the cs_block_s editor-aware wrapper.\n const useCsBlock = payload.blockType === 'section-container' || payload.blockType === 'table-repeater';\n\n if (payload.blockType === 'table-repeater') {\n const tableBlock = blockCreator.createWhiteHeaderTableBlock();\n tableBlock.dataset.blockType = payload.blockType;\n tableBlock.style.width = `${contentWidth}px`;\n return tableBlock;\n }\n\n if (payload.blockType === 'section-container') {\n const sectionBlock = blockCreator.createSectionContainerBlock();\n sectionBlock.dataset.blockType = payload.blockType;\n sectionBlock.style.width = `${contentWidth}px`;\n return sectionBlock;\n }\n\n if (payload.blockType === 'image') {\n const imageBlock = blockCreator.createSquareImageBlock();\n imageBlock.dataset.blockType = payload.blockType;\n return imageBlock;\n }\n\n if (payload.blockType === 'video') {\n const videoBlock = blockCreator.createVideoBlock();\n videoBlock.dataset.blockType = payload.blockType;\n return videoBlock;\n }\n\n if (useCsBlock) {\n const block = blockCreator.getCsBlockSmall(payload.blockType);\n block.setAttribute('custom-name', preset.label || payload.blockType);\n block.dataset.blockType = payload.blockType;\n block.innerHTML = `<div class=\"canvas-block__content\">${preset.html}</div>`;\n block.style.width = `${contentWidth}px`;\n return block;\n }\n\n const block = document.createElement('article');\n block.className = 'canvas-block';\n block.dataset.blockType = payload.blockType;\n block.innerHTML = `\n <div class=\"canvas-block__inner\">\n <button class=\"canvas-block__remove\" type=\"button\" aria-label=\"Remove block\">×</button>\n <div class=\"canvas-block__content\">${preset.html}</div>\n </div>\n `;\n block.style.width = `${contentWidth}px`;\n\n return block;\n};\n\nconst addBlockAtPosition = (payload, clientX, clientY, targetElement) => {\n const block = createBlockElement(payload);\n\n // Find if we are dropping inside a section container\n const containerContent = targetElement ? targetElement.closest('.section-container-content') : null;\n const targetParent = containerContent || dropSurface;\n const surfaceRect = targetParent.getBoundingClientRect();\n\n targetParent.appendChild(block);\n\n // Calculate position relative to drop surface\n const left = clientX - surfaceRect.left - (block.offsetWidth / 2);\n const top = clientY - surfaceRect.top - 36;\n\n constrainBlockToSurface(block, left, top);\n\n // Legacy canvas-block selection only — cs_block_s blocks are handled by inline-editor.js\n if (block.classList.contains('canvas-block') && !block.classList.contains('cs_block_s')) {\n selectBlock(block);\n }\n\n setEmptyStateVisibility();\n\n if (payload.blockType === 'section-container') {\n showSectionBindingModal(block);\n }\n\n return block;\n};\n\nconst beginMove = (event, block) => {\n const blockRect = block.getBoundingClientRect();\n\n activeMove = {\n block,\n offsetX: event.clientX - blockRect.left,\n offsetY: event.clientY - blockRect.top\n };\n\n selectBlock(block);\n block.setPointerCapture?.(event.pointerId);\n};\n\nconst handlePointerMove = (event) => {\n if (!activeMove) {\n return;\n }\n\n const surfaceRect = dropSurface.getBoundingClientRect();\n const left = event.clientX - surfaceRect.left - activeMove.offsetX;\n const top = event.clientY - surfaceRect.top - activeMove.offsetY;\n\n constrainBlockToSurface(activeMove.block, left, top);\n};\n\nconst finishMove = (event) => {\n if (!activeMove) {\n return;\n }\n\n activeMove.block.releasePointerCapture?.(event.pointerId);\n activeMove = null;\n};\n\nconst setupCanvasEvents = () => {\n console.log('custom-form: setting up events on', dropSurface);\n dropSurface.addEventListener('click', (event) => {\n if (!event.target.closest('.canvas-block, .cs_block_s')) {\n clearSelection();\n }\n });\n\n dropSurface.addEventListener('dragenter', (event) => {\n console.log('custom-form: dragenter', event);\n if (getDragPayload(event)) {\n event.preventDefault();\n dropSurface.classList.add('drop-surface--active');\n }\n });\n\n dropSurface.addEventListener('dragover', (event) => {\n console.log('custom-form: dragover', event);\n if (getDragPayload(event)) {\n event.preventDefault();\n event.dataTransfer.dropEffect = 'copy';\n dropSurface.classList.add('drop-surface--active');\n }\n });\n\n dropSurface.addEventListener('dragleave', (event) => {\n console.log('custom-form: dragleave', event);\n if (!dropSurface.contains(event.relatedTarget)) {\n dropSurface.classList.remove('drop-surface--active');\n }\n });\n\n dropSurface.addEventListener('drop', (event) => {\n console.log('custom-form: drop event', event);\n const payload = getDragPayload(event);\n\n if (!payload) {\n console.log('custom-form: no payload');\n return;\n }\n\n // Page Break is fully handled by flow-canvas.js (splits the page);\n // we must not also drop a legacy overlay block for it.\n if (payload.blockType === 'page-break') {\n event.preventDefault();\n dropSurface.classList.remove('drop-surface--active');\n return;\n }\n\n console.log('custom-form: drop payload', payload);\n event.preventDefault();\n dropSurface.classList.remove('drop-surface--active');\n addBlockAtPosition(payload, event.clientX, event.clientY, event.target);\n });\n\n dropSurface.addEventListener('pointerdown', (event) => {\n const removeButton = event.target.closest('.canvas-block__remove');\n\n if (removeButton) {\n const block = removeButton.closest('.canvas-block, .cs_block_s');\n block?.remove();\n if (selectedBlock === block) {\n selectedBlock = null;\n }\n setEmptyStateVisibility();\n return;\n }\n\n if (event.target.closest('[contenteditable=\"true\"], .fr-element, .inline-editing, [data-cs-chrome]')) {\n return;\n }\n\n // cs_block_s blocks are moved via the badge handle (owned by inline-editor.js).\n // Only the legacy .canvas-block flow uses whole-block drag here.\n const block = event.target.closest('.canvas-block');\n\n if (!block || block.classList.contains('cs_block_s') || event.button !== 0) {\n return;\n }\n\n event.preventDefault();\n beginMove(event, block);\n });\n\n dropSurface.addEventListener('pointermove', handlePointerMove);\n dropSurface.addEventListener('pointerup', finishMove);\n dropSurface.addEventListener('pointercancel', finishMove);\n};\n\ndocument.documentElement.dataset.previewReady = 'true';\n// Old absolute drag-and-drop is disabled when flow-canvas.js is loaded.\n// Flow canvas owns drop handling; we keep this file loaded only for the\n// section-binding modal (showSectionBindingModal) which other code may invoke.\nconst FLOW_CANVAS_OWNS_DRAG = true;\nif (!FLOW_CANVAS_OWNS_DRAG) {\n setupCanvasEvents();\n}\nsetEmptyStateVisibility();\n\n// Expose the modal opener so flow-canvas.js (and other modules) can trigger it.\nwindow.showSectionBindingModal = showSectionBindingModal;\n\n// Listen for messages from parent to open binding modal for a specific block\nwindow.addEventListener('message', (event) => {\n if (event.data?.target !== 'custom-form-twig' || event.data?.type !== 'open-binding-modal-for-block') {\n return;\n }\n const blockId = event.data?.blockId;\n if (!blockId) return;\n const block = document.getElementById(blockId);\n if (block) {\n showSectionBindingModal(block);\n }\n});\n\n<\/script>\n\n <!-- Manager-facing feature flags (must load before the registry). -->\n <script data-src=\"./js/feature-flags.js\">\n/**\n * @fileoverview Editor feature flags — MANAGER-FACING ON/OFF SWITCHES.\n *\n * Flip any flag to `false` to completely hide that feature from the editor\n * (its palette entries, panels, and UI won't appear). Loaded in BOTH runtime\n * contexts (the Angular shell via src/index.html, and the iframe canvas via\n * custom-form.html) BEFORE block-registry.js, so every consumer can read it.\n *\n * Read it as `window.EditorFeatures.<flag>` (defaults to enabled if missing).\n */\n(function () {\n const FEATURES = {\n rulersGuides: true, // Rulers + draggable alignment guides\n };\n\n const g = (typeof window !== 'undefined') ? window : globalThis;\n // Keep any flags an embedder set earlier; our defaults fill the rest.\n g.EditorFeatures = Object.assign({}, FEATURES, g.EditorFeatures || {});\n if (typeof globalThis !== 'undefined') globalThis.EditorFeatures = g.EditorFeatures;\n})();\n\n<\/script>\n <!-- Single source of truth for block types (must load before factory/menus) -->\n <script data-src=\"./js/block-registry.js\">\n/**\n * @fileoverview SINGLE SOURCE OF TRUTH for every block type.\n *\n * Add / edit / remove a block in ONE place here and it automatically flows to:\n * - the sidebar palette (src/app/app.ts → librarySections)\n * - the inline \"+\" insert menu (flow/inline-insert.js → INLINE_LIBRARY)\n * - the style-properties panel (src/app/app.ts → blockStyleConfig)\n * - repeater / flexible rules (flow-canvas.js, row-col-builder.js)\n * - repeater alias defaults (src/app/app.ts → defaultAliasFor)\n *\n * The actual DOM construction for each type still lives in flow/block-factory.js\n * (keyed by `type`), because that needs imperative builder code — but every\n * `type` listed here must have a matching builder there.\n *\n * Loaded as a plain script in BOTH runtime contexts (each gets its own copy of\n * the same data):\n * - the iframe canvas → public/custom-form/custom-form.html\n * - the Angular parent → src/index.html\n *\n * Per-block fields\n * ----------------\n * type kebab-case id used everywhere (dataset.blockType, payloads)\n * label human label shown in palettes\n * icon glyph shown in palettes\n * category palette grouping title (null = never shown in palettes)\n * inSidebar show in the left sidebar palette\n * inInlineMenu show in the inline \"+\" insert menu\n * isRepeater opens the binding modal; iterates over bound data\n * restrictInFlexible cannot be dropped inside a flexible container\n * alias default loop alias for repeaters (for {% for alias in ... %})\n * styleProps style controls shown in the right properties panel\n * legacyKeys old label-based blockType keys that still map to this block\n */\n(function () {\n // ---- shared style-prop presets (keep these matching app.ts history) -------\n const STD_TEXT = ['backgroundColor', 'textColor', 'fontSize', 'fontWeight', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const BOX_RADIUS = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const BOX_NO_RADIUS = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const TABLE = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'tableBorder', 'tableBorderColor', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const TABLE_RADIUS = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius', 'tableBorder', 'tableBorderColor', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const SPACER = ['backgroundColor', 'width', 'height', 'opacity'];\n const VIDEO = ['width', 'height', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'boxShadow'];\n const ICON = ['textColor', 'fontSize', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'width', 'height'];\n\n // ---- the block catalog ----------------------------------------------------\n const BLOCKS = [\n // ---- Basic Elements ----\n { type: 'heading', label: 'Heading', icon: 'H', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Title'] },\n { type: 'body-text', label: 'Body Text', icon: '¶', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Textarea'] },\n { type: 'aiden', label: 'AI Writer', icon: '✦', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT },\n { type: 'label-tag', label: 'Label / Tag', icon: 'A', category: 'Basic Elements', inSidebar: true, inInlineMenu: false, styleProps: STD_TEXT },\n { type: 'image', label: 'Image', icon: '▨', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: BOX_RADIUS, legacyKeys: ['Image'] },\n { type: 'video', label: 'Video', icon: '▶', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: VIDEO, legacyKeys: ['Video'] },\n { type: 'pen-shape', label: 'Pen Shape', icon: '✒', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'width', 'height'] },\n { type: 'button', label: 'Button', icon: '⬡', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Button'] },\n { type: 'divider', label: 'Divider', icon: '─', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: BOX_NO_RADIUS },\n { type: 'spacer', label: 'Spacer', icon: '⋮', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: SPACER, legacyKeys: ['Spacer'] },\n { type: 'table', label: 'Table', icon: '▦', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: TABLE_RADIUS },\n { type: 'page-break', label: 'Page Break', icon: '⤵', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: [] },\n\n // ---- Data Elements ----\n { type: 'section-container', label: 'Section Container', icon: '⬢', category: 'Data Elements', inSidebar: true, inInlineMenu: true, isRepeater: true, restrictInFlexible: true, alias: 'section', styleProps: BOX_RADIUS, legacyKeys: ['Section Container'] },\n { type: 'flexible', label: 'Flexible', icon: '⬡', category: 'Data Elements', inSidebar: true, inInlineMenu: true, styleProps: BOX_RADIUS },\n { type: 'table-repeater', label: 'Table Repeater', icon: '⊟', category: 'Data Elements', inSidebar: true, inInlineMenu: true, isRepeater: true, restrictInFlexible: false, alias: 'row', styleProps: TABLE, legacyKeys: [{ key: 'Table', styleProps: TABLE_RADIUS }] },\n { type: 'data-field', label: 'Data Field', icon: '{{}}', category: 'Data Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Data Field'] },\n // { type: 'list-repeater', label: 'List Repeater', icon: '≡', category: 'Data Elements', inSidebar: true, inInlineMenu: true, isRepeater: true, restrictInFlexible: true, alias: 'item', styleProps: BOX_NO_RADIUS, legacyKeys: ['List Repeater'] },\n { type: 'sync-list', label: 'List', icon: '▥', category: 'Data Elements', inSidebar: true, inInlineMenu: true, restrictInFlexible: true, styleProps: BOX_RADIUS },\n\n // ---- Builder-only (created programmatically, never shown in palettes) ----\n { type: 'heading-two', label: 'Heading', icon: 'H', category: null, inSidebar: false, inInlineMenu: false, styleProps: STD_TEXT },\n { type: 'fa-icon', label: 'Icon', icon: '★', category: null, inSidebar: false, inInlineMenu: false, styleProps: ICON },\n ];\n\n // A block whose `feature` flag is switched off is hidden from every palette.\n const featureOn = (b) => {\n if (!b.feature) return true;\n const flags = (typeof window !== 'undefined' && window.EditorFeatures) ? window.EditorFeatures\n : (typeof globalThis !== 'undefined' ? globalThis.EditorFeatures : null);\n return !flags || flags[b.feature] !== false;\n };\n\n // ---- helper accessors -----------------------------------------------------\n const byType = (type) => BLOCKS.find((b) => b.type === type) || null;\n\n // Group blocks (filtered by a boolean flag, e.g. 'inSidebar' / 'inInlineMenu')\n // into palette sections, preserving first-seen category order.\n const sections = (flag) => {\n const order = [];\n const map = new Map();\n BLOCKS.forEach((b) => {\n if (!b[flag] || !b.category || !featureOn(b)) return;\n if (!map.has(b.category)) {\n map.set(b.category, []);\n order.push(b.category);\n }\n map.get(b.category).push({ type: b.type, label: b.label, icon: b.icon });\n });\n return order.map((title) => ({ title, items: map.get(title) }));\n };\n\n const repeaterTypes = () => BLOCKS.filter((b) => b.isRepeater).map((b) => b.type);\n const restrictedInFlexibleTypes = () => BLOCKS.filter((b) => b.restrictInFlexible).map((b) => b.type);\n\n const aliasFor = (type) => {\n const b = byType(type);\n return (b && b.alias) || 'item';\n };\n\n // Build the { blockType: [styleProps] } map, including legacy label keys.\n // A legacy key may be a plain string (reuses the block's styleProps) or an\n // object { key, styleProps } when the old key needs a different prop set.\n const styleConfig = () => {\n const cfg = {};\n BLOCKS.forEach((b) => {\n cfg[b.type] = b.styleProps;\n (b.legacyKeys || []).forEach((k) => {\n if (typeof k === 'string') cfg[k] = b.styleProps;\n else if (k && k.key) cfg[k.key] = k.styleProps || b.styleProps;\n });\n });\n return cfg;\n };\n\n const api = {\n blocks: BLOCKS,\n byType,\n sections,\n repeaterTypes,\n restrictedInFlexibleTypes,\n aliasFor,\n styleConfig,\n };\n\n // Expose on whichever global object is available (window in both contexts).\n if (typeof window !== 'undefined') window.FormBlockRegistry = api;\n if (typeof globalThis !== 'undefined') globalThis.FormBlockRegistry = api;\n})();\n\n<\/script>\n\n <!-- Flow canvas feature modules (must load before flow-canvas.js entry point) -->\n <script data-src=\"./js/flow/template-data.js\">\n/**\n * @fileoverview Template HTML data for predefined invoice templates.\n *\n * This file contains all HTML templates used by the block factory.\n * Each template is identified by a numeric key (1, 2, 3, etc.).\n *\n * To add a new template:\n * 1. Add a new key-value pair to TEMPLATE_HTML below\n * 2. No other code changes needed — the factory dispatcher handles it automatically\n *\n * Usage: window.FlowCanvas.TEMPLATE_HTML[n] returns the HTML string for template n\n */\n\nwindow.FlowCanvas = window.FlowCanvas || {};\n\nwindow.FlowCanvas.TEMPLATE_HTML = {\n 1: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d1\" data-cs-page-region=\"header\" style=\"padding: 0px; min-height: 90px; border: 0px; background: linear-gradient(90deg, #f97316 0%, #f97316 100%); display: flex; align-items: center;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; padding: 20px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Header\" id=\"block_header_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_d1\" placeholder=\"\" style=\"font-size: 24px; font-weight: bold; color: #ffffff; margin: 0;\">\n <div style=\"display: flex; align-items: center; gap: 12px;\">\n <div style=\"width: 45px; height: 45px; background: #ffffff; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #f97316; font-weight: bold; font-size: 20px;\">F</div>\n <div>\n <div style=\"color: #ffffff; font-size: 20px; font-weight: bold;\">FLEX CORP</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Main Content -->\n <div class=\"body-main-content\" style=\"flex: 1 1 0%; display: flex; flex-direction: column; gap: 0px; padding: 25px;\">\n\n <!-- Invoice Title -->\n <div class=\"row-item\" id=\"row_title_d1\" style=\"margin-bottom: 20px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Title\" id=\"block_title_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_title_d1\" placeholder=\"\" style=\"font-size: 28px; font-weight: bold; color: #f97316; margin: 0;\">INVOICE</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Details Section -->\n <div class=\"row-item invoice-row invoice-row--intro\" id=\"row_intro_d1\" style=\"margin-bottom: 25px; min-height: 0px; gap: 20px;\">\n <!-- Invoice To -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Bill To\" id=\"block_bill_to_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_bill_to_d1\" placeholder=\"\" style=\"font-size: 13px; color: #333;\">\n <div style=\"font-weight: bold; color: #f97316; margin-bottom: 8px; font-size: 11px; text-transform: uppercase;\">Bill To:</div>\n <div style=\"font-weight: bold; font-size: 14px; color: #333;\">{{customer_name}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{address_line1}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{address_line2}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{city}}, {{state}} {{zip_code}}</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Meta Info -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Details\" id=\"block_details_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #fff8f0; padding: 15px; border-left: 4px solid #f97316; border-radius: 2px;\">\n <div class=\"edit_me resize\" id=\"dynamic_details_d1\" placeholder=\"\" style=\"font-size: 12px; color: #333; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 80px 1fr; gap: 8px; margin-bottom: 8px;\">\n <div style=\"font-weight: bold; color: #333;\">Invoice #:</div>\n <div>{{invoice_number}}</div>\n <div style=\"font-weight: bold; color: #333;\">Date:</div>\n <div>{{Invoice_Date}}</div>\n <div style=\"font-weight: bold; color: #333;\">Due Date:</div>\n <div>{{due_date}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item invoice-row invoice-row--items\" id=\"row_items_d1\" style=\"margin-bottom: 25px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Items Table\" id=\"block_items_d1\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d1\" placeholder=\"\" style=\"font-size: 12px; width: 100%; padding: 0px; margin: 0px; color: #333; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #f97316; color: white;\">\n <th style=\"padding: 10px 12px; text-align: left; font-weight: bold; border: 0;\">Item Description</th>\n <th style=\"padding: 10px 12px; text-align: center; font-weight: bold; border: 0; width: 70px;\">Qty</th>\n <th style=\"padding: 10px 12px; text-align: right; font-weight: bold; border: 0; width: 90px;\">Unit Price</th>\n <th style=\"padding: 10px 12px; text-align: right; font-weight: bold; border: 0; width: 90px;\">Total</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #fafafa;\">\n <td style=\"padding: 10px 12px; border: 0;\">Professional Design Services</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$600.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$600.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #ffffff;\">\n <td style=\"padding: 10px 12px; border: 0;\">Web Development (40 hours)</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">40</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$75.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$3,000.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #fafafa;\">\n <td style=\"padding: 10px 12px; border: 0;\">UI/UX Testing & Revisions</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">2</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$250.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #ffffff;\">\n <td style=\"padding: 10px 12px; border: 0;\">Content Writing (20 pages)</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">20</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$50.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$1,000.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #fafafa;\">\n <td style=\"padding: 10px 12px; border: 0;\">SEO Optimization Services</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$400.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$400.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #ffffff;\">\n <td style=\"padding: 10px 12px; border: 0;\">Project Management & Support</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$300.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$300.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Summary Section -->\n <div class=\"row-item invoice-row invoice-row--summary\" id=\"row_summary_d1\" style=\"margin-bottom: 25px; min-height: 0px; gap: 20px;\">\n <!-- Notes -->\n <div class=\"col-item\" style=\"flex: 1 1 55%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Notes\" id=\"block_notes_d1\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_notes_d1\" placeholder=\"\" style=\"font-size: 12px; color: #333;\">\n <div style=\"margin-bottom: 15px;\">\n <div style=\"font-weight: bold; color: #333; margin-bottom: 8px;\">Terms & Conditions:</div>\n <div style=\"font-size: 11px; line-height: 1.6; color: #666;\">\n Payment is due within 30 days of invoice date. Please reference the invoice number with your payment. Late payments will incur 1.5% monthly interest charges.\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Totals -->\n <div class=\"col-item\" style=\"flex: 0 0 45%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Totals\" id=\"block_totals_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #fff8f0; padding: 15px; border: 1px solid #f97316; border-radius: 2px;\">\n <div class=\"edit_me resize\" id=\"dynamic_totals_d1\" placeholder=\"\" style=\"font-size: 12px; color: #333; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 100px 90px; gap: 8px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #ffc699;\">\n <div style=\"text-align: right; font-weight: 500;\">Subtotal:</div>\n <div style=\"text-align: right;\">$5,800.00</div>\n <div style=\"text-align: right; font-weight: 500;\">Tax (0%):</div>\n <div style=\"text-align: right;\">$0.00</div>\n <div style=\"text-align: right; font-weight: 500;\">Shipping:</div>\n <div style=\"text-align: right;\">$0.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 100px 90px; gap: 8px; font-size: 14px;\">\n <div style=\"text-align: right; font-weight: bold; color: #f97316;\">TOTAL:</div>\n <div style=\"text-align: right; font-weight: bold; color: #f97316;\">$5,800.00</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer -->\n <div class=\"row-item\" id=\"row_footer_d1\" style=\"margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Footer\" id=\"block_footer_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_footer_d1\" placeholder=\"\" style=\"font-size: 10px; color: #999; text-align: center;\">\n <div>Thank you for choosing FLEX CORP! | support@flexcorp.com | (555) 987-6543</div>\n <div style=\"margin-top: 5px;\">© 2024 FLEX CORP. All Rights Reserved.</div>\n </div>\n </div>\n </div>\n </div>\n\n </div>\n `,\n 2: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d2\" data-cs-page-region=\"header\" style=\"padding: 0px; min-height: 100px; border: 0px; display: flex;\">\n <!-- Dark Left Side -->\n <div class=\"col-item\" style=\"flex: 0 0 40%; max-width: 100%; background: #1a1a1a; padding: 20px; display: flex; align-items: center;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Info\" id=\"block_header_left_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_left_d2\" placeholder=\"\" style=\"font-size: 22px; font-weight: bold; color: #ffffff; margin: 0;\">\n <div style=\"color: #f97316; font-size: 26px; margin-bottom: 4px;\">●●●</div>\n <div>MODERN</div>\n <div style=\"font-size: 12px; color: #f97316; font-weight: normal;\">Creative Agency</div>\n </div>\n </div>\n </div>\n <!-- Orange Right Side -->\n <div class=\"col-item\" style=\"flex: 0 0 60%; max-width: 100%; background: #f97316; padding: 20px; display: flex; align-items: center; justify-content: flex-end;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Header Title\" id=\"block_header_right_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_right_d2\" placeholder=\"\" style=\"font-size: 32px; font-weight: bold; color: #ffffff; margin: 0; text-align: right;\">INVOICE</div>\n </div>\n </div>\n </div>\n\n <!-- Main Content -->\n <div class=\"body-main-content\" style=\"flex: 1 1 0%; display: flex; flex-direction: column; gap: 0px; padding: 30px;\">\n\n <!-- Invoice Details -->\n <div class=\"row-item invoice-row invoice-row--intro\" id=\"row_intro_d2\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Invoice To -->\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Bill To\" id=\"block_bill_to_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_bill_to_d2\" placeholder=\"\" style=\"font-size: 13px; color: #333;\">\n <div style=\"font-weight: bold; color: #f97316; margin-bottom: 10px; font-size: 12px; text-transform: uppercase;\">Invoice To:</div>\n <div style=\"font-weight: bold; font-size: 16px; color: #1a1a1a; margin-bottom: 8px;\">{{customer_name}}</div>\n <div style=\"color: #666; font-size: 12px; line-height: 1.6;\">{{address_line1}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{address_line2}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{city}}, {{state}} {{zip_code}}</div>\n <div style=\"color: #666; font-size: 12px; margin-top: 8px;\">{{customer_email}}</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Meta -->\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Meta\" id=\"block_meta_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f5f5f5; padding: 15px; border-radius: 4px;\">\n <div class=\"edit_me resize\" id=\"dynamic_meta_d2\" placeholder=\"\" style=\"font-size: 13px; color: #333; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 100px 1fr; gap: 12px;\">\n <div style=\"font-weight: bold; color: #333;\">Invoice #:</div>\n <div>{{invoice_number}}</div>\n <div style=\"font-weight: bold; color: #333;\">Date:</div>\n <div>{{Invoice_Date}}</div>\n <div style=\"font-weight: bold; color: #333;\">Due Date:</div>\n <div>{{due_date}}</div>\n <div style=\"font-weight: bold; color: #333;\">PO #:</div>\n <div>{{po_number}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item invoice-row invoice-row--items\" id=\"row_items_d2\" style=\"margin-bottom: 30px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Items Table\" id=\"block_items_d2\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d2\" placeholder=\"\" style=\"font-size: 12px; width: 100%; padding: 0px; margin: 0px; color: #333; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #1a1a1a; color: white;\">\n <th style=\"padding: 12px; text-align: left; font-weight: bold; border: 0;\">Description</th>\n <th style=\"padding: 12px; text-align: center; font-weight: bold; border: 0; width: 80px;\">Qty</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Rate</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Amount</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Brand Identity Design</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$800.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$800.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Website Design & Development</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$2,500.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$2,500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Social Media Graphics (20 assets)</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">20</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$75.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$1,500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Marketing Collateral Design</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$600.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$600.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Video Production & Editing</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">3</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$400.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$1,200.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Copywriting & Content Strategy</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Summary Section -->\n <div class=\"row-item invoice-row invoice-row--summary\" id=\"row_summary_d2\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Notes -->\n <div class=\"col-item\" style=\"flex: 1 1 50%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Notes\" id=\"block_notes_d2\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_notes_d2\" placeholder=\"\" style=\"font-size: 12px; color: #333;\">\n <div style=\"margin-bottom: 15px;\">\n <div style=\"font-weight: bold; color: #333; margin-bottom: 8px; font-size: 13px;\">Special Notes:</div>\n <div style=\"font-size: 11px; line-height: 1.6; color: #666;\">\n Thank you for your project request! Upon project completion, all files and assets will be delivered in the agreed formats. Payment due upon invoice date as per our agreement.\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Payment & Totals -->\n <div class=\"col-item\" style=\"flex: 0 0 50%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Payment Info\" id=\"block_payment_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f97316; padding: 20px; border-radius: 4px; color: white;\">\n <div class=\"edit_me resize\" id=\"dynamic_payment_d2\" placeholder=\"\" style=\"font-size: 12px; color: #ffffff; margin: 0;\">\n <div style=\"margin-bottom: 15px;\">\n <div style=\"font-weight: bold; margin-bottom: 8px; font-size: 13px;\">Payment Details:</div>\n <div style=\"font-size: 11px; line-height: 1.8;\">\n <div><strong>Bank:</strong> {{bank_name}}</div>\n <div><strong>Account:</strong> {{bank_account}}</div>\n <div><strong>Routing:</strong> {{routing_number}}</div>\n </div>\n </div>\n <div style=\"border-top: 1px solid rgba(255,255,255,0.3); padding-top: 15px;\">\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; margin-bottom: 8px;\">\n <div style=\"text-align: right;\">Subtotal:</div>\n <div style=\"text-align: right;\">$6,750.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; margin-bottom: 12px;\">\n <div style=\"text-align: right;\">Tax:</div>\n <div style=\"text-align: right;\">$0.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; font-size: 14px; font-weight: bold;\">\n <div style=\"text-align: right;\">Total Due:</div>\n <div style=\"text-align: right;\">$6,750.00</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer -->\n <div class=\"row-item\" id=\"row_footer_d2\" style=\"margin-top: 30px; padding-top: 20px; border-top: 2px solid #f97316; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Footer\" id=\"block_footer_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_footer_d2\" placeholder=\"\" style=\"font-size: 11px; color: #999; text-align: center;\">\n <div>Modern Creative Agency | hello@modernagency.com | 1-800-MODERN-1</div>\n <div style=\"margin-top: 6px;\">© 2024 Modern Agency. All Rights Reserved. www.modernagency.com</div>\n </div>\n </div>\n </div>\n </div>\n\n </div>`,\n 3: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d3\" data-cs-page-region=\"header\" style=\"padding: 0px; min-height: 100px; border: 0px; background: linear-gradient(135deg, #1a2a47 0%, #1a2a47 100%); display: flex; align-items: center;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; padding: 20px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Header\" id=\"block_header_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_d3\" placeholder=\"\" style=\"font-size: 28px; font-weight: bold; color: #ffffff; margin: 0;\">\n <div style=\"display: flex; align-items: center; gap: 15px;\">\n <div style=\"width: 50px; height: 50px; background: #c41e3a; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 24px;\">A</div>\n <div>\n <div style=\"color: #ffffff; font-size: 24px; font-weight: bold;\">ACME Corp</div>\n <div style=\"color: #c41e3a; font-size: 12px; font-weight: normal;\">Cloud & IT Solutions</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Main Content -->\n <div class=\"body-main-content\" style=\"flex: 1 1 0%; display: flex; flex-direction: column; gap: 0px; padding: 30px;\">\n\n <!-- Invoice Title -->\n <div class=\"row-item\" id=\"row_title_d3\" style=\"margin-bottom: 20px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Title\" id=\"block_title_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_title_d3\" placeholder=\"\" style=\"font-size: 32px; font-weight: bold; color: #1a2a47; margin: 0;\">INVOICE</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice To / From Section -->\n <div class=\"row-item invoice-row invoice-row--intro\" id=\"row_intro_d3\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Invoice To -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice To\" id=\"block_invoice_to_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_invoice_to_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47;\">\n <div style=\"font-weight: bold; color: #c41e3a; margin-bottom: 8px; font-size: 12px; text-transform: uppercase;\">Bill To:</div>\n <div style=\"font-weight: bold; font-size: 15px;\">{{customer_name}}</div>\n <div>{{address_line1}}</div>\n <div>{{address_line2}}</div>\n <div>{{city}}, {{state}} {{zip_code}}</div>\n <div style=\"margin-top: 8px;\">{{customer_email}}</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Meta Info -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Info\" id=\"block_invoice_info_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f5f5f5; padding: 15px; border-radius: 4px; border-left: 4px solid #c41e3a;\">\n <div class=\"edit_me resize\" id=\"dynamic_invoice_info_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 100px 1fr; gap: 10px;\">\n <div style=\"font-weight: bold; color: #1a2a47;\">Invoice #:</div>\n <div>{{invoice_number}}</div>\n <div style=\"font-weight: bold; color: #1a2a47;\">Date:</div>\n <div>{{Invoice_Date}}</div>\n <div style=\"font-weight: bold; color: #1a2a47;\">Due Date:</div>\n <div>{{due_date}}</div>\n <div style=\"font-weight: bold; color: #1a2a47;\">PO Number:</div>\n <div>{{po_number}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item invoice-row invoice-row--items\" id=\"row_items_d3\" style=\"margin-bottom: 30px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Items Table\" id=\"block_items_d3\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d3\" placeholder=\"\" style=\"font-size: 13px; width: 100%; padding: 0px; margin: 0px; color: #1a2a47; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #1a2a47; color: white;\">\n <th style=\"padding: 12px; text-align: left; font-weight: bold; border: 0;\">Item</th>\n <th style=\"padding: 12px; text-align: center; font-weight: bold; border: 0; width: 80px;\">Qty</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Unit Price</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Total</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Cloud Hosting Setup</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$500.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Monthly Support & Maintenance</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">3</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$300.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$900.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Security Audit & Compliance</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$400.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$400.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">API Integration Development</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">2</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$250.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Database Optimization</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Deployment & Go-Live Support</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$200.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$200.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Summary Section -->\n <div class=\"row-item invoice-row invoice-row--summary\" id=\"row_summary_d3\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Notes Section -->\n <div class=\"col-item\" style=\"flex: 1 1 60%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Notes & Payment\" id=\"block_notes_d3\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_notes_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47;\">\n <div style=\"margin-bottom: 20px;\">\n <div style=\"font-weight: bold; color: #1a2a47; margin-bottom: 8px; font-size: 14px;\">Payment Methods:</div>\n <div style=\"background: #f5f5f5; padding: 12px; border-radius: 4px;\">\n <div style=\"margin-bottom: 8px;\"><strong>Bank Transfer:</strong></div>\n <div style=\"margin-left: 15px; font-size: 12px;\">Account: {{bank_account}}</div>\n <div style=\"margin-left: 15px; font-size: 12px;\">Routing: {{routing_number}}</div>\n <div style=\"margin-bottom: 8px; margin-top: 8px;\"><strong>Credit Card:</strong> Accepted via PaymentGateway</div>\n </div>\n </div>\n\n <div style=\"margin-bottom: 20px;\">\n <div style=\"font-weight: bold; color: #1a2a47; margin-bottom: 8px; font-size: 14px;\">Terms & Conditions:</div>\n <div style=\"font-size: 12px; line-height: 1.6;\">\n Payment is due within 30 days of invoice date. Late payments subject to 1.5% monthly interest. Please reference invoice number in payment communication.\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Totals Section -->\n <div class=\"col-item\" style=\"flex: 0 0 40%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Totals\" id=\"block_totals_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f5f5f5; padding: 15px; border-radius: 4px; box-shadow: 0 2px 4px rgba(196, 30, 58, 0.1);\">\n <div class=\"edit_me resize\" id=\"dynamic_totals_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #ddd;\">\n <div style=\"text-align: right;\">Subtotal:</div>\n <div style=\"text-align: right; font-weight: bold;\">$2,850.00</div>\n <div style=\"text-align: right;\">Tax (0%):</div>\n <div style=\"text-align: right; font-weight: bold;\">$0.00</div>\n <div style=\"text-align: right;\">Discount:</div>\n <div style=\"text-align: right; font-weight: bold;\">$0.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; font-size: 16px;\">\n <div style=\"text-align: right; font-weight: bold; color: #c41e3a;\">Total Due:</div>\n <div style=\"text-align: right; font-weight: bold; color: #c41e3a;\">$2,850.00</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer Section -->\n <div class=\"row-item\" id=\"row_footer_d3\" style=\"margin-top: 30px; padding-top: 30px; border-top: 2px solid #1a2a47; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Footer\" id=\"block_footer_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_footer_d3\" placeholder=\"\" style=\"font-size: 11px; color: #666; text-align: center;\">\n <div>Thank you for your business! For questions, contact support@acmecorp.com | Phone: (555) 123-4567</div>\n <div style=\"margin-top: 8px;\">© 2024 ACME Corp. All rights reserved.</div>\n </div>\n </div>\n </div>\n </div>\n\n </div>`,\n 4: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d4\" data-cs-page-region=\"header\" style=\"padding: 30px 36px; border: 0px; background: #1a2649; display: flex; justify-content: space-between; align-items: flex-start; position: relative;\">\n <div class=\"col-item\" style=\"flex: 0 0 auto; max-width: 50%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Logo & Info\" id=\"block_logo_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_logo_d4\" placeholder=\"\" style=\"color: #ffffff;\">\n <div style=\"display: flex; align-items: center; gap: 12px; margin-bottom: 12px;\">\n <div style=\"width: 52px; height: 44px; background: #f5c100; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-weight: 900; color: #1a2649; font-size: 28px;\">A</div>\n <div>\n <div style=\"font-size: 18px; font-weight: 800; color: #ffffff;\">Salford &amp; Co.</div>\n </div>\n </div>\n <div style=\"font-size: 12px; color: #a0aec0; font-style: italic; margin-bottom: 8px;\">Invoice To:</div>\n <div style=\"font-size: 16px; font-weight: 700; color: #ffffff; margin-bottom: 2px;\">{{client_name}}</div>\n <div style=\"font-size: 12px; color: #a0aec0;\">{{client_role}}</div>\n </div>\n </div>\n </div>\n\n <div class=\"col-item\" style=\"flex: 0 0 auto; max-width: 50%; text-align: right;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Meta\" id=\"block_meta_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_meta_d4\" placeholder=\"\" style=\"color: #ffffff;\">\n <div style=\"font-size: 52px; font-weight: 900; letter-spacing: 2px; color: #f5c100; line-height: 1; margin-bottom: 18px;\">INVOICE</div>\n <div style=\"display: grid; grid-template-columns: auto auto; gap: 4px 24px; font-size: 13px;\">\n <div style=\"color: #a0aec0;\">Invoice No:</div>\n <div style=\"color: #ffffff; font-weight: 600; text-align: right;\">{{invoice_number}}</div>\n <div style=\"color: #a0aec0;\">Due Date:</div>\n <div style=\"color: #ffffff; font-weight: 600; text-align: right;\">{{due_date}}</div>\n <div style=\"color: #a0aec0;\">Invoice Date:</div>\n <div style=\"color: #ffffff; font-weight: 600; text-align: right;\">{{invoice_date}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Address Banner -->\n <div class=\"row-item\" id=\"row_address_d4\" style=\"background: #f5c100; padding: 13px 36px; display: flex; align-items: center; gap: 10px; position: relative;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Address\" id=\"block_address_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none; flex: 1;\">\n <div class=\"edit_me resize\" id=\"dynamic_address_d4\" placeholder=\"\" style=\"color: #1a2649; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 10px;\">\n <span style=\"display: inline-block; width: 28px; height: 28px; background: #1a2649; border-radius: 50%; text-align: center; line-height: 28px; color: #f5c100; font-size: 12px; flex-shrink: 0;\">📍</span>\n {{company_address}}\n </div>\n </div>\n </div>\n\n <!-- Contact + Payment Info -->\n <div class=\"row-item\" id=\"row_info_d4\" style=\"display: flex; padding: 28px 36px; gap: 40px; border-bottom: 1px solid #f0f0f0; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Contact Info\" id=\"block_contact_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_contact_d4\" placeholder=\"\" style=\"font-size: 13px; color: #444;\">\n <div style=\"margin-bottom: 6px;\"><span style=\"color: #666; width: 60px; display: inline-block;\">Phone:</span> {{phone}}</div>\n <div style=\"margin-bottom: 6px;\"><span style=\"color: #666; width: 60px; display: inline-block;\">Email:</span> {{email}}</div>\n <div><span style=\"color: #666; width: 60px; display: inline-block;\">Address:</span> {{address}}</div>\n </div>\n </div>\n </div>\n\n <div class=\"col-item\" style=\"flex: 1; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Payment Method\" id=\"block_payment_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_payment_d4\" placeholder=\"\" style=\"font-size: 13px; color: #444;\">\n <div style=\"font-size: 13px; font-weight: 800; color: #1a2649; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;\">Payment Method</div>\n <div style=\"margin-bottom: 5px; display: flex; justify-content: space-between;\"><span style=\"color: #666;\">Account No:</span> <span style=\"font-weight: 500;\">{{account_number}}</span></div>\n <div style=\"margin-bottom: 5px; display: flex; justify-content: space-between;\"><span style=\"color: #666;\">Account Name:</span> <span style=\"font-weight: 500;\">{{account_name}}</span></div>\n <div style=\"display: flex; justify-content: space-between;\"><span style=\"color: #666;\">Branch:</span> <span style=\"font-weight: 500;\">{{branch_name}}</span></div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item\" id=\"row_items_d4\" style=\"padding: 0 36px 24px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Items Table\" id=\"block_items_d4\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d4\" placeholder=\"\" style=\"font-size: 13px; width: 100%; padding: 0px; margin: 0px; color: #333; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #1a2649; color: white;\">\n <th style=\"padding: 12px 16px; text-align: left; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0;\">Description</th>\n <th style=\"padding: 12px 16px; text-align: center; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0; width: 100px;\">Subtotal</th>\n <th style=\"padding: 12px 16px; text-align: center; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0; width: 60px;\">QTY</th>\n <th style=\"padding: 12px 16px; text-align: right; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0; width: 80px;\">Subtotal</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #f0f0f0;\">\n <td style=\"padding: 13px 16px; border: 0;\">Brand Consultation</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0; background: #f9fafb;\">\n <td style=\"padding: 13px 16px; border: 0;\">Logo Design</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0;\">\n <td style=\"padding: 13px 16px; border: 0;\">Website Design</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0; background: #f9fafb;\">\n <td style=\"padding: 13px 16px; border: 0;\">Social Media Template</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0;\">\n <td style=\"padding: 13px 16px; border: 0;\">Flyer</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$50</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">6</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$300.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer: Terms + Totals -->\n <div class=\"row-item\" id=\"row_footer_d4\" style=\"display: flex; padding: 16px 36px 28px; gap: 40px; align-items: flex-start; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1.2; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Terms\" id=\"block_terms_d4\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_terms_d4\" placeholder=\"\" style=\"font-size: 12px; color: #555; line-height: 1.6;\">\n <div style=\"font-size: 13px; font-weight: 800; color: #1a2649; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;\">Terms and Conditions</div>\n <div style=\"text-align: justify; margin-bottom: 20px;\">Please send payment within 30 days of receiving this invoice. There will be a 10% interest charge per month on late invoice.</div>\n\n <div style=\"font-size: 13px; font-weight: 800; color: #1a2649; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px;\">Thank You For Your Business</div>\n\n <div style=\"display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px;\">\n <div style=\"width: 22px; height: 22px; background: #1a2649; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #f5c100; font-size: 10px;\">📞</div>\n <span>{{phone_footer}}</span>\n </div>\n\n <div style=\"display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px;\">\n <div style=\"width: 22px; height: 22px; background: #1a2649; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #f5c100; font-size: 10px;\">🌐</div>\n <span>{{website}}</span>\n </div>\n\n <div style=\"display: flex; align-items: center; gap: 8px; font-size: 12px;\">\n <div style=\"width: 22px; height: 22px; background: #1a2649; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #f5c100; font-size: 10px;\">📍</div>\n <span>{{address_footer}}</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"col-item\" style=\"flex: 0.8; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Totals\" id=\"block_totals_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_totals_d4\" placeholder=\"\" style=\"font-size: 13px; color: #555;\">\n <div style=\"display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0;\">\n <span style=\"font-weight: 600; color: #333;\">Sub-total:</span>\n <span>$700.00</span>\n </div>\n <div style=\"display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0;\">\n <span style=\"font-weight: 600; color: #333;\">Discount:</span>\n <span>$0.00</span>\n </div>\n <div style=\"display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0;\">\n <span style=\"font-weight: 600; color: #333;\">Tax (10%):</span>\n <span>$50.00</span>\n </div>\n <div style=\"display: flex; justify-content: space-between; padding: 11px 14px; background: #1a2649; border-radius: 3px; margin-top: 4px;\">\n <span style=\"color: #ffffff; font-size: 15px; font-weight: 800;\">Total:</span>\n <span style=\"color: #ffffff; font-size: 15px; font-weight: 800;\">$750.00</span>\n </div>\n\n <div style=\"text-align: right; margin-top: 28px;\">\n <div style=\"border-top: 1.5px solid #444; width: 140px; margin-left: auto; margin-bottom: 6px;\"></div>\n <div style=\"font-size: 13px; font-weight: 700; color: #333;\">{{signature_name}}</div>\n <div style=\"font-size: 12px; color: #777;\">{{signature_role}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Bottom Footer Bar -->\n <div class=\"row-item\" id=\"row_bottom_d4\" style=\"background: #f5c100; height: 22px; position: relative; overflow: hidden; min-height: 0px;\"></div>\n `,\n};\n\n<\/script>\n <script data-src=\"./js/flow/block-factory.js\">\n/**\n * @fileoverview Block factory for flow canvas.\n *\n * Delegates to BlockCreator (block-creator.js) where possible, otherwise\n * constructs lightweight blocks for the simpler types (Divider, Spacer,\n * Button, Label/Tag, Data Field, List Repeater).\n *\n * Exposes: window.FlowCanvas.createBlock(blockType) → HTMLElement | null\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const blockCreator = (typeof BlockCreator !== 'undefined') ? new BlockCreator() : null;\n\n // ---------------------------------------------------------------------------\n // Lightweight block builders for types that BlockCreator doesn't handle.\n // Each returns a `.cs_block_s` element so it integrates with inline-editor.js\n // selection / editing chrome.\n // ---------------------------------------------------------------------------\n\n const makeCsBlock = (label, blockType, extraClass = '') => {\n if (!blockCreator) {\n const el = document.createElement('div');\n el.className = `cs_block_s ${extraClass}`.trim();\n el.setAttribute('data', label);\n el.setAttribute('custom-name', label);\n el.dataset.blockType = blockType;\n return el;\n }\n const el = blockCreator.getCsBlockSmall(label, extraClass);\n el.setAttribute('custom-name', label);\n el.dataset.blockType = blockType;\n return el;\n };\n\n const hash = () => {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();\n return Math.random().toString(16).slice(2);\n };\n\n const createLabelTagBlock = () => {\n const block = makeCsBlock('Label', 'label-tag', 'cs-label-block');\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-label-tag';\n inner.id = `dynamic_${hash()}`;\n inner.setAttribute('placeholder', 'Featured');\n inner.style.fontSize = '12px';\n inner.style.fontWeight = '600';\n inner.style.display = 'inline-block';\n inner.style.padding = '4px 10px';\n inner.style.borderRadius = '999px';\n inner.style.background = '#eef0ff';\n inner.style.color = '#5c5cff';\n inner.textContent = 'Featured';\n block.appendChild(inner);\n return block;\n };\n\n const createButtonBlock = () => {\n const block = makeCsBlock('Button', 'button', 'cs-button-block');\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-button';\n inner.id = `dynamic_${hash()}`;\n inner.setAttribute('placeholder', 'Call to Action');\n inner.style.display = 'inline-block';\n inner.style.padding = '10px 20px';\n inner.style.background = '#5c5cff';\n inner.style.color = '#fff';\n inner.style.borderRadius = '6px';\n inner.style.fontWeight = '600';\n inner.style.fontSize = '14px';\n inner.style.textAlign = 'center';\n inner.style.cursor = 'pointer';\n inner.textContent = 'Call to Action';\n block.appendChild(inner);\n return block;\n };\n\n const createDividerBlock = () => {\n const block = makeCsBlock('Divider', 'divider', 'cs-divider-block');\n const line = document.createElement('div');\n line.className = 'cs-divider-line';\n line.style.height = '1px';\n line.style.background = '#cfd4f6';\n line.style.width = '100%';\n // line.style.margin = '14px 0';\n block.appendChild(line);\n return block;\n };\n\n const createSpacerBlock = () => {\n const block = makeCsBlock('Spacer', 'spacer', 'cs-spacer-block');\n const space = document.createElement('div');\n space.className = 'cs-spacer';\n space.style.height = '32px';\n space.style.width = '100%';\n space.style.background = 'transparent';\n block.appendChild(space);\n return block;\n };\n\n const createDataFieldBlock = () => {\n const block = makeCsBlock('Data Field', 'data-field', 'cs-data-field-block');\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-data-field';\n inner.id = `dynamic_${hash()}`;\n inner.setAttribute('placeholder', '{{ binding.path }}');\n inner.style.padding = '8px 12px';\n inner.style.border = '1px dashed #cfd4f6';\n inner.style.borderRadius = '4px';\n inner.style.fontFamily = 'monospace';\n inner.style.fontSize = '14px';\n inner.style.color = '#5c5cff';\n inner.textContent = '{{ binding.path }}';\n block.appendChild(inner);\n return block;\n };\n\n const createPageBreakBlock = () => {\n // Page Break is a visual marker. The drop handler in flow-canvas.js\n // recognises this block type and immediately splits the page; the\n // block itself is removed during the split. We still build a styled\n // element so the user sees what they're dragging.\n const block = makeCsBlock('Page Break', 'page-break', 'cs-page-break-block');\n const inner = document.createElement('div');\n inner.className = 'cs-page-break';\n inner.style.display = 'flex';\n inner.style.alignItems = 'center';\n inner.style.gap = '8px';\n inner.style.padding = '8px 12px';\n inner.style.border = '1px dashed #f97316';\n inner.style.background = '#fff7ed';\n inner.style.color = '#c2410c';\n inner.style.fontSize = '12px';\n inner.style.fontWeight = '600';\n inner.style.textTransform = 'uppercase';\n inner.style.letterSpacing = '0.06em';\n inner.style.borderRadius = '4px';\n inner.textContent = '— Page Break —';\n block.appendChild(inner);\n return block;\n };\n\n const createListRepeaterBlock = () => {\n const block = makeCsBlock('List Repeater', 'list-repeater', 'cs-list-repeater-block');\n block.dataset.repeatPath = '';\n block.dataset.repeatAlias = 'item';\n const list = document.createElement('ul');\n list.className = 'edit_me cs-list-repeater';\n list.id = `dynamic_${hash()}`;\n list.style.margin = '0';\n list.style.padding = '0 0 0 20px';\n list.style.fontSize = '14px';\n list.style.lineHeight = '1.6';\n ['Dynamic list item one', 'Dynamic list item two', 'Dynamic list item three'].forEach(text => {\n const li = document.createElement('li');\n li.textContent = text;\n list.appendChild(li);\n });\n block.appendChild(list);\n return block;\n };\n\n const createFlexibleBlock = () => {\n const block = makeCsBlock('Flexible', 'flexible', 'cs-flexible-block');\n block.dataset.blockType = 'flexible';\n block.style.position = 'relative';\n const content = document.createElement('div');\n content.className = 'cs-flexible-content';\n content.id = `dynamic_${hash()}`;\n content.style.position = 'relative';\n content.style.width = '100%';\n content.style.minHeight = `${window.CanvasConfig?.flexible?.defaultHeight ?? 80}px`;\n block.appendChild(content);\n return block;\n };\n\n const createFAIconBlock = (iconName = 'star', iconClass = 'fas fa-star') => {\n const block = makeCsBlock('Icon', 'fa-icon', 'cs-fa-icon-block');\n const container = document.createElement('div');\n container.className = 'cs-fa-icon-container';\n container.style.display = 'flex';\n container.style.alignItems = 'center';\n container.style.justifyContent = 'center';\n container.style.width = '100%';\n container.style.minHeight = '60px';\n container.style.fontSize = '40px';\n container.style.color = '#5c5cff';\n\n const icon = document.createElement('i');\n icon.className = iconClass;\n icon.id = `dynamic_${hash()}`;\n container.appendChild(icon);\n block.appendChild(container);\n\n block.dataset.iconName = iconName;\n block.dataset.iconClass = iconClass;\n return block;\n };\n\n\n\n // ---------------------------------------------------------------------------\n // Builder registry — one entry per block `type`. To add a block, register it\n // in block-registry.js (metadata) AND add a builder here (DOM construction).\n // Each builder returns a `.cs_block_s` element.\n // ---------------------------------------------------------------------------\n const BUILDERS = {\n // Delegated to BlockCreator\n 'heading': () => blockCreator.createTitleBlock({ text: 'New Heading', className: 'add-heading-two', fontSize: '14px' }),\n 'heading-two': () => blockCreator.createTitleBlock({ text: 'New Heading', className: 'add-heading-two', fontSize: '14px' }),\n 'body-text': () => blockCreator.createBodyTextBlock({ fontSize: '14px' }),\n 'section-container': () => blockCreator.createSectionContainerBlock(),\n 'table-repeater': () => blockCreator.createWhiteHeaderTableBlock(),\n 'image': () => blockCreator.createSquareImageBlock(),\n 'video': () => blockCreator.createVideoBlock(),\n\n // Lightweight builders defined above\n 'label-tag': createLabelTagBlock,\n 'button': createButtonBlock,\n 'divider': createDividerBlock,\n 'spacer': createSpacerBlock,\n 'data-field': createDataFieldBlock,\n 'list-repeater': createListRepeaterBlock,\n 'flexible': createFlexibleBlock,\n 'fa-icon': () => createFAIconBlock(),\n 'page-break': createPageBreakBlock,\n 'pen-shape': () => window.PenShape?.createBlock() || null,\n 'table': () => window.TableBlock?.createBlock() || null,\n 'sync-list': () => window.SyncList?.createBlock() || null,\n 'aiden': () => window.Aiden?.createBlock() || null,\n };\n // Expose so other modules / future plugins can register builders.\n window.FlowCanvas.BLOCK_BUILDERS = BUILDERS;\n\n // ---------------------------------------------------------------------------\n // Main factory\n // ---------------------------------------------------------------------------\n\n window.FlowCanvas.createBlock = function (blockType) {\n if (!blockCreator) {\n console.warn('flow-canvas/block-factory: BlockCreator not loaded');\n return null;\n }\n\n // Dynamic dispatch for predefined templates\n const templateMatch = blockType.match(/^predefine-template-(\\d+)$/);\n if (templateMatch) {\n const n = Number(templateMatch[1]);\n return window.FlowCanvas.TEMPLATE_HTML && window.FlowCanvas.TEMPLATE_HTML[n] !== undefined ? window.FlowCanvas.TEMPLATE_HTML[n] : null;\n }\n\n const builder = BUILDERS[blockType];\n if (builder) return builder();\n\n // Unknown type — warn if the registry knows about it (missing builder),\n // then fall back to a generic placeholder block.\n if (window.FormBlockRegistry?.byType(blockType)) {\n console.warn(`flow-canvas/block-factory: no builder for registered type \"${blockType}\"`);\n }\n const el = blockCreator.getCsBlockSmall(blockType);\n el.dataset.blockType = blockType;\n el.innerHTML = `<div class=\"canvas-block__content\"><span class=\"canvas-block__tag\">${blockType}</span></div>`;\n return el;\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/aiden.js\">\n/**\n * @fileoverview Aiden — AI writing-assistant block.\n *\n * A text block (behaves exactly like a normal Heading/Body-Text block when you\n * just click + type) that gains an AI authoring flow when you press the\n * shortcut while focused in it:\n *\n * Windows / Linux : Alt + H\n * macOS : ⌘ + H\n *\n * Empty state shows a \"Help me to write… (Alt + H)\" placeholder, exactly like\n * the title-block placeholder (CSS `:empty:before` on the `.edit_me`).\n *\n * AI flow (a floating action bar under the block drives the phases):\n * 1. prompt — type what you want; [Cancel] [Generate]\n * 2. loading — \"AI:den writing…\" spinner + [Stop] (Stop aborts the request)\n * 3. result — the generated text is written into the block; the bar shows\n * [↻ Recreate] [🎤 Adjust tone] [Cancel] [Insert]\n * - Adjust tone opens a popup (professional / casual) → Apply re-generates.\n * - Recreate re-runs the request with the same prompt.\n * - Insert keeps the generated text; Cancel reverts to the previous content.\n *\n * The actual text generation goes through a configurable seam so a real backend\n * can be wired in without touching this file:\n *\n * window.Aiden.configure({\n * generate: async ({ prompt, tones, signal }) => '<p>…</p>' // HTML or text\n * });\n *\n * Until configured, a built-in stub returns a realistic simulated response (and\n * honours `signal` so Stop works), so the whole UX is exercisable end-to-end.\n *\n * Exposes: window.Aiden.createBlock(), window.Aiden.configure(), window.Aiden.open(block)\n */\n(function () {\n window.Aiden = window.Aiden || {};\n\n const isMac = /Mac|iPhone|iPad|iPod/i.test(\n (navigator.platform || '') + ' ' + (navigator.userAgent || '')\n );\n const SHORTCUT = isMac ? '⌘ H' : 'Alt + H';\n const HINT = `✦ Help me to write…  ${SHORTCUT}`;\n const PROMPT_HINT = '✦ Tell Aiden what to write…';\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID)\n ? crypto.randomUUID() : Math.random().toString(16).slice(2);\n\n /* ----------------------------- generation seam ---------------------------- */\n\n let customGenerate = null;\n window.Aiden.configure = (opts = {}) => {\n if (typeof opts.generate === 'function') customGenerate = opts.generate;\n };\n\n // Built-in simulated writer. Returns HTML. Honours an AbortSignal so Stop\n // cancels it. Replace via window.Aiden.configure({ generate }).\n const simulate = (prompt, tones) => {\n const topic = (prompt || 'your topic').trim().replace(/\\s+/g, ' ');\n const cap = topic.charAt(0).toUpperCase() + topic.slice(1);\n let opener = `Here's a draft about ${topic}.`;\n if (tones && tones.professional) {\n opener = `${cap}: an overview. The following outlines the key considerations in a clear, professional tone.`;\n } else if (tones && tones.casual) {\n opener = `So, about ${topic} — here's the friendly, no-fuss version. 👍`;\n }\n const body = `It brings together the essentials so you can adapt the wording, trim what you don't need, and keep the parts that fit your document. Edit it inline once inserted.`;\n return `<p>${opener}</p><p>${body}</p>`;\n };\n\n const defaultGenerate = ({ prompt, tones, signal }) => new Promise((resolve, reject) => {\n const timer = setTimeout(() => resolve(simulate(prompt, tones)), 1100);\n if (signal) {\n if (signal.aborted) { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); return; }\n signal.addEventListener('abort', () => {\n clearTimeout(timer);\n reject(new DOMException('Aborted', 'AbortError'));\n }, { once: true });\n }\n });\n\n const runGenerate = (args) =>\n Promise.resolve().then(() => (customGenerate || defaultGenerate)(args));\n\n /* ------------------------------- the block -------------------------------- */\n\n window.Aiden.createBlock = () => {\n const bc = (typeof BlockCreator !== 'undefined') ? new BlockCreator() : null;\n const block = bc\n ? bc.getCsBlockSmall('AI Writer', 'cs-aiden-block')\n : Object.assign(document.createElement('div'), { className: 'cs_block_s cs-aiden-block' });\n block.dataset.blockType = 'aiden';\n block.setAttribute('custom-name', 'AI Writer');\n\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-aiden-text';\n inner.id = `aiden_${hash()}`;\n inner.setAttribute('placeholder', HINT);\n inner.style.fontSize = '14px';\n block.appendChild(inner);\n return block;\n };\n\n // Register the builder so createBlock(type='aiden') / drag-drop work.\n if (window.FlowCanvas && window.FlowCanvas.BLOCK_BUILDERS) {\n window.FlowCanvas.BLOCK_BUILDERS['aiden'] = () => window.Aiden.createBlock();\n }\n\n /* ------------------------------ session state ----------------------------- */\n\n // One AI session at a time. { block, editable, phase, prompt, prevHTML,\n // controller, tones }.\n let session = null;\n\n // The action bar + tone popup are docked INSIDE the block (so they sit in the\n // input box itself). They're tagged data-cs-chrome so export + surface-click\n // ignore them, and removeChrome() has an exception so chrome teardown can't\n // wipe them mid-session (see inline-editor.js).\n let bar = null;\n let tonePop = null;\n\n const editableText = () => (session ? (session.editable.textContent || '').trim() : '');\n\n /* --------------------------------- the bar -------------------------------- */\n\n const BTN = (act, label, cls) =>\n `<button type=\"button\" data-act=\"${act}\" class=\"cs-aiden-btn ${cls}\">${label}</button>`;\n\n const renderBar = () => {\n if (!bar || !session) return;\n let html = '';\n if (session.phase === 'prompt') {\n html = `<div class=\"cs-aiden-bar__sp\"></div>`\n + BTN('cancel', 'Cancel', 'cs-aiden-btn--ghost')\n + BTN('generate', 'Generate', 'cs-aiden-btn--primary');\n } else if (session.phase === 'loading') {\n html = `<span class=\"cs-aiden-status\"><span class=\"cs-aiden-spin\"></span>AI:den writing…</span>`\n + `<div class=\"cs-aiden-bar__sp\"></div>`\n + BTN('stop', 'Stop', 'cs-aiden-btn--stop');\n } else { // result\n html = BTN('recreate', '↻ Recreate', 'cs-aiden-btn--link')\n + BTN('tone', '🎤 Adjust tone', 'cs-aiden-btn--link')\n + `<div class=\"cs-aiden-bar__sp\"></div>`\n + BTN('cancel', 'Cancel', 'cs-aiden-btn--ghost')\n + BTN('insert', 'Insert', 'cs-aiden-btn--primary');\n }\n bar.innerHTML = html;\n };\n\n const ensureBar = () => {\n if (bar) return;\n bar = document.createElement('div');\n bar.className = 'cs-aiden-bar';\n bar.setAttribute('data-cs-chrome', '');\n // Keep the caret in the block when a button is pressed, and keep our clicks\n // away from inline-editor's document-level select/teardown listeners.\n bar.addEventListener('mousedown', (e) => { e.preventDefault(); }, true);\n bar.addEventListener('pointerdown', (e) => { e.stopPropagation(); }, true);\n bar.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (!act) return;\n e.preventDefault();\n e.stopPropagation();\n onAction(act);\n });\n if (session) session.block.appendChild(bar);\n };\n\n const onAction = (act) => {\n if (act === 'generate') return generate();\n if (act === 'stop') return stop();\n if (act === 'recreate') return generateCore();\n if (act === 'insert') return commit();\n if (act === 'cancel') return cancel();\n if (act === 'tone') return toggleTonePopup();\n if (act === 'apply-tone') return applyTone();\n };\n\n /* ------------------------------- tone popup ------------------------------- */\n\n const ensureTonePop = () => {\n if (tonePop) return;\n tonePop = document.createElement('div');\n tonePop.className = 'cs-aiden-pop';\n tonePop.setAttribute('data-cs-chrome', '');\n tonePop.innerHTML = `\n <div class=\"cs-aiden-pop__title\">Adjust tone</div>\n <label class=\"cs-aiden-pop__row\"><input type=\"checkbox\" data-tone=\"professional\"> Make it sound professional</label>\n <label class=\"cs-aiden-pop__row\"><input type=\"checkbox\" data-tone=\"casual\"> Make it casual</label>\n <div class=\"cs-aiden-pop__foot\">${BTN('apply-tone', 'Apply', 'cs-aiden-btn--primary')}</div>`;\n tonePop.addEventListener('mousedown', (e) => e.preventDefault(), true);\n tonePop.addEventListener('pointerdown', (e) => e.stopPropagation(), true);\n tonePop.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act) { e.preventDefault(); e.stopPropagation(); onAction(act); }\n });\n (bar || document.body).appendChild(tonePop);\n };\n\n const toggleTonePopup = () => {\n ensureTonePop();\n const open = !tonePop.classList.contains('is-open');\n if (open && session) {\n tonePop.querySelectorAll('input[data-tone]').forEach((cb) => {\n cb.checked = !!session.tones[cb.dataset.tone];\n });\n }\n tonePop.classList.toggle('is-open', open);\n };\n\n const closeTonePopup = () => { if (tonePop) tonePop.classList.remove('is-open'); };\n\n const applyTone = () => {\n if (!session || !tonePop) return;\n tonePop.querySelectorAll('input[data-tone]').forEach((cb) => {\n session.tones[cb.dataset.tone] = cb.checked;\n });\n closeTonePopup();\n generateCore();\n };\n\n /* ----------------------------- phase control ------------------------------ */\n\n const setPhase = (phase) => {\n if (!session) return;\n session.phase = phase;\n session.block.classList.toggle('cs-aiden--loading', phase === 'loading');\n session.block.setAttribute('data-aiden-phase', phase);\n renderBar();\n };\n\n const generate = () => {\n if (!session) return;\n const prompt = editableText();\n if (!prompt) { focusEditable(); return; }\n session.prompt = prompt;\n generateCore();\n };\n\n const generateCore = () => {\n if (!session) return;\n closeTonePopup();\n setPhase('loading');\n const controller = ('AbortController' in window) ? new AbortController() : null;\n session.controller = controller;\n const mine = controller;\n runGenerate({ prompt: session.prompt, tones: session.tones, signal: controller && controller.signal })\n .then((out) => {\n if (!session || session.controller !== mine) return; // superseded / closed\n session.controller = null;\n session.result = out || '';\n session.editable.innerHTML = sanitize(out);\n setPhase('result');\n })\n .catch((err) => {\n if (err && err.name === 'AbortError') return; // Stop handled it\n if (!session || session.controller !== mine) return;\n session.controller = null;\n console.warn('[Aiden] generate failed:', err);\n setPhase('prompt');\n flash('Generation failed — try again.');\n });\n };\n\n const stop = () => {\n if (!session) return;\n if (session.controller) { try { session.controller.abort(); } catch (e) { /* */ } }\n session.controller = null;\n setPhase('prompt'); // editable still shows the prompt\n focusEditable();\n };\n\n // Insert: keep the generated text as the block's content and leave AI mode.\n const commit = () => {\n if (!session) return;\n session.editable.setAttribute('placeholder', HINT);\n close();\n };\n\n // Cancel: discard everything and restore the block's previous content.\n const cancel = () => {\n if (!session) return;\n if (session.controller) { try { session.controller.abort(); } catch (e) { /* */ } }\n session.editable.innerHTML = session.prevHTML;\n session.editable.setAttribute('placeholder', HINT);\n close();\n };\n\n const close = () => {\n if (session) {\n session.block.classList.remove('cs-aiden--active', 'cs-aiden--loading');\n session.block.removeAttribute('data-aiden-phase');\n }\n closeTonePopup();\n if (tonePop) { tonePop.remove(); tonePop = null; }\n if (bar) { bar.remove(); bar = null; }\n session = null;\n };\n\n /* -------------------------------- helpers --------------------------------- */\n\n // Very small guard so a configured backend can't inject scripts. Allows basic\n // formatting tags; everything else is treated as text by the browser anyway\n // once assigned via innerHTML, so we just strip <script>.\n const sanitize = (html) => String(html == null ? '' : html).replace(/<\\s*script[^>]*>[\\s\\S]*?<\\s*\\/\\s*script\\s*>/gi, '');\n\n const focusEditable = () => {\n if (!session) return;\n try {\n session.editable.focus();\n const sel = window.getSelection();\n if (sel && session.editable.lastChild) {\n const range = document.createRange();\n range.selectNodeContents(session.editable);\n range.collapse(false);\n sel.removeAllRanges();\n sel.addRange(range);\n }\n } catch (e) { /* */ }\n };\n\n const flash = (msg) => {\n if (!bar) return;\n const n = document.createElement('span');\n n.className = 'cs-aiden-flash';\n n.textContent = msg;\n bar.insertBefore(n, bar.firstChild);\n setTimeout(() => n.remove(), 2600);\n };\n\n /* ------------------------------- open AI mode ----------------------------- */\n\n window.Aiden.open = (block) => {\n if (!block || !block.classList || !block.classList.contains('cs-aiden-block')) return;\n const editable = block.querySelector('.edit_me');\n if (!editable) return;\n if (session && session.block === block) return; // already open here\n if (session) close();\n\n session = {\n block,\n editable,\n phase: 'prompt',\n prompt: (editable.textContent || '').trim(),\n prevHTML: editable.innerHTML,\n controller: null,\n tones: { professional: false, casual: false },\n };\n block.classList.add('cs-aiden--active');\n editable.setAttribute('contenteditable', 'true');\n editable.setAttribute('placeholder', PROMPT_HINT);\n\n ensureBar();\n setPhase('prompt');\n focusEditable();\n };\n\n /* -------------------------------- shortcut -------------------------------- */\n\n // Alt+H (Win/Linux) or ⌘+H (mac) while focused in / on an Aiden block.\n const onKey = (e) => {\n if (e.code !== 'KeyH' && (e.key || '').toLowerCase() !== 'h') return;\n const combo = isMac ? (e.metaKey && !e.ctrlKey && !e.altKey) : (e.altKey && !e.ctrlKey && !e.metaKey);\n if (!combo) {\n // Escape closes an open session (acts like Cancel).\n return;\n }\n const block = e.target?.closest?.('.cs-aiden-block')\n || document.querySelector('.cs-aiden-block.cs-editing, .cs-aiden-block.cs-selected');\n if (!block) return;\n e.preventDefault();\n e.stopPropagation();\n window.Aiden.open(block);\n };\n\n const onKeyAux = (e) => {\n if (!session) return;\n if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }\n // Ctrl/Cmd+Enter generates from the prompt.\n if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && session.phase === 'prompt') {\n e.preventDefault();\n generate();\n }\n };\n\n // Clicking outside the block + bar finalises: keep the result, otherwise cancel.\n const onDocPointerDown = (e) => {\n if (!session) return;\n const t = e.target;\n if (t.closest?.('.cs-aiden-bar, .cs-aiden-pop')) return;\n if (session.block.contains(t)) return;\n if (session.phase === 'result') commit();\n else cancel();\n };\n\n const init = () => {\n document.addEventListener('keydown', onKey, true);\n document.addEventListener('keydown', onKeyAux, true);\n document.addEventListener('pointerdown', onDocPointerDown, true);\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/pen-shape.js\">\n/**\n * @fileoverview Pen Shape block — a Photoshop-style vector pen tool.\n *\n * A draggable block whose content is an SVG vector shape the user draws with a\n * pen tool. The drawing/editing only activates when the block is in EDIT mode\n * (the `.cs-editing` class added by inline-editor.js after the second click).\n *\n * click → selected (badge) – no pen UI\n * click again → editing (.cs-editing) – pen UI + anchor markers active\n * click outside / Esc → deselect – pen UI removed\n *\n * Pen behaviour (mirrors Photoshop's Pen tool):\n * - click on empty canvas → add a corner anchor\n * - click-and-drag → add a smooth anchor (drag sets the bézier handles)\n * - Alt while dragging → break the handle (independent / corner-ish)\n * - click the first anchor → close the path → switch to direct-select mode\n * - drag an anchor → move it (handles move with it)\n * - drag a handle endpoint → reshape the curve (mirrored unless Alt held)\n * - Alt-click an anchor → convert corner ↔ smooth\n * - Ctrl/Cmd+Z / Shift+Z → step-by-step undo / redo (anchor markers redraw)\n * - Delete / Backspace → remove the selected (or last) anchor\n * - Enter → close the path\n *\n * Extras: solid / gradient / image fill, rotate (whole path), and a\n * smooth/round-corners pass.\n *\n * The drawn shape is stored two ways so it survives HTML export + reload:\n * - rendered : <path d=\"…\" fill=\"…\"> inside the block's SVG (exported as-is)\n * - editable : block.dataset.penPath = JSON({paths:[{anchors,closed},…]}) so\n * re-editing can rebuild every sub-path exactly. Multiple\n * sub-paths let one block hold several separate clip-shapes.\n *\n * Exposes window.PenShape.createBlock() (called by the block factory).\n */\n(function () {\n window.PenShape = window.PenShape || {};\n\n const NS = 'http://www.w3.org/2000/svg';\n // SVG user-space the path coords live in. The SVG stretches to fill the block\n // (preserveAspectRatio=\"none\") so resizing the block scales the shape — coords\n // stay stable, which keeps editing math simple and export deterministic.\n const VB = 1000;\n const CX = VB / 2, CY = VB / 2;\n const HIT_PX = 9; // pointer pick radius in screen px\n const DEFAULT_FILL = '#248567';\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2));\n const clone = (o) => JSON.parse(JSON.stringify(o));\n const ns = (tag, attrs) => { const el = document.createElementNS(NS, tag); for (const k in attrs) el.setAttribute(k, attrs[k]); return el; };\n\n // Rotate a point by `deg` around the viewBox centre.\n const rot = (x, y, deg) => {\n if (!deg) return { x, y };\n const a = deg * Math.PI / 180, dx = x - CX, dy = y - CY;\n return { x: CX + dx * Math.cos(a) - dy * Math.sin(a), y: CY + dx * Math.sin(a) + dy * Math.cos(a) };\n };\n\n /* --------------------------- path serialisation --------------------------- */\n\n // One segment p→c: cubic bézier if either side carries a handle, else a line.\n const seg = (p, c) => {\n const hasOut = p.outX != null, hasIn = c.inX != null;\n if (hasOut || hasIn) {\n const c1x = hasOut ? p.outX : p.x, c1y = hasOut ? p.outY : p.y;\n const c2x = hasIn ? c.inX : c.x, c2y = hasIn ? c.inY : c.y;\n return ` C ${c1x} ${c1y} ${c2x} ${c2y} ${c.x} ${c.y}`;\n }\n return ` L ${c.x} ${c.y}`;\n };\n\n const buildSubD = (anchors, closed) => {\n if (!anchors.length) return '';\n let d = `M ${anchors[0].x} ${anchors[0].y}`;\n for (let i = 1; i < anchors.length; i++) d += seg(anchors[i - 1], anchors[i]);\n if (closed && anchors.length > 2) { d += seg(anchors[anchors.length - 1], anchors[0]); d += ' Z'; }\n return d;\n };\n\n // The block holds MANY independent sub-paths (state.paths), each drawn as its\n // own <path> with its own per-path style — see renderShape().\n\n /* ------------------------------ state / style ----------------------------- */\n\n const DEFAULT_STYLE = {\n fillType: 'solid', fill: DEFAULT_FILL, fillOpacity: 1,\n gradFrom: '#5c5cff', gradTo: '#a855f7', gradAngle: 90,\n gradKind: 'linear', // 'linear' | 'radial'\n gradStops: null, // optional [color, color, …] (evenly spaced); falls back to from/to\n imageSrc: '',\n stroke: '', strokeWidth: 0,\n rotate: 0,\n blend: 'normal', // CSS mix-blend-mode for layered translucent shapes\n };\n\n // The colour stops for a gradient: an explicit gradStops list (≥2) wins,\n // else the legacy from/to pair. Offsets are spread evenly across 0→100%.\n const gradStopColors = (s) => (\n Array.isArray(s.gradStops) && s.gradStops.length >= 2\n ? s.gradStops.slice()\n : [s.gradFrom || '#5c5cff', s.gradTo || '#a855f7']\n );\n\n const readStyle = (block) => { try { return Object.assign({}, DEFAULT_STYLE, JSON.parse(block.dataset.penStyle)); } catch { return Object.assign({}, DEFAULT_STYLE); } };\n const writeStyle = (block, style) => { block.dataset.penStyle = JSON.stringify(style); };\n const readState = (block) => {\n try {\n const s = JSON.parse(block.dataset.penPath);\n if (s && Array.isArray(s.paths)) return s;\n if (s && Array.isArray(s.anchors)) return { paths: [{ anchors: s.anchors, closed: !!s.closed }] }; // migrate old single-path\n } catch { /* */ }\n return { paths: [] };\n };\n const writeState = (block, state) => { block.dataset.penPath = JSON.stringify(state); };\n\n const buildGradient = (id, s) => {\n let g;\n if (s.gradKind === 'radial') {\n g = ns('radialGradient', { id, 'data-pen-def': '', cx: 0.5, cy: 0.5, r: 0.5 });\n } else {\n const a = (s.gradAngle ?? 90) * Math.PI / 180;\n g = ns('linearGradient', {\n id, 'data-pen-def': '',\n x1: (0.5 - Math.cos(a) / 2), y1: (0.5 - Math.sin(a) / 2),\n x2: (0.5 + Math.cos(a) / 2), y2: (0.5 + Math.sin(a) / 2),\n });\n }\n const stops = gradStopColors(s), n = stops.length;\n stops.forEach((c, i) => g.appendChild(ns('stop', {\n offset: `${n > 1 ? (i / (n - 1)) * 100 : 0}%`, 'stop-color': c,\n })));\n return g;\n };\n\n const buildPattern = (id, s) => {\n const p = ns('pattern', { id, 'data-pen-def': '', patternUnits: 'userSpaceOnUse', width: VB, height: VB });\n const img = ns('image', { width: VB, height: VB, preserveAspectRatio: 'xMidYMid slice', href: s.imageSrc });\n img.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', s.imageSrc); // legacy fallback\n p.appendChild(img);\n return p;\n };\n\n // A path's geometry rings. A normal shape is a single ring (its anchors); a\n // MERGED shape flattens several originals into `rings` (anchors stays empty).\n const ringsOf = (p) => (\n p && p.rings && p.rings.length ? p.rings : [{ anchors: p.anchors || [], closed: p.closed }]\n );\n\n // Resolve the effective style for one sub-path: its own per-path style when\n // present, else the block-level style (back-compat for shapes drawn before\n // per-path styling existed).\n const pathStyleOf = (p, blockStyle) => (\n p && p.style ? Object.assign({}, DEFAULT_STYLE, p.style) : blockStyle\n );\n\n // Render every sub-path as its OWN <path class=\"cs-pen-fill\" data-pi=\"i\">\n // element with its OWN fill/gradient/image/stroke/rotate. This is what lets\n // each clip-path carry a different colour/style. defs get per-path unique ids.\n const renderShape = (block) => {\n const svg = block.querySelector('.cs-pen-svg');\n if (!svg) return;\n const state = readState(block);\n const blockStyle = readStyle(block);\n const uid = (block.querySelector('.cs-pen-shape')?.id || 'pen');\n\n let defs = svg.querySelector('defs');\n if (!defs) { defs = ns('defs', {}); svg.insertBefore(defs, svg.firstChild); }\n defs.querySelectorAll('[data-pen-def]').forEach((e) => e.remove());\n svg.querySelectorAll('.cs-pen-fill').forEach((e) => e.remove());\n\n state.paths.forEach((p, i) => {\n if (p.hidden) return;\n const d = ringsOf(p).map((r) => buildSubD(r.anchors, r.closed)).filter(Boolean).join(' ');\n if (!d) return;\n const style = pathStyleOf(p, blockStyle);\n const pathEl = ns('path', { class: 'cs-pen-fill', 'data-pi': String(i) });\n\n let fill = style.fill || DEFAULT_FILL;\n if (style.fillType === 'gradient') { const id = `grad_${uid}_${i}`; defs.appendChild(buildGradient(id, style)); fill = `url(#${id})`; }\n else if (style.fillType === 'image' && style.imageSrc) { const id = `pat_${uid}_${i}`; defs.appendChild(buildPattern(id, style)); fill = `url(#${id})`; }\n\n pathEl.setAttribute('d', d);\n pathEl.setAttribute('fill', fill);\n pathEl.setAttribute('fill-opacity', style.fillOpacity ?? 1);\n if (style.stroke && (style.strokeWidth || 0) > 0) {\n pathEl.setAttribute('stroke', style.stroke);\n pathEl.setAttribute('stroke-width', style.strokeWidth);\n pathEl.setAttribute('vector-effect', 'non-scaling-stroke');\n pathEl.setAttribute('stroke-linejoin', 'round');\n }\n if (style.rotate) pathEl.setAttribute('transform', `rotate(${style.rotate} ${CX} ${CY})`);\n // Blend mode for layered translucent shapes (inline style → survives export).\n if (style.blend && style.blend !== 'normal') pathEl.style.mixBlendMode = style.blend;\n svg.appendChild(pathEl);\n });\n };\n\n /* ------------------------------ block factory ----------------------------- */\n\n // Start empty — the user draws their own shape(s) with the pen tool.\n const defaultState = () => ({ paths: [] });\n\n window.PenShape.createBlock = function () {\n const bc = (typeof BlockCreator !== 'undefined') ? new BlockCreator() : null;\n const block = bc ? bc.getCsBlockSmall('Pen Shape', 'cs-pen-shape-block')\n : Object.assign(document.createElement('div'), { className: 'cs_block_s cs-pen-shape-block' });\n block.dataset.blockType = 'pen-shape';\n block.setAttribute('custom-name', 'Pen Shape');\n\n const inner = document.createElement('div');\n inner.className = 'cs-pen-shape';\n inner.id = `pen_${hash()}`;\n\n const svg = ns('svg', { class: 'cs-pen-svg', viewBox: `0 0 ${VB} ${VB}`, preserveAspectRatio: 'none' });\n svg.appendChild(ns('path', { class: 'cs-pen-fill' }));\n inner.appendChild(svg);\n block.appendChild(inner);\n // Default height comes from CSS (.cs-pen-shape-block) so it survives\n // normalizeForFlow()'s inline-style strip on drop. A manual resize sets an\n // inline height that overrides the CSS default.\n\n writeState(block, defaultState());\n writeStyle(block, Object.assign({}, DEFAULT_STYLE));\n renderShape(block);\n return block;\n };\n\n /* ------------------------------ edit session ------------------------------ */\n // Only one block edits at a time (mirrors EditorManager). `S` holds its state.\n let S = null;\n // Module-level clip-path clipboard for copy/paste (persists across blocks).\n let penClip = null;\n\n const innerRect = () => S.inner.getBoundingClientRect();\n\n // viewBox coord → screen px (within the overlay), rotating by `deg` (defaults\n // to the active path's rotation S.rotate).\n const vbToPxR = (vx, vy, deg) => {\n const r = innerRect();\n const p = rot(vx, vy, deg);\n // Markers are drawn into the overlay SVG. In the page designer the overlay\n // is enlarged BEYOND the block (so points can be placed off-page), so its\n // top-left no longer matches the block's. Offset by that delta to keep the\n // anchor/handle markers aligned with the rendered shape. (inline blocks:\n // overlay === block → delta is 0, unchanged.)\n let ox = 0, oy = 0;\n if (S.overlay) { const o = S.overlay.getBoundingClientRect(); ox = r.left - o.left; oy = r.top - o.top; }\n return { x: ox + p.x / VB * r.width, y: oy + p.y / VB * r.height };\n };\n const vbToPx = (vx, vy) => vbToPxR(vx, vy, S.rotate);\n // client px → viewBox coord (un-rotated to the active path's model space).\n const clientToVb = (cx, cy) => {\n const r = innerRect();\n const raw = { x: (cx - r.left) / r.width * VB, y: (cy - r.top) / r.height * VB };\n return rot(raw.x, raw.y, -S.rotate);\n };\n // client px → viewBox coord WITHOUT un-rotating (true rendered position).\n // Used for picking which sub-path the pointer is over (each path may carry a\n // different rotation, so we test against each path's own rotated geometry).\n const clientToVbRaw = (cx, cy) => {\n const r = innerRect();\n return { x: (cx - r.left) / r.width * VB, y: (cy - r.top) / r.height * VB };\n };\n const hitVb = () => { const r = innerRect(); return HIT_PX / ((r.width + r.height) / 2) * VB; };\n\n const snapshot = () => { S.undo.push(clone(S.state)); if (S.undo.length > 100) S.undo.shift(); S.redo.length = 0; };\n\n const commit = () => { writeState(S.block, S.state); renderShape(S.block); drawOverlay(); renderLayers(); };\n\n // Index of the sub-path currently open (still being drawn), else the last\n // path (so edits/undo target something sensible).\n const openPathIndex = () => {\n if (!S) return -1;\n const i = S.state.paths.findIndex((p) => !p.closed);\n return i >= 0 ? i : S.state.paths.length - 1;\n };\n\n // Hit-test anchors/handles of the ACTIVE sub-path only. Selecting a different\n // sub-path is done separately (pickPath) so each clip-path is edited/styled in\n // isolation. Returns { type, p, i } (p = path index = activePath).\n const pick = (vb) => {\n const t = hitVb(), pi = S.activePath, path = S.state.paths[pi];\n if (!path) return null;\n if (S.sel && S.sel.p === pi) {\n const sp = path.anchors[S.sel.i];\n if (sp) {\n if (sp.inX != null && Math.hypot(sp.inX - vb.x, sp.inY - vb.y) <= t) return { type: 'in', p: pi, i: S.sel.i };\n if (sp.outX != null && Math.hypot(sp.outX - vb.x, sp.outY - vb.y) <= t) return { type: 'out', p: pi, i: S.sel.i };\n }\n }\n const a = path.anchors;\n for (let i = 0; i < a.length; i++) if (Math.hypot(a[i].x - vb.x, a[i].y - vb.y) <= t) return { type: 'anchor', p: pi, i };\n return null;\n };\n\n // Even-odd point-in-polygon, used to pick which sub-path the pointer is over.\n const pointInPolygon = (pt, poly) => {\n let inside = false;\n for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {\n const a = poly[i], b = poly[j];\n if (((a.y > pt.y) !== (b.y > pt.y)) &&\n (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) inside = !inside;\n }\n return inside;\n };\n\n // Is the (un-rotated) pointer inside a sub-path's filled, rendered area?\n // Samples the béziers into a polygon and applies the path's own rotation so\n // the test matches what the user actually sees.\n const pointInPath = (vbRaw, path) => {\n if (!path) return false;\n const deg = (path.style && path.style.rotate) || 0;\n return ringsOf(path).some((r) => {\n if (!r.closed || r.anchors.length < 3) return false;\n const a = r.anchors, n = a.length, poly = [];\n for (let i = 0; i < n; i++) {\n const p = a[i], c = a[(i + 1) % n];\n for (let t = 0; t < 1; t += 0.1) { const s = sampleSeg(p, c, t); poly.push(rot(s.x, s.y, deg)); }\n }\n return pointInPolygon(vbRaw, poly);\n });\n };\n\n // Topmost sub-path (last drawn paints on top) whose fill the pointer is over.\n // Skips hidden + locked layers (those are only selectable from the panel).\n const pickPath = (vbRaw) => {\n for (let i = S.state.paths.length - 1; i >= 0; i--) {\n const p = S.state.paths[i];\n if (p.hidden || p.locked) continue;\n if (pointInPath(vbRaw, p)) return i;\n }\n return -1;\n };\n\n /* --------------------------- per-path style ------------------------------- */\n\n // The active sub-path's style (its own when set, else the block default).\n const getActiveStyle = () => {\n const p = S.state.paths[S.activePath];\n if (p && p.style) return Object.assign({}, DEFAULT_STYLE, p.style);\n return readStyle(S.block);\n };\n // Write the style onto the active sub-path AND remember it as the block\n // default so the NEXT new sub-path inherits it. Persist the per-path style to\n // dataset.penPath immediately — renderShape() re-reads from there, so without\n // this the colour wouldn't show until a later action flushed the state.\n const setActiveStyle = (style) => {\n const p = S.state.paths[S.activePath];\n if (p) p.style = style;\n writeStyle(S.block, style);\n writeState(S.block, S.state);\n };\n\n // Make `pi` the active sub-path: sync rotation + the toolbar style inputs to\n // it so the next edits affect that clip-path only.\n const selectPath = (pi) => {\n if (!S || pi < 0 || pi >= S.state.paths.length) return;\n S.activePath = pi;\n S.sel = null;\n const st = getActiveStyle();\n S.rotate = st.rotate || 0;\n writeStyle(S.block, st); // new paths inherit the selected path's look\n S.applyStyleValues?.();\n syncToolbar();\n };\n\n const setSmooth = (p, ox, oy) => { p.outX = ox; p.outY = oy; p.inX = 2 * p.x - ox; p.inY = 2 * p.y - oy; };\n // Keep anchors inside the block — EXCEPT in the page designer (freeDraw), where\n // points may sit off-page (a one-page bleed margin each side) so the user can\n // design past the page edge. Off-page geometry simply clips to the page on save.\n const clampVb = (v) => (S && S.freeDraw)\n ? Math.max(-VB, Math.min(2 * VB, v))\n : Math.max(0, Math.min(VB, v));\n\n /* ------------------------------- pointer ops ------------------------------ */\n\n // Resize mode: re-show the block's resize handles (hidden during shape editing\n // so they don't cover the corner anchors) and stop the overlay from grabbing\n // pointer events meant for those handles.\n const setResizeMode = (on) => {\n if (!S) return;\n S.resizeMode = !!on;\n S.block.classList.toggle('cs-pen-resizing', S.resizeMode);\n S.sel = null;\n drawOverlay();\n };\n\n const onDown = (e) => {\n if (!S || S.resizeMode) return; // let the block resize handles work\n e.preventDefault(); e.stopPropagation();\n S.overlay.setPointerCapture?.(e.pointerId);\n const vb = clientToVb(e.clientX, e.clientY);\n // Space held → \"move whole clip-path\" override: a drag anywhere relocates the\n // active shape, even over an anchor/handle (works in pen AND edit mode).\n if (S.spaceHeld) { startShapeDrag(vb); return; }\n const hit = pick(vb);\n const alt = e.altKey;\n\n if (S.mode === 'pen') {\n const ap = S.state.paths[S.activePath];\n const open = ap && !ap.closed;\n\n if (open) {\n // --- drawing ---\n // close the active open sub-path by clicking its first anchor\n if (hit && hit.type === 'anchor' && hit.p === S.activePath && hit.i === 0 && ap.anchors.length > 2) {\n snapshot(); ap.closed = true; S.sel = null; S.penHover = null; commit(); return;\n }\n // grab an existing anchor/handle to tweak while drawing (drag = handle)\n if (hit) { if (hit.type === 'anchor') S.sel = { p: hit.p, i: hit.i }; startDrag(hit, vb); return; }\n // add the next point (smart-guide aligned)\n snapshot();\n { const s = alignSnap(snapV(vb.x), snapV(vb.y), null); ap.anchors.push({ x: clampVb(s.x), y: clampVb(s.y) }); }\n S.sel = { p: S.activePath, i: ap.anchors.length - 1 };\n S.drag = { kind: 'new', p: S.activePath, i: S.sel.i };\n commit();\n return;\n }\n\n // --- editing a COMPLETED shape with the pen ---\n // Drag a handle dot of the selected point → curve. Drag a point → MOVE it.\n // Alt-click a point → remove it (delete is Alt-gated). Click outline → ADD.\n if (ap && ap.closed) {\n // A handle (in/out) of the currently-selected anchor → reshape the curve.\n const hp = pick(vb);\n if (hp && (hp.type === 'in' || hp.type === 'out')) {\n S.sel = { p: hp.p, i: hp.i };\n startDrag(hp, vb);\n return;\n }\n const ai = hitAnchor(vb, ap);\n if (ai >= 0) {\n if (alt) {\n // Alt-click a point → remove it (so a stray click can't drop a point).\n snapshot();\n const path = S.state.paths[S.activePath];\n path.anchors.splice(ai, 1);\n if (path.anchors.length < 3) path.closed = false;\n S.sel = null; S.penHover = null; commit();\n return;\n }\n // No Alt → select the point and drag to MOVE it (a clean click just\n // selects, which reveals its handle dots — drag those to curve).\n snapshot();\n S.sel = { p: S.activePath, i: ai };\n S.penHover = null;\n S.drag = { kind: 'anchor', p: S.activePath, i: ai, ox: vb.x, oy: vb.y };\n drawOverlay();\n return;\n }\n const seg = findSegmentInsertion(vb, ap);\n if (seg) {\n snapshot();\n ap.anchors.splice(seg.i + 1, 0, { x: seg.pt.x, y: seg.pt.y });\n S.sel = { p: S.activePath, i: seg.i + 1 }; S.penHover = null; commit();\n return;\n }\n }\n // Over a DIFFERENT closed shape → select it (so its points become editable).\n const overPath = pickPath(clientToVbRaw(e.clientX, e.clientY));\n if (overPath >= 0) { S.selected?.clear(); selectPath(overPath); return; }\n // Empty space → start a BRAND-NEW shape (its own style copy).\n snapshot();\n const fp = alignSnap(snapV(vb.x), snapV(vb.y), null);\n const path = { anchors: [{ x: clampVb(fp.x), y: clampVb(fp.y) }], closed: false, name: nextPathName(), style: Object.assign({}, readStyle(S.block)) };\n S.state.paths.push(path); S.activePath = S.state.paths.length - 1;\n S.sel = { p: S.activePath, i: 0 };\n S.drag = { kind: 'new', p: S.activePath, i: 0 };\n S.penHover = null;\n commit();\n return;\n }\n\n // EDIT (direct-select) mode. A locked active layer can't be anchor-edited\n // or dragged (you can still select a DIFFERENT layer to switch away).\n const activeLocked = !!S.state.paths[S.activePath]?.locked;\n if (hit && !activeLocked) {\n if (hit.type === 'anchor' && alt) {\n snapshot();\n const p = S.state.paths[hit.p].anchors[hit.i];\n if (p.inX != null || p.outX != null) { delete p.inX; delete p.inY; delete p.outX; delete p.outY; }\n else setSmooth(p, p.x + 80, p.y);\n S.sel = { p: hit.p, i: hit.i }; commit(); return;\n }\n S.sel = { p: hit.p, i: hit.i };\n startDrag(hit, vb);\n return;\n }\n // MOVE (hand) tool — move ONLY (no point inserting; that's the pen's job):\n // • over a DIFFERENT sub-path → select it\n // • over the ACTIVE shape → drag the whole shape\n // • empty space → deselect\n const vbRaw = clientToVbRaw(e.clientX, e.clientY);\n const overPath = pickPath(vbRaw);\n if (overPath >= 0 && overPath !== S.activePath) { S.selected?.clear(); selectPath(overPath); return; }\n if (overPath === S.activePath && !activeLocked) {\n startShapeDrag(vb);\n return;\n }\n S.sel = null; drawOverlay();\n };\n\n const startDrag = (hit, vb) => { snapshot(); S.drag = { kind: hit.type, p: hit.p, i: hit.i, ox: vb.x, oy: vb.y }; drawOverlay(); };\n\n const onMove = (e) => {\n if (!S) return;\n const vb = clientToVb(e.clientX, e.clientY);\n if (!S.drag) {\n const ap = S.state.paths[S.activePath];\n S.cursor = null; S.penHover = null; S.guides = null;\n if (S.mode === 'pen' && ap) {\n if (!ap.closed && ap.anchors.length) {\n const snap = alignSnap(snapV(vb.x), snapV(vb.y), null); // smart-guide the next point\n S.cursor = { x: clampVb(snap.x), y: clampVb(snap.y) };\n } else if (ap.closed) {\n // Hovering a completed shape: over a point, show the × REMOVE marker\n // ONLY while Alt is held (delete is Alt-gated) — without Alt the point\n // is draggable to curve it. Over the outline, show the + ADD marker.\n const ai = hitAnchor(vb, ap);\n if (ai >= 0) { if (e.altKey) S.penHover = { kind: 'remove', i: ai }; }\n else { const seg = findSegmentInsertion(vb, ap); if (seg) S.penHover = { kind: 'add', x: seg.pt.x, y: seg.pt.y }; }\n }\n }\n drawOverlay();\n return;\n }\n const d = S.drag, a = S.state.paths[d.p].anchors;\n if (d.kind === 'shape') {\n // Translate every ring of the shape; snap the delta when snap is on.\n const dx = snapDelta(vb.x - d.ox), dy = snapDelta(vb.y - d.oy);\n const path = S.state.paths[d.p];\n const moved = d.orig.map((r) => ({\n closed: r.closed,\n anchors: r.anchors.map((o) => {\n const np = { x: o.x + dx, y: o.y + dy };\n if (o.inX != null) { np.inX = o.inX + dx; np.inY = o.inY + dy; }\n if (o.outX != null) { np.outX = o.outX + dx; np.outY = o.outY + dy; }\n return np;\n }),\n }));\n if (path.rings && path.rings.length) path.rings = moved;\n else { path.anchors = moved[0].anchors; path.closed = moved[0].closed; }\n } else if (d.kind === 'new') {\n const p = a[d.i];\n if (e.altKey) { p.outX = vb.x; p.outY = vb.y; } else setSmooth(p, vb.x, vb.y);\n } else if (d.kind === 'anchor') {\n const snap = alignSnap(snapV(vb.x), snapV(vb.y), { p: d.p, i: d.i });\n const p = a[d.i], nx = clampVb(snap.x), nyv = clampVb(snap.y), dx = nx - p.x, dy = nyv - p.y;\n p.x = nx; p.y = nyv;\n if (p.inX != null) { p.inX += dx; p.inY += dy; }\n if (p.outX != null) { p.outX += dx; p.outY += dy; }\n } else {\n const p = a[d.i];\n if (d.kind === 'out') { p.outX = vb.x; p.outY = vb.y; if (!e.altKey && p.inX != null) { p.inX = 2 * p.x - vb.x; p.inY = 2 * p.y - vb.y; } }\n else { p.inX = vb.x; p.inY = vb.y; if (!e.altKey && p.outX != null) { p.outX = 2 * p.x - vb.x; p.outY = 2 * p.y - vb.y; } }\n }\n writeState(S.block, S.state); renderShape(S.block); drawOverlay();\n };\n\n const onUp = () => {\n if (!S || !S.drag) return;\n // Pen-mode point delete is now immediate on Alt-click (onDown); a plain\n // click/drag here either moved the point or just selected it.\n S.drag = null;\n S.guides = null;\n commit();\n };\n\n const sampleSeg = (p, c, t) => {\n const hasOut = p.outX != null, hasIn = c.inX != null;\n if (!hasOut && !hasIn) return { x: p.x + (c.x - p.x) * t, y: p.y + (c.y - p.y) * t };\n const c1x = hasOut ? p.outX : p.x, c1y = hasOut ? p.outY : p.y;\n const c2x = hasIn ? c.inX : c.x, c2y = hasIn ? c.inY : c.y, u = 1 - t;\n return {\n x: u * u * u * p.x + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * c.x,\n y: u * u * u * p.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * c.y,\n };\n };\n\n // Index of an anchor of `path` under `vb`, or -1. Used by the pen tool's\n // hover add/remove affordances.\n const hitAnchor = (vb, path) => {\n if (!path) return -1;\n const t = hitVb();\n for (let i = 0; i < path.anchors.length; i++) {\n if (Math.hypot(path.anchors[i].x - vb.x, path.anchors[i].y - vb.y) <= t) return i;\n }\n return -1;\n };\n\n // The candidate insertion point on `path`'s outline nearest `vb` (within the\n // hit tolerance), or null. { i, pt } — insert after anchor i.\n const findSegmentInsertion = (vb, path) => {\n if (!path) return null;\n let best = null;\n const a = path.anchors, n = a.length;\n for (let i = 0; i < n; i++) {\n if (i === n - 1 && !path.closed) break;\n const p = a[i], c = a[(i + 1) % n];\n for (let t = 0.05; t < 1; t += 0.05) {\n const pt = sampleSeg(p, c, t), dist = Math.hypot(pt.x - vb.x, pt.y - vb.y);\n if (!best || dist < best.dist) best = { dist, i, pt };\n }\n }\n return (best && best.dist <= hitVb() * 1.8) ? best : null;\n };\n\n // Begin dragging the whole active shape (translate all its rings).\n const startShapeDrag = (vb) => {\n const path = S.state.paths[S.activePath];\n if (!path) return;\n snapshot();\n S.drag = { kind: 'shape', p: S.activePath, ox: vb.x, oy: vb.y, orig: clone(ringsOf(path)) };\n drawOverlay();\n };\n\n /* ------------------------------ overlay draw ------------------------------ */\n\n const drawOverlay = () => {\n if (!S) return;\n const r = innerRect(), ov = S.ovSvg;\n ov.setAttribute('width', r.width); ov.setAttribute('height', r.height);\n ov.setAttribute('viewBox', `0 0 ${r.width} ${r.height}`);\n ov.replaceChildren();\n if (S.resizeMode) return; // box-resize mode: anchors hidden, handles drive\n const paths = S.state.paths;\n const ap = paths[S.activePath];\n // Hand / Move tool: hide the clip-path selection chrome (anchor squares +\n // bézier handles) for a clean view. The Pen tool brings them back. Guides /\n // rubber-band still draw (the former only during a whole-shape snap-drag).\n const showMarks = S.mode !== 'edit';\n\n // Smart alignment guides (full-bleed dashed lines through the snapped x / y).\n // Span the FULL overlay, not just the block: in the page designer the overlay\n // extends past the page, and vbToPx places markers relative to the overlay —\n // so a line drawn only 0..blockWidth would land shifted into the bleed margin.\n if (S.guides) {\n const orect = S.overlay ? S.overlay.getBoundingClientRect() : r;\n const ow = orect.width, oh = orect.height;\n if (S.guides.gx != null) { const gx = vbToPx(S.guides.gx, 0).x; ov.appendChild(ns('line', { x1: gx, y1: 0, x2: gx, y2: oh, class: 'cs-pen-guide' })); }\n if (S.guides.gy != null) { const gy = vbToPx(0, S.guides.gy).y; ov.appendChild(ns('line', { x1: 0, y1: gy, x2: ow, y2: gy, class: 'cs-pen-guide' })); }\n }\n\n // rubber-band preview from the active open path's last anchor to the cursor\n if (S.cursor && S.mode === 'pen' && ap && !ap.closed && ap.anchors.length) {\n const last = ap.anchors[ap.anchors.length - 1], p1 = vbToPx(last.x, last.y), p2 = vbToPx(S.cursor.x, S.cursor.y);\n ov.appendChild(ns('line', { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y, class: 'cs-pen-rubber' }));\n }\n // handles for the selected anchor\n if (showMarks && S.sel && paths[S.sel.p]?.anchors[S.sel.i]) {\n const p = paths[S.sel.p].anchors[S.sel.i], apx = vbToPx(p.x, p.y);\n [[p.inX, p.inY], [p.outX, p.outY]].forEach(([hx, hy]) => {\n if (hx == null) return;\n const hp = vbToPx(hx, hy);\n ov.appendChild(ns('line', { x1: apx.x, y1: apx.y, x2: hp.x, y2: hp.y, class: 'cs-pen-handle-line' }));\n ov.appendChild(ns('circle', { cx: hp.x, cy: hp.y, r: 4, class: 'cs-pen-handle' }));\n });\n }\n // anchors for every sub-path — drawn at each path's OWN rotation. Non-active\n // paths are dimmed so it's clear which clip-path the toolbar edits.\n if (showMarks) paths.forEach((path, pi) => {\n const active = pi === S.activePath;\n if (path.hidden && !active) return; // hidden layers show no anchors\n const deg = (path.style && path.style.rotate) || 0;\n path.anchors.forEach((p, i) => {\n const pp = vbToPxR(p.x, p.y, deg), size = active ? 8 : 6;\n const isSel = active && S.sel && S.sel.p === pi && S.sel.i === i;\n const isFirst = active && !path.closed && i === 0;\n const cls = 'cs-pen-anchor'\n + (isSel ? ' is-sel' : '')\n + (isFirst ? ' is-first' : '')\n + (active ? '' : ' is-dim');\n ov.appendChild(ns('rect', { x: pp.x - size / 2, y: pp.y - size / 2, width: size, height: size, class: cls }));\n });\n });\n\n // Pen-tool hover affordances on a completed shape: + to add a point on the\n // outline, × to remove the point under the cursor.\n if (S.mode === 'pen' && S.penHover && !S.drag && ap) {\n const deg = (ap.style && ap.style.rotate) || 0;\n if (S.penHover.kind === 'add') {\n const c = vbToPxR(S.penHover.x, S.penHover.y, deg);\n ov.appendChild(ns('circle', { cx: c.x, cy: c.y, r: 8, class: 'cs-pen-add' }));\n ov.appendChild(ns('line', { x1: c.x - 4, y1: c.y, x2: c.x + 4, y2: c.y, class: 'cs-pen-add-mark' }));\n ov.appendChild(ns('line', { x1: c.x, y1: c.y - 4, x2: c.x, y2: c.y + 4, class: 'cs-pen-add-mark' }));\n } else if (S.penHover.kind === 'remove') {\n const an = ap.anchors[S.penHover.i];\n if (an) {\n const c = vbToPxR(an.x, an.y, deg);\n ov.appendChild(ns('circle', { cx: c.x, cy: c.y, r: 8, class: 'cs-pen-remove' }));\n ov.appendChild(ns('line', { x1: c.x - 4, y1: c.y - 4, x2: c.x + 4, y2: c.y + 4, class: 'cs-pen-remove-mark' }));\n ov.appendChild(ns('line', { x1: c.x - 4, y1: c.y + 4, x2: c.x + 4, y2: c.y - 4, class: 'cs-pen-remove-mark' }));\n }\n }\n }\n };\n\n /* ------------------------------- operations ------------------------------- */\n\n // --- preset geometry helpers (all in the 0..1000 viewBox, centred ~500,500) ---\n const poly = (pts) => ({ anchors: pts.map(([x, y]) => ({ x, y })), closed: true });\n // Regular n-gon. rot = angle of the first vertex (default top).\n const ngon = (n, R, rot) => {\n const cx = 500, cy = 500, a0 = (rot == null ? -Math.PI / 2 : rot), pts = [];\n for (let i = 0; i < n; i++) { const a = a0 + i * 2 * Math.PI / n; pts.push({ x: cx + R * Math.cos(a), y: cy + R * Math.sin(a) }); }\n return { anchors: pts, closed: true };\n };\n // p-pointed star alternating outer R / inner r radius.\n const starPoly = (p, R, r) => {\n const cx = 500, cy = 500, a0 = -Math.PI / 2, pts = [];\n for (let i = 0; i < p * 2; i++) { const a = a0 + i * Math.PI / p; const rad = i % 2 ? r : R; pts.push({ x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }); }\n return { anchors: pts, closed: true };\n };\n // Rounded rectangle via cubic-bézier corners.\n const roundedRect = (L, T, R, B, rr) => {\n const k = rr * 0.5523;\n return {\n closed: true, anchors: [\n { x: L + rr, y: T, inX: L + rr - k, inY: T },\n { x: R - rr, y: T, outX: R - rr + k, outY: T },\n { x: R, y: T + rr, inX: R, inY: T + rr - k },\n { x: R, y: B - rr, outX: R, outY: B - rr + k },\n { x: R - rr, y: B, inX: R - rr + k, inY: B },\n { x: L + rr, y: B, outX: L + rr - k, outY: B },\n { x: L, y: B - rr, inX: L, inY: B - rr + k },\n { x: L, y: T + rr, outX: L, outY: T + rr - k },\n ],\n };\n };\n\n const PRESETS = {\n rectangle: () => ({ anchors: [{ x: 80, y: 80 }, { x: 920, y: 80 }, { x: 920, y: 920 }, { x: 80, y: 920 }], closed: true }),\n square: () => poly([[140, 140], [860, 140], [860, 860], [140, 860]]),\n 'rounded-rect': () => roundedRect(110, 180, 890, 820, 150),\n pill: () => roundedRect(90, 360, 910, 640, 140),\n triangle: () => ({ anchors: [{ x: 500, y: 80 }, { x: 920, y: 920 }, { x: 80, y: 920 }], closed: true }),\n 'triangle-down': () => poly([[120, 120], [880, 120], [500, 880]]),\n 'right-triangle': () => poly([[150, 140], [150, 860], [870, 860]]),\n diamond: () => poly([[500, 70], [930, 500], [500, 930], [70, 500]]),\n pentagon: () => ngon(5, 440),\n hexagon: () => {\n const pts = [], cx = 500, cy = 500, R = 440;\n for (let i = 0; i < 6; i++) { const ang = -Math.PI / 2 + i * Math.PI / 3; pts.push({ x: cx + R * Math.cos(ang), y: cy + R * Math.sin(ang) }); }\n return { anchors: pts, closed: true };\n },\n heptagon: () => ngon(7, 440),\n octagon: () => ngon(8, 460, -Math.PI / 2 + Math.PI / 8),\n parallelogram: () => poly([[280, 200], [940, 200], [720, 800], [60, 800]]),\n trapezoid: () => poly([[300, 200], [700, 200], [900, 800], [100, 800]]),\n ellipse: () => {\n const k = 0.5523 * 420, cx = 500, cy = 500, rr = 420;\n return {\n anchors: [\n { x: cx, y: cy - rr, inX: cx - k, inY: cy - rr, outX: cx + k, outY: cy - rr },\n { x: cx + rr, y: cy, inX: cx + rr, inY: cy - k, outX: cx + rr, outY: cy + k },\n { x: cx, y: cy + rr, inX: cx + k, inY: cy + rr, outX: cx - k, outY: cy + rr },\n { x: cx - rr, y: cy, inX: cx - rr, inY: cy + k, outX: cx - rr, outY: cy - k },\n ], closed: true\n };\n },\n star: () => {\n const pts = [], cx = 500, cy = 510, R = 440, r = 180;\n for (let i = 0; i < 10; i++) { const ang = -Math.PI / 2 + i * Math.PI / 5, rad = i % 2 ? r : R; pts.push({ x: cx + rad * Math.cos(ang), y: cy + rad * Math.sin(ang) }); }\n return { anchors: pts, closed: true };\n },\n 'star-4': () => starPoly(4, 470, 150),\n 'star-6': () => starPoly(6, 450, 210),\n 'star-12': () => starPoly(12, 450, 330),\n burst: () => starPoly(16, 470, 380),\n 'arrow-right': () => poly([[100, 360], [560, 360], [560, 200], [920, 500], [560, 800], [560, 640], [100, 640]]),\n 'arrow-left': () => poly([[900, 360], [440, 360], [440, 200], [80, 500], [440, 800], [440, 640], [900, 640]]),\n 'arrow-up': () => poly([[360, 900], [360, 440], [200, 440], [500, 80], [800, 440], [640, 440], [640, 900]]),\n 'arrow-down': () => poly([[360, 100], [360, 560], [200, 560], [500, 920], [800, 560], [640, 560], [640, 100]]),\n 'arrow-h': () => poly([[80, 500], [300, 300], [300, 420], [700, 420], [700, 300], [920, 500], [700, 700], [700, 580], [300, 580], [300, 700]]),\n 'arrow-v': () => poly([[500, 80], [300, 300], [420, 300], [420, 700], [300, 700], [500, 920], [700, 700], [580, 700], [580, 300], [700, 300]]),\n chevron: () => poly([[120, 200], [520, 200], [900, 500], [520, 800], [120, 800], [500, 500]]),\n plus: () => poly([[380, 100], [620, 100], [620, 380], [900, 380], [900, 620], [620, 620], [620, 900], [380, 900], [380, 620], [100, 620], [100, 380], [380, 380]]),\n heart: () => ({\n closed: true, anchors: [\n { x: 500, y: 300 }, // top centre dip (cusp)\n { x: 200, y: 0, inX: 400, inY: 0, outX: 0, outY: 0 },\n { x: 0, y: 250, outX: 0, outY: 400 },\n { x: 500, y: 760, inX: 250, inY: 600, outX: 750, outY: 600 }, // bottom tip\n { x: 1000, y: 250, inX: 1000, inY: 400 },\n { x: 800, y: 0, inX: 1000, inY: 0, outX: 600, outY: 0 },\n ],\n }),\n speech: () => poly([[120, 130], [880, 130], [880, 620], [430, 620], [290, 850], [300, 620], [120, 620]]),\n banner: () => poly([[100, 300], [900, 300], [780, 510], [900, 720], [100, 720], [220, 510]]),\n cloud: () => ({\n closed: true, anchors: [\n { x: 280, y: 720, inX: 160, inY: 690 }, // bottom-left (flat bottom to next)\n { x: 720, y: 720, outX: 860, outY: 710 }, // bottom-right\n { x: 840, y: 560, inX: 870, inY: 660, outX: 870, outY: 470 }, // right bump\n { x: 660, y: 420, inX: 800, inY: 420, outX: 700, outY: 360 }, // upper-right bump\n { x: 500, y: 380, inX: 610, inY: 330, outX: 390, outY: 330 }, // top bump\n { x: 340, y: 430, inX: 300, inY: 360, outX: 200, outY: 430 }, // upper-left bump\n { x: 160, y: 560, inX: 130, inY: 470, outX: 120, outY: 680 }, // left bump → closes to A0\n ],\n }),\n // Page-background shapes that bleed to the edges (great for invoices).\n corner: () => ({ anchors: [{ x: 0, y: 0 }, { x: 540, y: 0 }, { x: 0, y: 540 }], closed: true }),\n diagonal: () => ({ anchors: [{ x: 0, y: 260 }, { x: 1000, y: 0 }, { x: 1000, y: 260 }, { x: 0, y: 520 }], closed: true }),\n header: () => ({ anchors: [{ x: 0, y: 0 }, { x: 1000, y: 0 }, { x: 1000, y: 170 }, { x: 0, y: 170 }], closed: true }),\n footer: () => ({ anchors: [{ x: 0, y: 830 }, { x: 1000, y: 830 }, { x: 1000, y: 1000 }, { x: 0, y: 1000 }], closed: true }),\n };\n\n // Basic shapes can be dropped at a chosen size; the edge-bleed background\n // presets keep their full-page layout.\n // Everything except the edge-bleed page backgrounds is sized to the W/H box.\n const SIZABLE_PRESETS = {\n rectangle: 1, square: 1, 'rounded-rect': 1, pill: 1, ellipse: 1,\n triangle: 1, 'triangle-down': 1, 'right-triangle': 1, diamond: 1,\n pentagon: 1, hexagon: 1, heptagon: 1, octagon: 1, parallelogram: 1, trapezoid: 1,\n star: 1, 'star-4': 1, 'star-6': 1, 'star-12': 1, burst: 1,\n 'arrow-right': 1, 'arrow-left': 1, 'arrow-up': 1, 'arrow-down': 1,\n 'arrow-h': 1, 'arrow-v': 1, chevron: 1, plus: 1, heart: 1, speech: 1, banner: 1, cloud: 1,\n };\n\n // Scale a ring's anchors (and handles) so its bounding box becomes w×h\n // (viewBox units), centred on the page.\n const fitAnchorsToBox = (anchors, w, h) => {\n if (!anchors.length) return;\n let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;\n anchors.forEach((a) => { minx = Math.min(minx, a.x); maxx = Math.max(maxx, a.x); miny = Math.min(miny, a.y); maxy = Math.max(maxy, a.y); });\n const bw = (maxx - minx) || 1, bh = (maxy - miny) || 1;\n const sx = w / bw, sy = h / bh, ox = CX - w / 2, oy = CY - h / 2;\n const mapX = (x) => ox + (x - minx) * sx, mapY = (y) => oy + (y - miny) * sy;\n anchors.forEach((a) => {\n if (a.inX != null) { a.inX = mapX(a.inX); a.inY = mapY(a.inY); }\n if (a.outX != null) { a.outX = mapX(a.outX); a.outY = mapY(a.outY); }\n a.x = mapX(a.x); a.y = mapY(a.y);\n });\n };\n\n // A preset ADDS a new closed sub-path (so the user can stack several shapes),\n // with its OWN copy of the current style so it can be recoloured separately.\n // opts.w / opts.h (viewBox units) drop a sizable shape at that size, centred.\n const loadPreset = (name, opts) => {\n if (!S || !PRESETS[name]) return;\n snapshot();\n const path = PRESETS[name]();\n if (opts && opts.w > 0 && opts.h > 0 && SIZABLE_PRESETS[name]) {\n fitAnchorsToBox(path.anchors, Math.min(VB, opts.w), Math.min(VB, opts.h));\n }\n path.name = nextPathName();\n path.style = Object.assign({}, readStyle(S.block));\n S.state.paths.push(path);\n S.activePath = S.state.paths.length - 1;\n S.mode = 'edit'; S.sel = null; S.selected?.clear();\n S.rotate = (path.style.rotate) || 0;\n S.applyStyleValues?.();\n commit();\n };\n\n // Clicking the Pen tool finishes the current open shape so the next click on\n // empty canvas begins a brand-new sub-path.\n const startNewPath = () => {\n if (!S) return;\n const ap = S.state.paths[S.activePath];\n if (ap && !ap.closed && ap.anchors.length > 2) { snapshot(); ap.closed = true; }\n S.sel = null; commit();\n };\n\n const clearAllPaths = () => {\n if (!S) return;\n snapshot();\n S.state.paths = []; S.activePath = -1; S.mode = 'pen'; S.sel = null; S.selected?.clear();\n commit();\n };\n\n // Flip the ACTIVE sub-path only (all its rings), in isolation.\n const flip = (axis) => {\n if (!S) return;\n const path = S.state.paths[S.activePath];\n if (!path || path.locked) return;\n snapshot();\n const f = (v) => VB - v;\n ringsOf(path).forEach((r) => r.anchors.forEach((p) => {\n if (axis === 'h') { p.x = f(p.x); if (p.inX != null) p.inX = f(p.inX); if (p.outX != null) p.outX = f(p.outX); }\n else { p.y = f(p.y); if (p.inY != null) p.inY = f(p.inY); if (p.outY != null) p.outY = f(p.outY); }\n }));\n commit();\n };\n\n // Smooth / round corners on the ACTIVE sub-path: give every anchor symmetric\n // handles tangent to its neighbours (Catmull-Rom). Open-path endpoints stay\n // corners. Repeatable.\n // Give every anchor of `path` symmetric handles tangent to its neighbours\n // (Catmull-Rom). Open-path endpoints stay corners. No snapshot/commit — the\n // caller owns those.\n const smoothAnchors = (path) => {\n const k = 0.16;\n const a = path.anchors, n = a.length, closed = path.closed, next = [];\n for (let i = 0; i < n; i++) {\n const cur = a[i];\n if (!closed && (i === 0 || i === n - 1)) { next.push({ x: cur.x, y: cur.y }); continue; }\n const prev = a[(i - 1 + n) % n], nx = a[(i + 1) % n];\n const tx = nx.x - prev.x, ty = nx.y - prev.y;\n next.push({ x: cur.x, y: cur.y, outX: cur.x + tx * k, outY: cur.y + ty * k, inX: cur.x - tx * k, inY: cur.y - ty * k });\n }\n path.anchors = next;\n };\n\n // Smooth / round corners on the ACTIVE sub-path. Repeatable.\n const smoothAll = () => {\n if (!S) return;\n const path = S.state.paths[S.activePath];\n if (!path) return;\n snapshot();\n smoothAnchors(path);\n commit();\n };\n\n const deleteSelected = () => {\n if (!S) return;\n let sel = S.sel;\n if (!sel && S.mode === 'pen') { const ap = S.state.paths[S.activePath]; if (ap && ap.anchors.length) sel = { p: S.activePath, i: ap.anchors.length - 1 }; }\n const path = sel && S.state.paths[sel.p];\n if (!path || !path.anchors[sel.i]) return;\n snapshot();\n path.anchors.splice(sel.i, 1);\n if (path.anchors.length < 3) path.closed = false;\n if (path.anchors.length === 0) { S.state.paths.splice(sel.p, 1); S.activePath = openPathIndex(); }\n S.sel = null; commit();\n };\n\n const undo = () => { if (!S || !S.undo.length) return; S.redo.push(clone(S.state)); S.state = S.undo.pop(); S.sel = null; S.activePath = openPathIndex(); commit(); };\n const redo = () => { if (!S || !S.redo.length) return; S.undo.push(clone(S.state)); S.state = S.redo.pop(); S.sel = null; S.activePath = openPathIndex(); commit(); };\n\n /* -------------------------- shape management ------------------------------ */\n\n // A fresh \"Shape N\" name (N = highest existing number + 1) so names are stable\n // and don't renumber when layers are reordered.\n const nextPathName = () => {\n let max = 0;\n (S?.state.paths || []).forEach((p) => { const m = /(\\d+)/.exec(p.name || ''); if (m) max = Math.max(max, +m[1]); });\n return `Shape ${max + 1}`;\n };\n\n // Duplicate the active sub-path (offset a little so it's visible) and select it.\n const offsetAnchors = (anchors, dx, dy) => anchors.forEach((a) => {\n a.x += dx; a.y += dy;\n if (a.inX != null) { a.inX += dx; a.inY += dy; }\n if (a.outX != null) { a.outX += dx; a.outY += dy; }\n });\n\n const duplicateActivePath = () => {\n if (!S) return;\n const p = S.state.paths[S.activePath];\n if (!p) return;\n snapshot();\n const copy = clone(p);\n copy.name = `${p.name || 'Shape'} copy`;\n offsetAnchors(copy.anchors, 40, 40);\n S.state.paths.push(copy);\n S.activePath = S.state.paths.length - 1;\n S.sel = null; S.selected?.clear(); commit();\n };\n\n // Copy / paste the ACTIVE sub-path. The clipboard is module-level, so a shape\n // copied in one block (or the page-shape designer) can be pasted into another.\n // Pasting selects the copy in edit mode so it can be moved / flipped right\n // away (e.g. copy the right-corner shape, paste, flip-H, drag to the left).\n const copyActivePath = () => {\n if (!S) return;\n const p = S.state.paths[S.activePath];\n if (p) penClip = clone(p);\n };\n\n const pastePath = () => {\n if (!S || !penClip) return;\n snapshot();\n const copy = clone(penClip);\n copy.name = `${penClip.name || 'Shape'} copy`;\n if (Array.isArray(copy.anchors)) offsetAnchors(copy.anchors, 40, 40);\n if (Array.isArray(copy.rings)) copy.rings.forEach((r) => offsetAnchors(r.anchors, 40, 40));\n S.state.paths.push(copy);\n S.activePath = S.state.paths.length - 1;\n S.mode = 'edit';\n S.sel = null; S.selected?.clear();\n commit();\n };\n\n // Delete the whole active sub-path (not just one anchor).\n const deleteActivePath = () => {\n if (!S || !S.state.paths[S.activePath]) return;\n snapshot();\n S.state.paths.splice(S.activePath, 1);\n S.activePath = Math.min(S.activePath, S.state.paths.length - 1);\n S.sel = null; S.selected?.clear(); commit();\n };\n\n // Z-order: later paths paint on top, so swap with the neighbour. dir +1 =\n // bring forward, -1 = send backward.\n const reorderActivePath = (dir) => {\n if (!S) return;\n const i = S.activePath, j = i + dir, arr = S.state.paths;\n if (i < 0 || j < 0 || j >= arr.length) return;\n snapshot();\n [arr[i], arr[j]] = [arr[j], arr[i]];\n S.activePath = j; commit();\n };\n\n /* ------------------------------ snapping ---------------------------------- */\n\n const SNAP_GRID = VB / 40; // ~25 vb units\n const SNAP_EDGE_TOL = 18; // snap-to-edge/centre tolerance\n // Snap a coordinate to the page edges / centre, else to the grid.\n const snapV = (v) => {\n if (!S || !S.snap) return v;\n for (const t of [0, CX, VB]) if (Math.abs(v - t) <= SNAP_EDGE_TOL) return t;\n return Math.round(v / SNAP_GRID) * SNAP_GRID;\n };\n // Snap a translation delta to the grid (for whole-shape moves).\n const snapDelta = (d) => (S && S.snap ? Math.round(d / SNAP_GRID) * SNAP_GRID : d);\n\n // Smart alignment guides: snap (x,y) to line up with ANY other anchor's x or\n // y (so edges come out straight and left/right points sit at the same height,\n // or share a width). Always on — it only engages within a small tolerance, so\n // free placement isn't disturbed. Records the guide lines in S.guides for the\n // overlay. `skip` = the anchor being moved (don't align to itself).\n const ALIGN_TOL = 2; // vb units — smaller = less \"sticky\" snapping to other anchors\n const alignSnap = (x, y, skip) => {\n let gx = null, gy = null, dx = ALIGN_TOL, dy = ALIGN_TOL;\n (S?.state.paths || []).forEach((path, pi) => {\n path.anchors.forEach((a, i) => {\n if (skip && skip.p === pi && skip.i === i) return;\n const ax = Math.abs(a.x - x); if (ax < dx) { dx = ax; gx = a.x; }\n const ay = Math.abs(a.y - y); if (ay < dy) { dy = ay; gy = a.y; }\n });\n });\n if (S) S.guides = (gx != null || gy != null) ? { gx, gy } : null;\n return { x: gx != null ? gx : x, y: gy != null ? gy : y };\n };\n\n /* ------------------------------ layers ------------------------------------ */\n\n const swatchOf = (st) => {\n const stops = gradStopColors(st);\n return st.fillType === 'gradient'\n ? `linear-gradient(135deg, ${stops[0]}, ${stops[stops.length - 1]})`\n : (st.fillType === 'image' ? '#9aa0ff' : (st.fill || DEFAULT_FILL));\n };\n\n // A mini SVG preview of a single sub-path (Photoshop-style layer thumbnail).\n const pathThumb = (p, st, uid) => {\n const svg = ns('svg', { viewBox: `0 0 ${VB} ${VB}`, class: 'cs-pen-layer-thumb__svg', preserveAspectRatio: 'xMidYMid meet' });\n const d = ringsOf(p).map((r) => buildSubD(r.anchors, r.closed)).filter(Boolean).join(' ');\n if (d) {\n const pe = ns('path', { d, 'fill-opacity': st.fillOpacity ?? 1 });\n if (st.fillType === 'gradient') {\n const defs = ns('defs', {}); const id = `lt_${uid}`; defs.appendChild(buildGradient(id, st)); svg.appendChild(defs);\n pe.setAttribute('fill', `url(#${id})`);\n } else { pe.setAttribute('fill', st.fillType === 'image' ? '#9aa0ff' : (st.fill || DEFAULT_FILL)); }\n if (st.rotate) pe.setAttribute('transform', `rotate(${st.rotate} ${CX} ${CY})`);\n svg.appendChild(pe);\n }\n return svg;\n };\n\n // Rebuild the compact toolbar chips AND, when a side panel is attached, the\n // full Photoshop-style layer list (top row = front-most). Drag a row to\n // reorder = change z-index.\n const renderLayers = () => {\n if (!S) return;\n // 1) Compact chips in the toolbar (used by the in-canvas block).\n if (S.layersEl) {\n S.layersEl.replaceChildren();\n S.state.paths.forEach((p, i) => {\n const chip = document.createElement('button');\n chip.type = 'button';\n chip.className = 'cs-pen-layer' + (i === S.activePath ? ' is-active' : '');\n chip.title = `Shape ${i + 1}`;\n const st = p.style ? Object.assign({}, DEFAULT_STYLE, p.style) : readStyle(S.block);\n chip.style.background = swatchOf(st);\n chip.addEventListener('click', (e) => { e.stopPropagation(); S.mode = 'edit'; selectPath(i); });\n S.layersEl.appendChild(chip);\n });\n }\n // 2) Rich side panel (used by the page-background designer modal).\n if (!S.panelEl) return;\n const panel = S.panelEl;\n panel.replaceChildren();\n const uidBase = (S.block.querySelector('.cs-pen-shape')?.id || 'pen');\n // Render front-to-back: last path paints on top → show it at the TOP.\n for (let i = S.state.paths.length - 1; i >= 0; i--) {\n const p = S.state.paths[i];\n const st = p.style ? Object.assign({}, DEFAULT_STYLE, p.style) : readStyle(S.block);\n const row = document.createElement('div');\n row.className = 'cs-pen-layer-row'\n + (i === S.activePath ? ' is-active' : '')\n + (S.selected && S.selected.has(i) ? ' is-multi' : '')\n + (p.hidden ? ' is-hidden' : '')\n + (p.locked ? ' is-locked' : '');\n row.draggable = !p.locked;\n row.dataset.pi = String(i);\n\n // const eye = document.createElement('button');\n // eye.type = 'button'; eye.className = 'cs-pen-layer-row__eye'; eye.title = 'Show / hide';\n // eye.textContent = p.hidden ? '🚫' : '👁';\n // eye.addEventListener('click', (e) => { e.stopPropagation(); snapshot(); p.hidden = !p.hidden; commit(); });\n\n const lock = document.createElement('button');\n lock.type = 'button'; lock.className = 'cs-pen-layer-row__eye'; lock.title = p.locked ? 'Unlock' : 'Lock';\n lock.textContent = p.locked ? '🔒' : '🔓';\n lock.addEventListener('click', (e) => { e.stopPropagation(); snapshot(); p.locked = !p.locked; commit(); });\n\n const thumbWrap = document.createElement('span');\n thumbWrap.className = 'cs-pen-layer-row__thumb';\n thumbWrap.appendChild(pathThumb(p, st, `${uidBase}_${i}`));\n\n const name = document.createElement('span');\n name.className = 'cs-pen-layer-row__name';\n name.textContent = p.name || `Shape ${i + 1}`;\n name.title = 'Rename (✎ or double-click)';\n\n // Inline rename. The row is draggable, which would otherwise swallow the\n // input's mousedown (can't type), so we turn drag off while editing.\n const startRename = () => {\n const input = document.createElement('input');\n input.className = 'cs-pen-layer-row__rename';\n input.value = p.name || `Shape ${i + 1}`;\n row.draggable = false;\n name.replaceWith(input);\n input.focus(); input.select();\n let done = false;\n const finish = (save) => {\n if (done) return; done = true;\n if (save) { const v = input.value.trim(); if (v) { p.name = v; writeState(S.block, S.state); } }\n renderLayers();\n };\n input.addEventListener('mousedown', (ev) => ev.stopPropagation());\n input.addEventListener('click', (ev) => ev.stopPropagation());\n input.addEventListener('blur', () => finish(true));\n input.addEventListener('keydown', (ev) => {\n ev.stopPropagation();\n if (ev.key === 'Enter') { ev.preventDefault(); finish(true); }\n else if (ev.key === 'Escape') { ev.preventDefault(); finish(false); }\n });\n };\n name.addEventListener('dblclick', (e) => { e.stopPropagation(); startRename(); });\n\n const ren = document.createElement('button');\n ren.type = 'button'; ren.className = 'cs-pen-layer-row__act'; ren.title = 'Rename'; ren.textContent = '✎';\n ren.addEventListener('click', (e) => { e.stopPropagation(); startRename(); });\n\n const up = document.createElement('button');\n up.type = 'button'; up.className = 'cs-pen-layer-row__act'; up.title = 'Bring forward (up)'; up.textContent = '▲';\n up.disabled = i === S.state.paths.length - 1;\n up.addEventListener('click', (e) => { e.stopPropagation(); moveLayer(i, 1); });\n\n const down = document.createElement('button');\n down.type = 'button'; down.className = 'cs-pen-layer-row__act'; down.title = 'Send backward (down)'; down.textContent = '▼';\n down.disabled = i === 0;\n down.addEventListener('click', (e) => { e.stopPropagation(); moveLayer(i, -1); });\n\n const dup = document.createElement('button');\n dup.type = 'button'; dup.className = 'cs-pen-layer-row__act'; dup.title = 'Duplicate'; dup.textContent = '⧉';\n dup.addEventListener('click', (e) => { e.stopPropagation(); selectPath(i); duplicateActivePath(); syncToolbar(); });\n\n const del = document.createElement('button');\n del.type = 'button'; del.className = 'cs-pen-layer-row__act'; del.title = 'Delete'; del.textContent = '🗑';\n del.addEventListener('click', (e) => { e.stopPropagation(); selectPath(i); deleteActivePath(); syncToolbar(); });\n\n //incase if you need one more block append please add lock variable before showing hide/show icon \n row.append(lock, thumbWrap, name, up, down, ren, dup, del);\n row.addEventListener('click', (e) => {\n S.mode = 'edit';\n if (e.ctrlKey || e.metaKey) {\n // Multi-select: keep the current active in the set, then toggle this.\n if (S.activePath >= 0) S.selected.add(S.activePath);\n if (S.selected.has(i)) S.selected.delete(i); else S.selected.add(i);\n } else {\n S.selected.clear();\n }\n selectPath(i);\n });\n\n // Drag-to-reorder (HTML5). dragstart stores the source path index.\n row.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', String(i)); row.classList.add('is-dragging'); });\n row.addEventListener('dragend', () => row.classList.remove('is-dragging'));\n row.addEventListener('dragover', (e) => { e.preventDefault(); row.classList.add('is-drop'); });\n row.addEventListener('dragleave', () => row.classList.remove('is-drop'));\n row.addEventListener('drop', (e) => {\n e.preventDefault(); row.classList.remove('is-drop');\n const from = Number(e.dataTransfer.getData('text/plain'));\n const to = i;\n if (Number.isNaN(from) || from === to) return;\n reorderPathTo(from, to);\n });\n\n panel.appendChild(row);\n }\n };\n\n // Move sub-path at index `from` so it sits where `to` is (z-order change).\n const reorderPathTo = (from, to) => {\n if (!S) return;\n const arr = S.state.paths;\n if (from < 0 || from >= arr.length || to < 0 || to >= arr.length || from === to) return;\n snapshot();\n const [moved] = arr.splice(from, 1);\n arr.splice(to, 0, moved);\n S.activePath = arr.indexOf(moved);\n S.selected?.clear();\n commit(); syncToolbar();\n };\n\n // Nudge a layer one step up (dir +1 = bring forward) or down (-1 = backward).\n const moveLayer = (i, dir) => {\n if (!S) return;\n reorderPathTo(i, Math.max(0, Math.min(S.state.paths.length - 1, i + dir)));\n };\n\n /* ----------------------- multi-select / merge / lock ---------------------- */\n\n // The layers the next merge/lock acts on: the explicit multi-selection, or\n // just the active layer when nothing is multi-selected.\n const selectedIndices = () => {\n const set = S.selected && S.selected.size ? [...S.selected] : (S.activePath >= 0 ? [S.activePath] : []);\n return set.filter((i) => i >= 0 && i < S.state.paths.length).sort((a, b) => a - b);\n };\n\n // Flatten the selected layers into ONE layer (Photoshop \"merge\"). The merged\n // layer keeps the bottom-most selected layer's style/name; its geometry holds\n // every original ring (so it renders identically) but is no longer\n // anchor-editable — it can still be styled / moved / reordered as one unit.\n const mergeSelected = () => {\n if (!S) return;\n const idxs = selectedIndices();\n if (idxs.length < 2) return;\n snapshot();\n const paths = S.state.paths;\n const rings = [];\n idxs.forEach((i) => ringsOf(paths[i]).forEach((r) => rings.push(clone(r))));\n const host = paths[idxs[0]];\n const merged = {\n name: `${host.name || 'Shape'} (merged)`,\n closed: true, anchors: [], rings,\n style: clone(host.style || readStyle(S.block)),\n };\n for (let k = idxs.length - 1; k >= 0; k--) paths.splice(idxs[k], 1);\n paths.splice(idxs[0], 0, merged);\n S.activePath = idxs[0];\n S.selected.clear();\n commit(); syncToolbar();\n };\n\n // Toggle lock on the selected layers (lock prevents accidental edits).\n const toggleLockSelected = () => {\n if (!S) return;\n const idxs = selectedIndices();\n if (!idxs.length) return;\n snapshot();\n const lockAll = idxs.some((i) => !S.state.paths[i].locked);\n idxs.forEach((i) => { S.state.paths[i].locked = lockAll; });\n commit(); syncToolbar();\n };\n\n /* ------------------------------- toolbar ---------------------------------- */\n\n const TOOL_HTML = `\n <div class=\"cs-pen-toolbar\">\n <div class=\"cs-pen-layers\" data-pen-layers title=\"Shapes — click to select\"></div>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"pen\" title=\"Pen — draw a shape; on a finished shape hover an edge to add a point (+) or a point to remove it (×)\">✒</button>\n <button type=\"button\" data-pen=\"edit\" title=\"Move — drag points or the whole shape\">✋</button>\n <button type=\"button\" data-pen=\"snap\" title=\"Snap to grid / page edges\">🧲</button>\n <button type=\"button\" data-pen=\"smooth\" title=\"Smooth / round corners\">∿</button>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"dup\" title=\"Duplicate shape\">⧉</button>\n <button type=\"button\" data-pen=\"del-shape\" title=\"Delete this shape\">✖</button>\n <button type=\"button\" data-pen=\"fwd\" title=\"Bring forward\">⤒</button>\n <button type=\"button\" data-pen=\"back\" title=\"Send backward\">⤓</button>\n <button type=\"button\" data-pen=\"clear\" title=\"Clear all shapes\">🗑</button>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"preset-rectangle\" title=\"Rectangle\">▭</button>\n <button type=\"button\" data-pen=\"preset-ellipse\" title=\"Ellipse\">◯</button>\n <button type=\"button\" data-pen=\"preset-triangle\" title=\"Triangle\">△</button>\n <button type=\"button\" data-pen=\"preset-star\" title=\"Star\">★</button>\n <button type=\"button\" data-pen=\"preset-hexagon\" title=\"Hexagon\">⬡</button>\n <button type=\"button\" data-pen=\"preset-corner\" title=\"Corner wedge\">◣</button>\n <button type=\"button\" data-pen=\"preset-diagonal\" title=\"Diagonal band\">▰</button>\n <button type=\"button\" data-pen=\"preset-header\" title=\"Header bar\">▀</button>\n <button type=\"button\" data-pen=\"preset-footer\" title=\"Footer bar\">▄</button>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"delete\" title=\"Delete point (Del)\">⛔</button>\n <button type=\"button\" data-pen=\"undo\" title=\"Undo (Ctrl+Z)\">↶</button>\n <button type=\"button\" data-pen=\"redo\" title=\"Redo (Ctrl+Shift+Z)\">↷</button>\n <div class=\"cs-pen-props\" data-pen-props>\n <div class=\"cs-pen-props__group cs-pen-group--transform\">\n <span class=\"cs-pen-props__label\">Transform</span>\n <button type=\"button\" data-pen=\"flip-h\" title=\"Flip horizontal\">⇆</button>\n <button type=\"button\" data-pen=\"flip-v\" title=\"Flip vertical\">⇅</button>\n <label class=\"cs-pen-num\" title=\"Rotate\">↻<input type=\"range\" min=\"0\" max=\"360\" step=\"1\" data-pen=\"rotate\"></label>\n </div>\n <div class=\"cs-pen-props__group cs-pen-group--fill\">\n <span class=\"cs-pen-props__label\">Fill</span>\n <select data-pen=\"fill-type\" title=\"Fill type\">\n <option value=\"solid\">Solid</option>\n <option value=\"gradient\">Gradient</option>\n <option value=\"image\">Image</option>\n </select>\n <span class=\"cs-pen-fill-solid\">\n <label class=\"cs-pen-swatch\" title=\"Fill colour\"><input type=\"color\" data-pen=\"fill\"></label>\n </span>\n <span class=\"cs-pen-fill-gradient\">\n <select data-pen=\"grad-kind\" title=\"Gradient type\">\n <option value=\"linear\">Linear</option>\n <option value=\"radial\">Radial</option>\n </select>\n <span class=\"cs-pen-grad-stops\" data-pen-stops></span>\n <button type=\"button\" data-pen=\"stop-add\" title=\"Add colour stop\">+</button>\n <button type=\"button\" data-pen=\"stop-del\" title=\"Remove colour stop\">-</button>\n <label class=\"cs-pen-num\" title=\"Angle\">∠<input type=\"number\" min=\"0\" max=\"360\" step=\"15\" data-pen=\"grad-angle\"></label>\n </span>\n <span class=\"cs-pen-fill-image\">\n <button type=\"button\" data-pen=\"image\" title=\"Choose image\">🖼 Image</button>\n </span>\n </div>\n <div class=\"cs-pen-props__group cs-pen-group--opacity\">\n <span class=\"cs-pen-props__label\">Opacity</span>\n <label class=\"cs-pen-num\" title=\"Fill opacity (transparency)\">◑<input type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" data-pen=\"fill-opacity\"></label>\n <select data-pen=\"blend\" title=\"Blend mode\">\n <option value=\"normal\">Normal</option>\n <option value=\"multiply\">Multiply</option>\n <option value=\"screen\">Screen</option>\n <option value=\"overlay\">Overlay</option>\n <option value=\"darken\">Darken</option>\n <option value=\"lighten\">Lighten</option>\n </select>\n </div>\n <div class=\"cs-pen-props__group cs-pen-group--stroke\">\n <span class=\"cs-pen-props__label\">Stroke</span>\n <label class=\"cs-pen-swatch\" title=\"Stroke colour\"><input type=\"color\" data-pen=\"stroke\"></label>\n <label class=\"cs-pen-num\" title=\"Stroke width\">W<input type=\"number\" min=\"0\" max=\"40\" step=\"1\" data-pen=\"stroke-width\"></label>\n </div>\n </div>\n </div>`;\n\n const buildToolbar = () => {\n const wrap = document.createElement('div');\n wrap.innerHTML = TOOL_HTML.trim();\n const bar = wrap.firstChild;\n // The style/transform controls live in a movable container so the modal can\n // relocate them into its right-hand panel (setLayersPanel's sibling).\n const propsEl = bar.querySelector('[data-pen-props]');\n S.propsEl = propsEl;\n const q = (sel) => propsEl.querySelector(sel);\n const set = (sel, v) => { const el = q(sel); if (el) el.value = v; };\n const stopsEl = q('[data-pen-stops]');\n S.layersEl = bar.querySelector('[data-pen-layers]');\n\n // Rebuild the gradient colour-stop swatches from the active style.\n const renderStops = () => {\n const cols = gradStopColors(getActiveStyle());\n stopsEl.replaceChildren();\n cols.forEach((c) => {\n const lbl = document.createElement('label');\n lbl.className = 'cs-pen-swatch';\n const inp = document.createElement('input');\n inp.type = 'color'; inp.dataset.pen = 'grad-stop'; inp.value = c;\n lbl.appendChild(inp);\n stopsEl.appendChild(lbl);\n });\n };\n\n // Push the ACTIVE sub-path's style into the toolbar inputs. Stored on S so\n // selecting another clip-path can refresh the controls to match it.\n const applyStyleValues = () => {\n const st = getActiveStyle();\n set('[data-pen=\"fill-type\"]', st.fillType);\n set('[data-pen=\"fill\"]', st.fill || DEFAULT_FILL);\n set('[data-pen=\"grad-kind\"]', st.gradKind || 'linear');\n set('[data-pen=\"grad-angle\"]', st.gradAngle);\n set('[data-pen=\"rotate\"]', st.rotate || 0);\n set('[data-pen=\"fill-opacity\"]', st.fillOpacity ?? 1);\n set('[data-pen=\"blend\"]', st.blend || 'normal');\n set('[data-pen=\"stroke\"]', st.stroke || '#000000');\n set('[data-pen=\"stroke-width\"]', st.strokeWidth || 0);\n renderStops();\n };\n S.applyStyleValues = applyStyleValues;\n\n bar.addEventListener('pointerdown', (e) => e.stopPropagation());\n propsEl.addEventListener('pointerdown', (e) => e.stopPropagation());\n\n // Tool actions live on the floating toolbar.\n bar.addEventListener('click', (e) => {\n const btn = e.target.closest('button[data-pen]');\n if (!btn || !bar.contains(btn) || propsEl.contains(btn)) return;\n const cmd = btn.dataset.pen;\n if (cmd === 'pen') { setResizeMode(false); S.mode = 'pen'; startNewPath(); }\n else if (cmd === 'edit') { setResizeMode(false); S.mode = 'edit'; }\n else if (cmd === 'resize') setResizeMode(!S.resizeMode);\n else if (cmd === 'snap') S.snap = !S.snap;\n else if (cmd === 'smooth') smoothAll();\n else if (cmd === 'clear') clearAllPaths();\n else if (cmd === 'dup') duplicateActivePath();\n else if (cmd === 'del-shape') deleteActivePath();\n else if (cmd === 'fwd') reorderActivePath(1);\n else if (cmd === 'back') reorderActivePath(-1);\n else if (cmd.startsWith('preset-')) loadPreset(cmd.slice(7));\n else if (cmd === 'delete') deleteSelected();\n else if (cmd === 'undo') undo();\n else if (cmd === 'redo') redo();\n else return;\n syncToolbar();\n });\n\n // Style / transform actions live on the (movable) props container.\n propsEl.addEventListener('click', (e) => {\n const btn = e.target.closest('button[data-pen]');\n if (!btn) return;\n const cmd = btn.dataset.pen;\n if (cmd === 'flip-h') flip('h');\n else if (cmd === 'flip-v') flip('v');\n else if (cmd === 'image') pickImage();\n else if (cmd === 'stop-add') { const s = Object.assign({}, getActiveStyle()); const c = gradStopColors(s); c.push(c[c.length - 1]); s.gradStops = c; s.fillType = 'gradient'; setActiveStyle(s); renderShape(S.block); applyStyleValues(); }\n else if (cmd === 'stop-del') { const s = Object.assign({}, getActiveStyle()); const c = gradStopColors(s); if (c.length > 2) { c.pop(); s.gradStops = c; setActiveStyle(s); renderShape(S.block); applyStyleValues(); } }\n else return;\n syncToolbar();\n });\n\n const onStyle = () => {\n // Edit the ACTIVE sub-path's style (keeps the others untouched).\n const s = Object.assign({}, getActiveStyle());\n s.fillType = q('[data-pen=\"fill-type\"]').value;\n s.fill = q('[data-pen=\"fill\"]').value;\n s.gradKind = q('[data-pen=\"grad-kind\"]').value;\n const stops = Array.from(propsEl.querySelectorAll('[data-pen=\"grad-stop\"]')).map((i) => i.value);\n if (stops.length >= 2) { s.gradStops = stops; s.gradFrom = stops[0]; s.gradTo = stops[stops.length - 1]; }\n s.gradAngle = Number(q('[data-pen=\"grad-angle\"]').value) || 0;\n s.rotate = Number(q('[data-pen=\"rotate\"]').value) || 0;\n const fo = Number(q('[data-pen=\"fill-opacity\"]').value);\n s.fillOpacity = isNaN(fo) ? 1 : fo;\n s.blend = q('[data-pen=\"blend\"]').value;\n s.stroke = q('[data-pen=\"stroke\"]').value;\n s.strokeWidth = Number(q('[data-pen=\"stroke-width\"]').value) || 0;\n setActiveStyle(s);\n S.rotate = s.rotate;\n renderShape(S.block);\n updateFillControls();\n drawOverlay();\n renderLayers();\n };\n // Listeners on propsEl so they travel with it when moved to the side panel.\n // `input` updates live; `change` covers browsers whose colour dialog only\n // commits on close.\n propsEl.addEventListener('input', onStyle);\n propsEl.addEventListener('change', onStyle);\n\n applyStyleValues();\n renderLayers();\n return bar;\n };\n\n const updateFillControls = () => {\n if (!S || !S.propsEl) return;\n const p = S.propsEl;\n const t = p.querySelector('[data-pen=\"fill-type\"]').value;\n p.querySelector('.cs-pen-fill-solid').style.display = (t === 'solid') ? '' : 'none';\n p.querySelector('.cs-pen-fill-gradient').style.display = (t === 'gradient') ? '' : 'none';\n p.querySelector('.cs-pen-fill-image').style.display = (t === 'image') ? '' : 'none';\n // Angle only matters for a linear gradient.\n const kind = p.querySelector('[data-pen=\"grad-kind\"]').value;\n const angle = p.querySelector('[data-pen=\"grad-angle\"]');\n if (angle?.parentElement) angle.parentElement.style.display = (kind === 'radial') ? 'none' : '';\n };\n\n const pickImage = () => {\n const inp = document.createElement('input');\n inp.type = 'file'; inp.accept = 'image/*';\n inp.addEventListener('change', () => {\n const file = inp.files && inp.files[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => {\n const s = Object.assign({}, getActiveStyle());\n s.imageSrc = reader.result; s.fillType = 'image';\n setActiveStyle(s);\n const ft = S.propsEl?.querySelector('[data-pen=\"fill-type\"]'); if (ft) ft.value = 'image';\n renderShape(S.block); updateFillControls();\n };\n reader.readAsDataURL(file);\n });\n inp.click();\n };\n\n const syncToolbar = () => {\n if (!S) return;\n S.toolbar.querySelectorAll('[data-pen=\"pen\"],[data-pen=\"edit\"],[data-pen=\"resize\"],[data-pen=\"snap\"]').forEach((b) => b.classList.remove('is-active'));\n if (S.resizeMode) S.toolbar.querySelector('[data-pen=\"resize\"]')?.classList.add('is-active');\n else S.toolbar.querySelector(`[data-pen=\"${S.mode}\"]`)?.classList.add('is-active');\n if (S.snap) S.toolbar.querySelector('[data-pen=\"snap\"]')?.classList.add('is-active');\n // Keep the style controls + rotation in sync with whatever sub-path is now\n // active (e.g. after undo/redo/delete changed activePath).\n S.rotate = getActiveStyle().rotate || 0;\n S.applyStyleValues?.();\n updateFillControls();\n drawOverlay();\n renderLayers();\n };\n\n /* ------------------------------ keyboard ---------------------------------- */\n\n const onKey = (e) => {\n if (!S) return;\n // Don't hijack typing in form fields (rename input, stroke-width, etc.).\n const tag = e.target && e.target.tagName;\n if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target?.isContentEditable) return;\n // Hold Space → temporary \"move whole clip-path\" mode (drag relocates the\n // active shape). Swallow the key so the page/stage doesn't scroll.\n if (e.key === ' ' || e.code === 'Space') {\n e.preventDefault(); e.stopPropagation();\n if (!S.spaceHeld) { S.spaceHeld = true; S.overlay?.classList.add('cs-pen-pan'); }\n return;\n }\n const z = (e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z');\n const y = (e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y');\n if (z) { e.preventDefault(); e.stopPropagation(); (e.shiftKey ? redo : undo)(); syncToolbar(); return; }\n if (y) { e.preventDefault(); e.stopPropagation(); redo(); syncToolbar(); return; }\n const mod = e.ctrlKey || e.metaKey;\n if (mod && (e.key === 'c' || e.key === 'C')) { e.preventDefault(); e.stopPropagation(); copyActivePath(); return; }\n if (mod && (e.key === 'v' || e.key === 'V')) { e.preventDefault(); e.stopPropagation(); pastePath(); syncToolbar(); return; }\n if (mod && (e.key === 'd' || e.key === 'D')) { e.preventDefault(); e.stopPropagation(); duplicateActivePath(); syncToolbar(); return; }\n if (e.key === 'Delete' || e.key === 'Backspace') {\n e.preventDefault(); e.stopPropagation();\n // An anchor selected (or mid-draw) → delete just that point. Otherwise a\n // whole shape is selected → delete the entire shape (like the layer 🗑).\n if (S.sel || S.mode === 'pen') deleteSelected();\n else deleteActivePath();\n syncToolbar();\n return;\n }\n if (e.key === 'Enter' && S.mode === 'pen') {\n const ap = S.state.paths[S.activePath];\n if (ap && !ap.closed && ap.anchors.length > 2) { e.preventDefault(); e.stopPropagation(); snapshot(); ap.closed = true; commit(); syncToolbar(); }\n return;\n }\n // Arrow keys nudge the selected anchor, or the whole active shape if none\n // is selected. Shift = bigger step.\n if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {\n e.preventDefault(); e.stopPropagation();\n const horiz = e.key === 'ArrowLeft' || e.key === 'ArrowRight';\n const step = (e.shiftKey ? 20 : 4) * ((e.key === 'ArrowLeft' || e.key === 'ArrowUp') ? -1 : 1);\n const move = (a) => {\n if (horiz) { a.x += step; if (a.inX != null) a.inX += step; if (a.outX != null) a.outX += step; }\n else { a.y += step; if (a.inY != null) a.inY += step; if (a.outY != null) a.outY += step; }\n };\n snapshot();\n const selA = S.sel && S.state.paths[S.sel.p]?.anchors[S.sel.i];\n if (selA) move(selA);\n else { const p = S.state.paths[S.activePath]; if (p) p.anchors.forEach(move); }\n commit();\n }\n };\n\n // Releasing Space ends the temporary \"move clip-path\" mode.\n const onKeyUp = (e) => {\n if (!S) return;\n if (e.key === ' ' || e.code === 'Space') {\n S.spaceHeld = false;\n S.overlay?.classList.remove('cs-pen-pan');\n }\n };\n\n /* --------------------------- activate / deactivate ------------------------ */\n\n const activate = (block) => {\n if (S && S.block === block) return;\n if (S) deactivate();\n const inner = block.querySelector('.cs-pen-shape');\n if (!inner) return;\n\n const overlay = document.createElement('div');\n overlay.className = 'cs-pen-overlay';\n overlay.setAttribute('data-cs-chrome', '');\n const ovSvg = ns('svg', { class: 'cs-pen-overlay-svg' });\n overlay.appendChild(ovSvg);\n\n const state = readState(block);\n // Give any unnamed sub-path a stable name so the layers panel labels don't\n // renumber on reorder (older shapes were drawn before names existed).\n state.paths.forEach((p, i) => { if (!p.name) p.name = `Shape ${i + 1}`; });\n // Continue an open sub-path if one exists; else edit existing shapes; else\n // start fresh in pen mode.\n const openIdx = state.paths.findIndex((p) => !p.closed);\n const activePath = openIdx >= 0 ? openIdx : (state.paths.length - 1);\n const activeStyle = state.paths[activePath]?.style || readStyle(block);\n S = {\n block, inner, overlay, ovSvg, state, rotate: activeStyle.rotate || 0,\n mode: 'pen',\n // Page designer enlarges the overlay past the page → allow off-page points.\n freeDraw: block.classList.contains('cs-page-shape-block'),\n activePath,\n sel: null, drag: null, cursor: null, penHover: null, guides: null, resizeMode: false, snap: false, spaceHeld: false, layersEl: null, panelEl: null, propsEl: null, selected: new Set(), undo: [], redo: []\n };\n S.toolbar = buildToolbar();\n overlay.appendChild(S.toolbar);\n inner.appendChild(overlay);\n\n overlay.addEventListener('pointerdown', onDown);\n overlay.addEventListener('pointermove', onMove);\n overlay.addEventListener('pointerup', onUp);\n overlay.addEventListener('pointercancel', onUp);\n // Listen on the block's OWN document so shortcuts work even when the block\n // lives in the host document (the page-shape designer renders its modal at\n // the app root, outside this iframe).\n S.keyDoc = block.ownerDocument || document;\n S.keyDoc.addEventListener('keydown', onKey, true);\n S.keyDoc.addEventListener('keyup', onKeyUp, true);\n\n // inline-editor.js's attachChrome() runs removeChrome() ~2 frames after edit\n // mode starts, which deletes every [data-cs-chrome] — including our overlay.\n // Re-append it whenever it gets stripped while we're still editing.\n S.guard = new MutationObserver(() => {\n if (S && S.block === block && block.classList.contains('cs-editing') && !inner.contains(overlay)) {\n inner.appendChild(overlay);\n drawOverlay();\n }\n });\n S.guard.observe(inner, { childList: true });\n\n S.ro = new ResizeObserver(() => drawOverlay());\n S.ro.observe(inner);\n\n syncToolbar();\n };\n\n const deactivate = () => {\n if (!S) return;\n (S.keyDoc || document).removeEventListener('keydown', onKey, true);\n (S.keyDoc || document).removeEventListener('keyup', onKeyUp, true);\n S.guard?.disconnect();\n S.ro?.disconnect();\n S.overlay.remove();\n writeState(S.block, S.state);\n renderShape(S.block);\n S = null;\n };\n\n /* ------------------------- public engine surface -------------------------- */\n // Expose the reusable pen engine so other UIs (e.g. the full-page background\n // shape designer) can run the exact same drawing/editing session on any\n // block built by createBlock() — no code duplication.\n Object.assign(window.PenShape, {\n activate, // activate(block) → start the pen session + toolbar overlay\n deactivate, // deactivate() → end the session, write state, render final\n renderShape, // renderShape(block) → repaint <path>/<defs> from dataset\n readState, writeState,\n readStyle, writeStyle,\n clearAllPaths, // clearAllPaths() → wipe the active session's shapes\n loadPreset, // loadPreset(name) → add a preset shape (rectangle, corner, …)\n mergeSelected, // merge the multi-selected layers into one\n toggleLockSelected, // lock / unlock the multi-selected layers\n getActiveBlock: () => (S ? S.block : null),\n // Attach (or detach with null) an external element to host the rich,\n // Photoshop-style layers panel. The engine fills + keeps it in sync.\n setLayersPanel: (el) => {\n if (!S) return;\n S.panelEl = el || null;\n S.toolbar?.classList.toggle('cs-pen-has-panel', !!el);\n renderLayers();\n },\n // Relocate the style/transform controls into an external host (the modal's\n // right-hand panel). They keep working because their listeners + queries are\n // bound to the props container itself, not the toolbar.\n setPropsPanel: (el) => {\n if (!S || !S.propsEl) return;\n if (el) { el.appendChild(S.propsEl); S.propsEl.classList.add('cs-pen-props--panel'); }\n else if (S.toolbar) { S.toolbar.appendChild(S.propsEl); S.propsEl.classList.remove('cs-pen-props--panel'); }\n S.toolbar?.classList.toggle('cs-pen-has-props-panel', !!el);\n updateFillControls();\n },\n VIEWBOX: VB,\n });\n\n /* --------------------------------- wiring --------------------------------- */\n const init = () => {\n // Watch the whole page board, not a single .custom-form-design: each cover\n // page is its OWN .custom-form-design surface (a sibling under .cs_paper),\n // so observing only the first one missed pen-shape blocks dropped on cover\n // pages — their cs-editing class change was never seen and activate() never\n // ran. .cs_paper contains every page (content wrappers + covers).\n const surface = document.querySelector('.cs_paper')\n || document.querySelector('.custom-form-design')\n || document.body;\n if (!surface) return;\n const obs = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName !== 'class') continue;\n const el = m.target;\n if (!el.classList || el.dataset.blockType !== 'pen-shape') continue;\n if (el.classList.contains('cs-editing')) activate(el);\n else if (S && S.block === el) deactivate();\n }\n });\n obs.observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/table-block.js\">\n/**\n * @fileoverview Static Table block — a Canva-style editable table.\n *\n * A plain <table> inside a .cs_block_s. Unlike the data-bound Table Repeater,\n * this is a STATIC table the user builds by hand: type into cells, add/remove\n * rows & columns, merge/split cells, resize columns/rows, and style cells\n * (fill / border / alignment / text format).\n *\n * It exports cleanly: the <table> is plain DOM the Twig generator clones; the\n * floating toolbar lives in <body> with [data-cs-chrome] so it's never\n * exported and never starts a drag. Cell `contenteditable` is stripped on\n * deactivate.\n *\n * Editing turns on when the block enters `.cs-editing` (the same state machine\n * inline-editor.js drives for every block). Because the table has no `.edit_me`\n * target, inline-editor's text-editor init no-ops and we own all interaction.\n *\n * Internals use a rectangular ID-matrix (read() ⇄ render()) so merges,\n * inserts and deletes stay correct even with col/row spans.\n *\n * Exposes: window.TableBlock.createBlock(rows, cols)\n */\n(function () {\n window.TableBlock = window.TableBlock || {};\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2));\n\n /* ------------------------------ block markup ----------------------------- */\n\n const buildTableEl = (rows, cols) => {\n const table = document.createElement('table');\n table.className = 'cs-table';\n table.id = `dynamic_${hash()}`;\n\n const cg = document.createElement('colgroup');\n for (let c = 0; c < cols; c++) {\n const col = document.createElement('col');\n col.style.width = `${(100 / cols).toFixed(4)}%`;\n cg.appendChild(col);\n }\n table.appendChild(cg);\n\n const tbody = document.createElement('tbody');\n for (let r = 0; r < rows; r++) {\n const tr = document.createElement('tr');\n for (let c = 0; c < cols; c++) {\n const td = document.createElement('td');\n td.className = 'cs-cell' + (r === 0 ? ' cs-cell--head' : '');\n td.innerHTML = r === 0 ? `` : '';\n tr.appendChild(td);\n }\n tbody.appendChild(tr);\n }\n table.appendChild(tbody);\n return table;\n };\n\n const createBlock = (rows = 3, cols = 3) => {\n const block = document.createElement('div');\n block.className = 'cs_block_s cs-table-block';\n block.setAttribute('data', 'Table');\n block.setAttribute('custom-name', 'Table');\n block.dataset.blockType = 'table';\n block.id = `block_${hash()}`;\n\n const table = buildTableEl(rows, cols);\n\n // Froala mode: wrap the table in an `.edit_me` so inline-editor.js inits\n // Froala on it (giving Froala's built-in table cell editing) — same pattern\n // as the Table Repeater, just without data binding. Custom mode: bare table\n // so our own engine drives it.\n if ((typeof window.isFroalaEditor === 'function') && window.isFroalaEditor()) {\n const wrap = document.createElement('div');\n wrap.className = 'edit_me cs-table-edit fr-element fr-view';\n wrap.id = `dynamic_${hash()}`;\n wrap.appendChild(table);\n block.appendChild(wrap);\n } else {\n block.appendChild(table);\n }\n return block;\n };\n\n /* ------------------------- matrix read / render -------------------------- */\n\n // Build an ID-matrix M[r][c] -> cellId, plus a cells{} map of cell data.\n // Spanned slots all point at the same id; the cell's bounding rect (derived\n // at render time) yields its colspan/rowspan.\n const read = (table) => {\n const body = table.tBodies[0];\n const trs = Array.from(body ? body.rows : []);\n const M = [];\n const cells = {};\n let nextId = 1;\n trs.forEach((tr, r) => {\n M[r] = M[r] || [];\n let c = 0;\n Array.from(tr.cells).forEach((td) => {\n while (M[r][c] !== undefined) c++;\n const id = nextId++;\n cells[id] = { html: td.innerHTML, style: td.getAttribute('style') || '', head: td.classList.contains('cs-cell--head') };\n const cs = td.colSpan || 1, rs = td.rowSpan || 1;\n for (let i = 0; i < rs; i++) { M[r + i] = M[r + i] || []; for (let j = 0; j < cs; j++) M[r + i][c + j] = id; }\n c += cs;\n });\n });\n const cols = M.reduce((m, row) => Math.max(m, row.length), 0);\n // Fill ragged gaps with fresh empty cells so the matrix is rectangular.\n M.forEach((row) => { for (let c = 0; c < cols; c++) if (row[c] === undefined) { const id = nextId++; cells[id] = { html: '', style: '', head: false }; row[c] = id; } });\n return { M, cells, rows: M.length, cols };\n };\n\n const colWidthsOf = (table) => Array.from(table.querySelectorAll('colgroup > col')).map((c) => c.style.width || '');\n\n /**\n * Legacy Froala mode only: Froala's built-in \"insert column/row\" creates plain\n * <td>s that are missing our `cs-cell` class (so they get no border) and often\n * carry a junk `style=\"null; width:…\"` attribute. Re-stamp every cell so it\n * looks like a real table cell again. A freshly inserted cell mirrors the\n * header state of its row's already-stamped siblings, so a column inserted\n * into the header row stays a header. Returns true if anything changed.\n */\n const normalizeCells = (table) => {\n if (!table) return false;\n let changed = false;\n Array.from(table.rows).forEach((tr) => {\n // Captured before we stamp anything: do this row's existing cells read as\n // header cells? Column inserts should match their row.\n const rowIsHead = Array.from(tr.cells).some((c) => c.classList.contains('cs-cell--head'));\n Array.from(tr.cells).forEach((td) => {\n if (!td.classList.contains('cs-cell')) {\n td.classList.add('cs-cell');\n if (rowIsHead) td.classList.add('cs-cell--head');\n changed = true;\n }\n // Drop the literal \"null\" Froala prepends to copied style attributes.\n const style = td.getAttribute('style');\n if (style && /(^|;)\\s*null\\s*(;|$)/.test(style)) {\n const cleaned = style.replace(/(^|;)\\s*null\\s*(;|$)/g, '$1').replace(/^;+/, '').trim();\n if (cleaned) td.setAttribute('style', cleaned);\n else td.removeAttribute('style');\n changed = true;\n }\n });\n });\n return changed;\n };\n\n // Re-render <colgroup> + <tbody> from a matrix. Each cell is emitted once at\n // the top-left of its bounding rect with the right colspan/rowspan.\n const render = (table, state, colWidths) => {\n const { M, cells, rows, cols } = state;\n const rect = {};\n for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {\n const id = M[r][c];\n const b = rect[id] || (rect[id] = { r0: r, c0: c, r1: r, c1: c });\n b.r0 = Math.min(b.r0, r); b.c0 = Math.min(b.c0, c); b.r1 = Math.max(b.r1, r); b.c1 = Math.max(b.c1, c);\n }\n const tbody = document.createElement('tbody');\n for (let r = 0; r < rows; r++) {\n const tr = document.createElement('tr');\n for (let c = 0; c < cols; c++) {\n const id = M[r][c];\n const b = rect[id];\n if (b.r0 !== r || b.c0 !== c) continue; // skip non top-left slots\n const td = document.createElement('td');\n td.className = 'cs-cell' + (cells[id].head ? ' cs-cell--head' : '');\n if (cells[id].style) td.setAttribute('style', cells[id].style);\n const cspan = b.c1 - b.c0 + 1, rspan = b.r1 - b.r0 + 1;\n if (cspan > 1) td.colSpan = cspan;\n if (rspan > 1) td.rowSpan = rspan;\n td.innerHTML = cells[id].html || '';\n tr.appendChild(td);\n }\n tbody.appendChild(tr);\n }\n const oldCg = table.querySelector('colgroup');\n if (oldCg) oldCg.remove();\n const cg = document.createElement('colgroup');\n for (let c = 0; c < cols; c++) {\n const col = document.createElement('col');\n const w = (colWidths && colWidths[c]) || `${(100 / cols).toFixed(4)}%`;\n col.style.width = w;\n cg.appendChild(col);\n }\n if (table.tBodies[0]) { table.insertBefore(cg, table.tBodies[0]); table.tBodies[0].replaceWith(tbody); }\n else { table.appendChild(cg); table.appendChild(tbody); }\n };\n\n /* ------------------------------ coordinates ------------------------------ */\n\n // The matrix top-left (r,c) of a rendered <td>. A row's DOM cells are exactly\n // the cells whose top-left is on that row (rowspan cells from above don't\n // appear in the row), so we map the td's DOM index to the n-th column that\n // starts a cell on this row.\n const cellRect = (table, td) => {\n const state = read(table);\n const tr = td.parentElement;\n const r0 = Array.from(table.tBodies[0].rows).indexOf(tr);\n const idx = Array.from(tr.cells).indexOf(td);\n const starts = [];\n for (let c = 0; c < state.cols; c++) {\n const id = state.M[r0][c];\n const topLeftHere = (r0 === 0 || state.M[r0 - 1][c] !== id) && (c === 0 || state.M[r0][c - 1] !== id);\n if (topLeftHere) starts.push(c);\n }\n const c0 = starts[idx] != null ? starts[idx] : 0;\n return { r: r0, c: c0, state };\n };\n\n /* --------------------------------- engine -------------------------------- */\n\n let S = null; // { block, table, toolbar, selected:Set<td>, anchor, ... }\n\n const getColWidths = () => colWidthsOf(S.table);\n\n // The rendered <td> that owns matrix slot (r,c) — used to re-find the active\n // cell after a re-render so follow-up ops still target the right cell.\n const tdAt = (r, c) => {\n const state = read(S.table);\n if (!state.M[r] || state.M[r][c] == null) return null;\n const id = state.M[r][c];\n let found = null;\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n if (found) return;\n const rc = cellRect(S.table, td);\n if (state.M[rc.r][rc.c] === id) found = td;\n });\n return found;\n };\n\n // Run a structural op: read → mutate(state) → render → re-wire cells. The\n // active cell is re-resolved by coordinate so the next op keeps targeting it.\n const apply = (mutate) => {\n const ac = (S.activeCell && S.table.contains(S.activeCell)) ? cellRect(S.table, S.activeCell) : null;\n const state = read(S.table);\n const widths = getColWidths();\n const next = mutate(state, widths) || {};\n render(S.table, state, next.widths || widths);\n wireCells();\n clearSelection();\n if (ac) {\n const d = read(S.table);\n S.activeCell = tdAt(Math.min(ac.r, d.rows - 1), Math.min(ac.c, d.cols - 1));\n S.anchorCell = S.activeCell;\n } else { S.activeCell = null; S.anchorCell = null; }\n updateOverlay();\n emitChange();\n };\n\n const activeCoord = () => {\n const td = S.activeCell && S.table.contains(S.activeCell) ? S.activeCell : S.table.querySelector('.cs-cell');\n if (!td) return { r: 0, c: 0 };\n return cellRect(S.table, td);\n };\n\n /* structural operations -------------------------------------------------- */\n\n const insertRow = (where) => apply((st) => {\n const { r } = activeCoord();\n const at = where === 'above' ? r : r + 1;\n const row = [];\n for (let c = 0; c < st.cols; c++) {\n // If a vertical span crosses the insert boundary, extend it.\n if (at > 0 && at < st.rows && st.M[at - 1][c] === st.M[at][c]) row[c] = st.M[at][c];\n else { const id = freshId(st); row[c] = id; }\n }\n st.M.splice(at, 0, row);\n st.rows++;\n });\n\n const insertCol = (where) => apply((st, widths) => {\n const { c } = activeCoord();\n const at = where === 'left' ? c : c + 1;\n for (let r = 0; r < st.rows; r++) {\n if (at > 0 && at < st.cols && st.M[r][at - 1] === st.M[r][at]) st.M[r].splice(at, 0, st.M[r][at]); // inside a horizontal span\n else st.M[r].splice(at, 0, freshId(st, r === 0));\n }\n st.cols++;\n const nw = widths.slice(); nw.splice(at, 0, `${(100 / st.cols).toFixed(4)}%`);\n return { widths: nw };\n });\n\n const deleteRow = () => apply((st) => {\n if (st.rows <= 1) return;\n const { r } = activeCoord();\n st.M.splice(r, 1); st.rows--;\n });\n\n const deleteCol = () => apply((st, widths) => {\n if (st.cols <= 1) return;\n const { c } = activeCoord();\n st.M.forEach((row) => row.splice(c, 1)); st.cols--;\n const nw = widths.slice(); nw.splice(c, 1);\n return { widths: nw };\n });\n\n // A fresh empty cell id added to a state mid-mutation.\n const freshId = (st, head = false) => {\n const id = (st._next || (st._next = Object.keys(st.cells).length + 1000)) + 1;\n st._next = id;\n st.cells[id] = { html: '', style: '', head };\n return id;\n };\n\n /* merge / split ---------------------------------------------------------- */\n\n // Bounding rect of the current selection + whether it COMPLETELY fills that\n // rect (no gaps). Merge is allowed only for a full rectangle of ≥2 cells —\n // otherwise a diagonal/sparse pick would swallow unselected cells.\n const selectionRectInfo = () => {\n const tds = S.selected.size ? Array.from(S.selected) : (S.activeCell ? [S.activeCell] : []);\n if (!tds.length) return null;\n const state = read(S.table);\n const ids = new Set();\n tds.forEach((td) => { const rc = cellRect(S.table, td); ids.add(state.M[rc.r][rc.c]); });\n let r0 = Infinity, c0 = Infinity, r1 = -1, c1 = -1, slots = 0;\n for (let r = 0; r < state.rows; r++) for (let c = 0; c < state.cols; c++) {\n if (ids.has(state.M[r][c])) { slots++; r0 = Math.min(r0, r); c0 = Math.min(c0, c); r1 = Math.max(r1, r); c1 = Math.max(c1, c); }\n }\n const area = (r1 - r0 + 1) * (c1 - c0 + 1);\n return { r0, c0, r1, c1, filled: slots === area, cellCount: ids.size };\n };\n\n // Merge offered only when the picked cells form a complete rectangle (≥2).\n const canMerge = () => { const i = selectionRectInfo(); return !!i && i.filled && i.cellCount > 1; };\n // Split offered only for an already-merged cell.\n const canSplit = () => { const td = S.activeCell; return !!td && ((td.colSpan || 1) > 1 || (td.rowSpan || 1) > 1); };\n\n const mergeCells = () => {\n const info = selectionRectInfo();\n if (!info || !info.filled || info.cellCount < 2) return;\n const { r0, c0, r1, c1 } = info;\n apply((st) => {\n const keep = st.M[r0][c0];\n const parts = [];\n const done = new Set();\n for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {\n const id = st.M[r][c];\n if (!done.has(id)) { done.add(id); const h = (st.cells[id].html || '').trim(); if (h && id !== keep) parts.push(h); }\n st.M[r][c] = keep;\n }\n if (parts.length) st.cells[keep].html = [(st.cells[keep].html || '').trim(), ...parts].filter(Boolean).join(' ');\n });\n };\n\n const splitCell = () => {\n if (!canSplit()) return;\n const { r, c } = activeCoord();\n apply((st) => {\n const id = st.M[r][c];\n let first = true;\n for (let rr = 0; rr < st.rows; rr++) for (let cc = 0; cc < st.cols; cc++) {\n if (st.M[rr][cc] === id) {\n if (first) { first = false; } // keep master slot as-is\n else st.M[rr][cc] = freshId(st, st.cells[id].head);\n }\n }\n });\n };\n\n /* cell styling ----------------------------------------------------------- */\n\n const eachSelected = (fn) => {\n const tds = S.selected.size ? Array.from(S.selected) : (S.activeCell ? [S.activeCell] : []);\n tds.forEach(fn);\n emitChange();\n };\n\n const setCellStyle = (prop, value) => eachSelected((td) => { td.style[prop] = value; });\n const toggleHeader = () => eachSelected((td) => td.classList.toggle('cs-cell--head'));\n\n const setBorder = (color, on) => eachSelected((td) => {\n if (on === false) { td.style.border = 'none'; return; }\n td.style.border = `1px solid ${color || '#d0d5e2'}`;\n });\n\n // Text format inside the focused cell via execCommand.\n const textCmd = (cmd, val) => {\n if (S.activeCell) S.activeCell.focus();\n try { document.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n try { document.execCommand(cmd, false, val == null ? null : val); } catch (e) { /* */ }\n emitChange();\n };\n\n /* ------------------------------- selection ------------------------------- */\n\n const clearSelection = () => {\n if (!S) return;\n S.table.querySelectorAll('.cs-cell--selected').forEach((td) => td.classList.remove('cs-cell--selected'));\n S.selected.clear();\n updateOverlay();\n };\n\n const selectRange = (a, b) => {\n clearSelection();\n const ra = cellRect(S.table, a), rb = cellRect(S.table, b);\n const r0 = Math.min(ra.r, rb.r), r1 = Math.max(ra.r, rb.r);\n const c0 = Math.min(ra.c, rb.c), c1 = Math.max(ra.c, rb.c);\n const state = read(S.table);\n const ids = new Set();\n for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) ids.add(state.M[r][c]);\n // map ids back to rendered tds\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n const rc = cellRect(S.table, td);\n if (ids.has(state.M[rc.r][rc.c])) { td.classList.add('cs-cell--selected'); S.selected.add(td); }\n });\n updateOverlay();\n };\n\n // Single Canva-style rectangle drawn over the union of the selected cells\n // (a body-level fixed box so it isn't clipped and needs no block positioning).\n const updateOverlay = () => {\n if (!S) return;\n if (!S.overlay) { S.overlay = document.createElement('div'); S.overlay.className = 'cs-tbl-selrect'; S.overlay.setAttribute('data-cs-chrome', ''); document.body.appendChild(S.overlay); }\n if (!S.selected.size) { S.overlay.style.display = 'none'; return; }\n let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;\n S.selected.forEach((td) => { const q = td.getBoundingClientRect(); l = Math.min(l, q.left); t = Math.min(t, q.top); r = Math.max(r, q.right); b = Math.max(b, q.bottom); });\n S.overlay.style.display = 'block';\n S.overlay.style.left = `${l}px`;\n S.overlay.style.top = `${t}px`;\n S.overlay.style.width = `${r - l}px`;\n S.overlay.style.height = `${b - t}px`;\n };\n\n /* ------------------------------- toolbar --------------------------------- */\n\n // Self-explanatory inline-SVG icons so each table tool reads at a glance:\n // a green band + \"+\" means INSERT a row/column on that side, a red band + \"✕\"\n // means DELETE, etc. Tooltips (title=) still spell every button out.\n const svg = (inner, vb = '0 0 18 18') =>\n `<svg width=\"15\" height=\"15\" viewBox=\"${vb}\" fill=\"none\" aria-hidden=\"true\">${inner}</svg>`;\n const PLUS = (cx, cy) =>\n `<path d=\"M${cx} ${cy - 1.15}V${cy + 1.15}M${cx - 1.15} ${cy}H${cx + 1.15}\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>`;\n const CROSS = (cx, cy) =>\n `<path d=\"M${cx - 1.1} ${cy - 1.1}L${cx + 1.1} ${cy + 1.1}M${cx + 1.1} ${cy - 1.1}L${cx - 1.1} ${cy + 1.1}\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>`;\n\n const ICON = {\n // existing table outlined in the current colour + a green \"new\" band w/ +\n rowAbove: svg(`<rect x=\"2.5\" y=\"8\" width=\"13\" height=\"7.5\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"8\" x2=\"9\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"2.5\" y=\"2\" width=\"13\" height=\"4.4\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(9, 4.2)}`),\n rowBelow: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"7.5\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"2.5\" x2=\"9\" y2=\"10\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"2.5\" y=\"11.6\" width=\"13\" height=\"4.4\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(9, 13.8)}`),\n colLeft: svg(`<rect x=\"8\" y=\"2.5\" width=\"7.5\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"8\" y1=\"9\" x2=\"15.5\" y2=\"9\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"2\" y=\"2.5\" width=\"4.4\" height=\"13\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(4.2, 9)}`),\n colRight: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"7.5\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"2.5\" y1=\"9\" x2=\"10\" y2=\"9\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"11.6\" y=\"2.5\" width=\"4.4\" height=\"13\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(13.8, 9)}`),\n // full table + the doomed row/column tinted red w/ ✕\n delRow: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"2.5\" y1=\"7.2\" x2=\"15.5\" y2=\"7.2\" stroke=\"currentColor\" stroke-width=\"1\"/><line x1=\"2.5\" y1=\"10.8\" x2=\"15.5\" y2=\"10.8\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"3\" y=\"7.4\" width=\"12\" height=\"3.2\" fill=\"#ff5a5a\"/>${CROSS(9, 9)}`),\n delCol: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"7.2\" y1=\"2.5\" x2=\"7.2\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><line x1=\"10.8\" y1=\"2.5\" x2=\"10.8\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"7.4\" y=\"3\" width=\"3.2\" height=\"12\" fill=\"#ff5a5a\"/>${CROSS(9, 9)}`),\n // two cells → one (arrows in) / one cell → two (arrows out)\n merge: svg(`<rect x=\"2.5\" y=\"4.5\" width=\"13\" height=\"9\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"4.5\" x2=\"9\" y2=\"13.5\" stroke=\"currentColor\" stroke-width=\"1\" stroke-dasharray=\"1.6 1.6\"/><path d=\"M5.4 7.6 L7.8 9 L5.4 10.4 Z\" fill=\"currentColor\"/><path d=\"M12.6 7.6 L10.2 9 L12.6 10.4 Z\" fill=\"currentColor\"/>`),\n split: svg(`<rect x=\"2.5\" y=\"4.5\" width=\"13\" height=\"9\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"4.5\" x2=\"9\" y2=\"13.5\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M7.4 7.6 L5 9 L7.4 10.4 Z\" fill=\"currentColor\"/><path d=\"M10.6 7.6 L13 9 L10.6 10.4 Z\" fill=\"currentColor\"/>`),\n // table with a filled top row = header\n header: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><rect x=\"3\" y=\"3\" width=\"12\" height=\"3.4\" fill=\"currentColor\"/><line x1=\"9\" y1=\"6.4\" x2=\"9\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><line x1=\"2.5\" y1=\"11\" x2=\"15.5\" y2=\"11\" stroke=\"currentColor\" stroke-width=\"1\"/>`),\n // filled square = fill, outline square = border, dashed+slash = no border\n fill: svg(`<rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.6\" fill=\"currentColor\" opacity=\"0.55\"/><rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.2\"/>`),\n border: svg(`<rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.4\" stroke=\"currentColor\" stroke-width=\"1.8\"/>`),\n borderOff: svg(`<rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-dasharray=\"2.2 1.8\"/><line x1=\"3.5\" y1=\"14.5\" x2=\"14.5\" y2=\"3.5\" stroke=\"#ff5a5a\" stroke-width=\"1.4\" stroke-linecap=\"round\"/>`),\n };\n\n // The table-ONLY controls. Appended to the END of the shared text toolbar so a\n // table shows \"[every text-block option] + [these]\", while a plain text block\n // shows just the text options. Insert/delete row-col · merge/split/header ·\n // cell border. (Cell fill = the shared \"highlight\" colour; text colour = the\n // shared \"A\".) Same green-band-+ / red-band-✕ icons as before.\n const tableGroupHTML = () => `\n <div class=\"cre-group\">\n <button type=\"button\" data-op=\"row-above\" title=\"Insert row above\">${ICON.rowAbove}</button>\n <button type=\"button\" data-op=\"row-below\" title=\"Insert row below\">${ICON.rowBelow}</button>\n <button type=\"button\" data-op=\"col-left\" title=\"Insert column left\">${ICON.colLeft}</button>\n <button type=\"button\" data-op=\"col-right\" title=\"Insert column right\">${ICON.colRight}</button>\n <button type=\"button\" data-op=\"del-row\" title=\"Delete row\">${ICON.delRow}</button>\n <button type=\"button\" data-op=\"del-col\" title=\"Delete column\">${ICON.delCol}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-op=\"merge\" title=\"Merge selected cells\">${ICON.merge}</button>\n <button type=\"button\" data-op=\"split\" title=\"Split cell\">${ICON.split}</button>\n <button type=\"button\" data-op=\"header\" title=\"Toggle header row\">${ICON.header}</button>\n <label class=\"cre-color\" title=\"Cell border colour\">${ICON.border}<input type=\"color\" data-border value=\"#d0d5e2\"></label>\n <button type=\"button\" data-op=\"border-off\" title=\"Remove cell border\">${ICON.borderOff}</button>\n </div>`;\n\n // Heading at cell level = size + bold; \"Normal\" clears both back to default.\n const HEADING_PX = { h1: '32px', h2: '24px', h3: '19px', h4: '16px', h5: '13px', h6: '11px' };\n const applyCellHeading = (level) => eachSelected((td) => {\n if (HEADING_PX[level]) { td.style.fontSize = HEADING_PX[level]; td.style.fontWeight = '700'; }\n else { td.style.fontSize = ''; td.style.fontWeight = ''; }\n });\n\n // Text case via CSS text-transform (non-destructive).\n const applyCellCase = (value) => { if (value) setCellStyle('textTransform', value); };\n\n const buildToolbar = () => {\n const tb = document.createElement('div');\n // Same class as the text-block bar → identical look + placement + docked\n // behaviour. The extra `cre-toolbar--table` marker lets the click-away guard\n // recognise our bar. `is-visible` shows it (cre-toolbar is hidden by default).\n tb.className = 'cre-toolbar cre-toolbar--table is-visible';\n tb.setAttribute('data-cs-chrome', '');\n const richHTML = (window.CustomRichEditor && window.CustomRichEditor.toolbarInnerHTML)\n ? window.CustomRichEditor.toolbarInnerHTML(window.FROALA_FONTS || null, null)\n : '';\n tb.innerHTML = richHTML + tableGroupHTML();\n\n // Keep cell focus/selection when pressing a control (selects + colour inputs\n // need focus to open, so don't preventDefault those).\n tb.addEventListener('mousedown', (e) => { if (!e.target.closest('input, select')) e.preventDefault(); });\n tb.addEventListener('click', onToolbarClick);\n tb.addEventListener('change', onToolbarChange);\n tb.addEventListener('input', onToolbarInput);\n document.body.appendChild(tb);\n return tb;\n };\n\n // Route the SHARED text toolbar's controls to the table's cell operations, so\n // the same bar drives both. (data-cmd/-act/-sel/-color come from the rich\n // markup; data-op/-border are our appended table group.)\n const ALIGN_CMD = { justifyLeft: 'left', justifyCenter: 'center', justifyRight: 'right', justifyFull: 'justify' };\n const onToolbarClick = (e) => {\n const opBtn = e.target.closest('[data-op]');\n if (opBtn) { e.preventDefault(); return runOp(opBtn.dataset.op); }\n const actBtn = e.target.closest('[data-act]');\n if (actBtn) {\n e.preventDefault();\n if (actBtn.dataset.act === 'link') { const u = window.prompt('Link URL:', 'https://'); if (u) textCmd('createLink', u); }\n return;\n }\n const cmdBtn = e.target.closest('[data-cmd]');\n if (cmdBtn) {\n e.preventDefault();\n const cmd = cmdBtn.dataset.cmd;\n if (ALIGN_CMD[cmd]) return setCellStyle('textAlign', ALIGN_CMD[cmd]);\n return textCmd(cmd);\n }\n };\n const onToolbarChange = (e) => {\n const sel = e.target.closest('[data-sel]');\n if (!sel) return;\n const v = sel.value;\n switch (sel.dataset.sel) {\n case 'format': return applyCellHeading(v);\n case 'font': return v && setCellStyle('fontFamily', v);\n case 'size': return v && setCellStyle('fontSize', /px|em|rem|%/.test(v) ? v : v + 'px');\n case 'lineheight': return v && setCellStyle('lineHeight', v);\n case 'letterspacing': return v && setCellStyle('letterSpacing', v);\n case 'textcase': return v && applyCellCase(v);\n }\n };\n const onToolbarInput = (e) => {\n const t = e.target;\n if (t.matches('[data-color=\"fore\"]')) return setCellStyle('color', t.value);\n if (t.matches('[data-color=\"back\"]')) return setCellStyle('backgroundColor', t.value); // highlight → cell fill\n if (t.matches('[data-border]')) return setBorder(t.value, true);\n };\n\n const runOp = (op) => {\n switch (op) {\n case 'row-above': return insertRow('above');\n case 'row-below': return insertRow('below');\n case 'col-left': return insertCol('left');\n case 'col-right': return insertCol('right');\n case 'del-row': return deleteRow();\n case 'del-col': return deleteCol();\n case 'merge': return mergeCells();\n case 'split': return splitCell();\n case 'header': return toggleHeader();\n case 'border-off': return setBorder(null, false);\n case 'link': {\n const url = window.prompt('Link URL:', 'https://');\n if (url) textCmd('createLink', url);\n return;\n }\n case 'rows-equal': return rowsEqual();\n case 'cols-equal': return colsEqual();\n case 'rows-content': return rowsContent();\n case 'cols-content': return colsContent();\n case 'mv-row-up': return moveRow('up');\n case 'mv-row-down': return moveRow('down');\n case 'mv-col-left': return moveCol('left');\n case 'mv-col-right': return moveCol('right');\n case 'del-table': { const b = S.block; deactivate(); try { window.EditorManager?.clearAll?.(); } catch (e) { /* */ } b.remove(); return; }\n }\n };\n\n // Make every row the same height (= the current tallest row).\n const rowsEqual = () => {\n const rows = Array.from(S.table.tBodies[0].rows);\n let max = 0;\n rows.forEach((tr) => { max = Math.max(max, tr.getBoundingClientRect().height); });\n rows.forEach((tr) => { tr.style.height = `${Math.round(max)}px`; });\n emitChange();\n };\n\n // Make every column an equal share of the table width.\n const colsEqual = () => {\n const cols = Array.from(S.table.querySelectorAll('colgroup > col'));\n const w = `${(100 / cols.length).toFixed(4)}%`;\n cols.forEach((c) => { c.style.width = w; });\n emitChange();\n };\n\n // Let every row shrink to its content height.\n const rowsContent = () => {\n Array.from(S.table.tBodies[0].rows).forEach((tr) => { tr.style.height = ''; });\n emitChange();\n };\n\n // Fit every column to its widest cell (measured under auto layout — fixed\n // layout would just report the set width).\n const colsContent = () => {\n const cols = Array.from(S.table.querySelectorAll('colgroup > col'));\n const prevLayout = S.table.style.tableLayout;\n S.table.style.tableLayout = 'auto';\n cols.forEach((c) => { c.style.width = ''; });\n const widths = cols.map((_, ci) => {\n let m = 24;\n Array.from(S.table.tBodies[0].rows).forEach((tr) => Array.from(tr.cells).forEach((cell) => {\n if ((cell.colSpan || 1) === 1 && cellRect(S.table, cell).c === ci) m = Math.max(m, cell.getBoundingClientRect().width);\n }));\n return m;\n });\n S.table.style.tableLayout = prevLayout || '';\n const tableW = S.table.getBoundingClientRect().width || 1;\n cols.forEach((c, ci) => { c.style.width = `${(Math.ceil(widths[ci] + 8) / tableW * 100).toFixed(2)}%`; });\n emitChange();\n };\n\n // Move the active row up/down (swaps adjacent matrix rows).\n const moveRow = (dir) => apply((st) => {\n const { r } = activeCoord();\n const to = r + (dir === 'down' ? 1 : -1);\n if (to < 0 || to >= st.rows) return;\n const tmp = st.M[r]; st.M[r] = st.M[to]; st.M[to] = tmp;\n });\n\n // Move the active column left/right (swaps adjacent matrix columns + widths).\n const moveCol = (dir) => apply((st, widths) => {\n const { c } = activeCoord();\n const to = c + (dir === 'right' ? 1 : -1);\n if (to < 0 || to >= st.cols) return;\n st.M.forEach((row) => { const t = row[c]; row[c] = row[to]; row[to] = t; });\n const nw = widths.slice(); const t = nw[c]; nw[c] = nw[to]; nw[to] = t;\n return { widths: nw };\n });\n\n /* --------------------------- right-click menu ---------------------------- */\n\n let cmenu = null;\n // Built fresh each open so Merge/Split only appear when they apply.\n const menuItems = () => {\n const items = [\n { op: 'row-above', label: '+ Add row above' },\n { op: 'row-below', label: '+ Add row below' },\n { op: 'col-left', label: '+ Add column left' },\n { op: 'col-right', label: '+ Add column right' },\n { sep: true },\n ];\n if (canMerge()) items.push({ op: 'merge', label: '⊞ Merge cells' }, { sep: true });\n if (canSplit()) items.push({ op: 'split', label: '⤲ Unmerge cell' }, { sep: true });\n items.push(\n { op: 'rows-equal', label: '▤ Size rows equally' },\n { op: 'cols-equal', label: '▥ Size columns equally' },\n { op: 'rows-content', label: '↕ Size rows to content' },\n { op: 'cols-content', label: '↔ Size columns to content' },\n { sep: true },\n );\n\n // Move items appear only in the directions the active cell can actually\n // move (no \"up\" on the first row, no \"left\" on the first column, etc.).\n const pos = (() => {\n if (!S.activeCell || !S.table.contains(S.activeCell)) return null;\n const rc = cellRect(S.table, S.activeCell);\n const d = read(S.table);\n return { r: rc.r, c: rc.c, rows: d.rows, cols: d.cols };\n })();\n if (pos) {\n const mv = [];\n if (pos.r > 0) mv.push({ op: 'mv-row-up', label: '↑ Move row up' });\n if (pos.r < pos.rows - 1) mv.push({ op: 'mv-row-down', label: '↓ Move row down' });\n if (pos.c > 0) mv.push({ op: 'mv-col-left', label: '← Move column left' });\n if (pos.c < pos.cols - 1) mv.push({ op: 'mv-col-right', label: '→ Move column right' });\n if (mv.length) items.push(...mv, { sep: true });\n }\n\n items.push(\n { op: 'del-row', label: '🗑 Delete row', danger: true },\n { op: 'del-col', label: '🗑 Delete column', danger: true },\n { op: 'del-table', label: '🗑 Delete table', danger: true },\n );\n return items;\n };\n\n const clearPreview = () => {\n if (!S) return;\n S.table.querySelectorAll('.cs-cell--danger').forEach((td) => td.classList.remove('cs-cell--danger'));\n };\n\n // Mark every rendered cell whose matrix area satisfies `pred(r,c)` red.\n const markCells = (pred) => {\n if (!S) return;\n const state = read(S.table);\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n const rc = cellRect(S.table, td);\n const id = state.M[rc.r][rc.c];\n let hit = false;\n for (let r = 0; r < state.rows && !hit; r++) for (let c = 0; c < state.cols; c++) { if (state.M[r][c] === id && pred(r, c)) { hit = true; break; } }\n if (hit) td.classList.add('cs-cell--danger');\n });\n };\n\n // Hover-preview of what a delete item will remove (Canva-style red highlight).\n const previewOp = (op) => {\n clearPreview();\n if (!S) return;\n if (op === 'del-table') { S.table.querySelectorAll('td.cs-cell').forEach((td) => td.classList.add('cs-cell--danger')); return; }\n if (op === 'del-row') { const { r } = activeCoord(); markCells((rr) => rr === r); return; }\n if (op === 'del-col') { const { c } = activeCoord(); markCells((rr, cc) => cc === c); return; }\n };\n\n const hideContextMenu = () => {\n clearPreview();\n if (cmenu) { cmenu.remove(); cmenu = null; }\n };\n\n const showContextMenu = (x, y) => {\n hideContextMenu();\n const m = document.createElement('div');\n m.className = 'cs-tbl-menu';\n m.setAttribute('data-cs-chrome', '');\n menuItems().forEach((it) => {\n if (it.sep) { const s = document.createElement('div'); s.className = 'cs-tbl-menu__sep'; m.appendChild(s); return; }\n const b = document.createElement('button');\n b.type = 'button';\n b.className = 'cs-tbl-menu__item' + (it.danger ? ' cs-tbl-menu__item--danger' : '');\n b.dataset.op = it.op;\n b.textContent = it.label;\n if (/^del-/.test(it.op)) {\n b.addEventListener('mouseenter', () => previewOp(it.op));\n b.addEventListener('mouseleave', clearPreview);\n }\n m.appendChild(b);\n });\n m.addEventListener('mousedown', (e) => e.preventDefault());\n m.addEventListener('click', (e) => {\n const op = e.target.closest('[data-op]')?.dataset.op;\n if (op) { clearPreview(); runOp(op); hideContextMenu(); }\n });\n document.body.appendChild(m);\n const mw = m.offsetWidth, mh = m.offsetHeight;\n let left = x, top = y;\n if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;\n if (top + mh > window.innerHeight - 8) top = window.innerHeight - mh - 8;\n m.style.left = `${Math.max(8, left)}px`;\n m.style.top = `${Math.max(8, top)}px`;\n cmenu = m;\n };\n\n const positionToolbar = () => {\n if (!S || !S.toolbar) return;\n const tb = S.toolbar;\n // Docked mode (Page Settings → \"Inline text toolbar\" OFF): pin the table\n // bar to the top of the canvas, full-width — the same place the rich-text\n // bar docks — so a single bar shows instead of the placeholder + a floating\n // one. CSS owns the placement; clear any leftover inline coords.\n const docked = (typeof window.isRichToolbarDocked === 'function') ? window.isRichToolbarDocked() : false;\n tb.classList.toggle('cre-toolbar--docked', docked);\n if (docked) {\n // Follow the host scroll (the iframe grows + host scrolls, so a fixed bar\n // would scroll off-screen). Same tracker the text bar uses.\n tb.style.left = '';\n window.CustomRichEditor?.trackDockedBar?.(tb);\n return;\n }\n window.CustomRichEditor?.untrackDockedBar?.(tb);\n\n const rect = S.block.getBoundingClientRect();\n const tw = tb.offsetWidth, th = tb.offsetHeight;\n let top = rect.top - th - 8;\n if (top < 8) top = rect.bottom + 8;\n let left = rect.left;\n if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8;\n if (left < 8) left = 8;\n tb.style.top = `${top}px`;\n tb.style.left = `${left}px`;\n };\n\n /* ------------------------------ cell wiring ------------------------------ */\n\n const wireCells = () => {\n if (!S) return;\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n td.setAttribute('contenteditable', 'true');\n });\n };\n\n const unwireCells = (table) => {\n table.querySelectorAll('td.cs-cell').forEach((td) => {\n td.removeAttribute('contenteditable');\n td.classList.remove('cs-cell--selected');\n });\n };\n\n const emitChange = () => {\n try { S.block.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) { /* */ }\n };\n\n // Put the caret at the end of a cell (used by Tab navigation).\n const focusCell = (td) => {\n if (!td) return;\n td.focus();\n const range = document.createRange();\n range.selectNodeContents(td); range.collapse(false);\n const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);\n clearSelection();\n S.activeCell = td; S.anchorCell = td;\n };\n\n // Tab / Shift+Tab move between cells (Tab on the last cell adds a row).\n const onTableKey = (e) => {\n if (e.key !== 'Tab') return;\n const td = e.target.closest && e.target.closest('td.cs-cell');\n if (!td) return;\n e.preventDefault();\n const all = Array.from(S.table.querySelectorAll('td.cs-cell'));\n const i = all.indexOf(td);\n if (e.shiftKey) { if (i > 0) focusCell(all[i - 1]); return; }\n if (i < all.length - 1) { focusCell(all[i + 1]); return; }\n // Last cell → grow the table, then land in the new row's first cell.\n S.activeCell = td;\n insertRow('below');\n const d = read(S.table);\n focusCell(tdAt(d.rows - 1, 0));\n };\n\n // Paste as PLAIN TEXT so cells don't inherit messy external markup.\n const onPaste = (e) => {\n const td = e.target.closest && e.target.closest('td.cs-cell');\n if (!td) return;\n e.preventDefault();\n const text = ((e.clipboardData || window.clipboardData)?.getData('text/plain') || '').replace(/\\r/g, '');\n try { document.execCommand('insertText', false, text); } catch (err) { /* */ }\n };\n\n /* ------------------------------ activate --------------------------------- */\n\n const onTablePointerDown = (e) => {\n if (!S) return;\n // Right-click is handled by the contextmenu listener — DON'T touch the\n // selection here (otherwise the multi-cell pick is wiped before the menu).\n if (e.button === 2) return;\n hideContextMenu();\n const td = e.target.closest('td.cs-cell');\n if (!td) return;\n // Column / row resize when grabbing a cell edge.\n const edge = edgeAt(td, e);\n if (edge) { startResize(edge, td, e); return; }\n\n // Ctrl/Cmd-click → toggle this cell in the multi-selection (no caret).\n if (e.metaKey || e.ctrlKey) {\n e.preventDefault();\n const sel = window.getSelection(); if (sel) sel.removeAllRanges();\n if (S.selected.has(td)) { S.selected.delete(td); td.classList.remove('cs-cell--selected'); }\n else { S.selected.add(td); td.classList.add('cs-cell--selected'); }\n S.activeCell = td; S.anchorCell = td;\n updateOverlay();\n return;\n }\n // Shift-click → rectangular range from the anchor cell.\n if (e.shiftKey && S.anchorCell) {\n e.preventDefault();\n const sel = window.getSelection(); if (sel) sel.removeAllRanges();\n selectRange(S.anchorCell, td);\n S.activeCell = td;\n return;\n }\n // Plain press → caret for typing; clear any multi-selection; arm drag-select.\n clearSelection();\n S.activeCell = td;\n S.anchorCell = td;\n S.dragStart = td;\n };\n\n const onTablePointerMove = (e) => {\n if (!S || !S.dragStart) return;\n const td = e.target.closest('td.cs-cell');\n if (!td || td === S.dragStart) {\n if (td === S.dragStart && S.selected.size) { /* keep */ }\n return;\n }\n // Dragged onto a different cell → range-select (and stop caret text-select).\n e.preventDefault();\n const sel = window.getSelection(); if (sel) sel.removeAllRanges();\n selectRange(S.dragStart, td);\n };\n\n const onTablePointerUp = () => { if (S) S.dragStart = null; };\n\n // Detect if the pointer is near a cell's right (col) or bottom (row) edge.\n const edgeAt = (td, e) => {\n const r = td.getBoundingClientRect();\n if (Math.abs(e.clientX - r.right) <= 5) return 'col';\n if (Math.abs(e.clientY - r.bottom) <= 5) return 'row';\n return null;\n };\n\n const startResize = (kind, td, e) => {\n e.preventDefault();\n const rc = cellRect(S.table, td);\n const startX = e.clientX, startY = e.clientY;\n if (kind === 'col') {\n const cols = Array.from(S.table.querySelectorAll('colgroup > col'));\n // The boundary being dragged sits at the RIGHT of the cell's last spanned\n // column. Widen that column and shrink the NEXT one by the same amount so\n // the table's total width stays fixed (Canva-style boundary drag).\n const i = rc.c + ((td.colSpan || 1) - 1);\n const tableW = S.table.getBoundingClientRect().width || 1;\n const startWi = cols[i] ? cols[i].getBoundingClientRect().width : 80;\n const hasNext = i + 1 < cols.length;\n const startWn = hasNext ? cols[i + 1].getBoundingClientRect().width : 0;\n const MINW = 24;\n const move = (ev) => {\n let d = ev.clientX - startX;\n if (hasNext) {\n d = Math.max(-(startWi - MINW), Math.min(d, startWn - MINW));\n cols[i].style.width = `${((startWi + d) / tableW * 100).toFixed(3)}%`;\n cols[i + 1].style.width = `${((startWn - d) / tableW * 100).toFixed(3)}%`;\n } else if (cols[i]) {\n cols[i].style.width = `${(Math.max(MINW, startWi + d) / tableW * 100).toFixed(3)}%`;\n }\n };\n const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); emitChange(); };\n document.addEventListener('pointermove', move);\n document.addEventListener('pointerup', up);\n } else {\n const tr = td.parentElement;\n const startH = tr.getBoundingClientRect().height;\n const move = (ev) => { tr.style.height = `${Math.max(18, startH + (ev.clientY - startY))}px`; };\n const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); emitChange(); };\n document.addEventListener('pointermove', move);\n document.addEventListener('pointerup', up);\n }\n };\n\n const onCursorHint = (e) => {\n const td = e.target.closest && e.target.closest('td.cs-cell');\n if (!td) return;\n const edge = edgeAt(td, e);\n td.style.cursor = edge === 'col' ? 'col-resize' : edge === 'row' ? 'row-resize' : 'text';\n };\n\n // Engine switch: in legacy Froala mode the custom table engine stays off\n // (the static Table block is a new-mode-only feature). See canvas-config.js.\n const froalaMode = () => (typeof window.isFroalaEditor === 'function') && window.isFroalaEditor();\n\n const activate = (block) => {\n if (froalaMode()) return;\n if (S && S.block === block) return;\n if (S) deactivate();\n const table = block.querySelector('table.cs-table');\n if (!table) return;\n S = { block, table, selected: new Set(), activeCell: null, anchorCell: null, dragStart: null };\n S.toolbar = buildToolbar();\n wireCells();\n\n S._pd = onTablePointerDown; S._pm = onTablePointerMove; S._pu = onTablePointerUp; S._mm = onCursorHint;\n table.addEventListener('pointerdown', S._pd);\n table.addEventListener('pointermove', S._pm);\n document.addEventListener('pointerup', S._pu);\n table.addEventListener('mousemove', S._mm);\n table.addEventListener('keydown', onTableKey);\n table.addEventListener('paste', onPaste);\n\n // Deselect when the user presses outside the table (and not on our toolbar\n // / context menu), or hits Escape. Belt-and-suspenders over inline-editor.\n S._down = (e) => {\n if (!S) return;\n const t = e.target;\n if (t.closest && (t.closest('.cre-toolbar') || t.closest('.cs-tbl-menu'))) return;\n if (t.closest && t.closest('.cs_block_s') === block) return;\n hideContextMenu();\n try { window.EditorManager?.clearAll?.(); } catch (err) { /* */ }\n deactivate();\n };\n S._key = (e) => {\n if (e.key !== 'Escape') return;\n hideContextMenu();\n // First Escape clears a multi-cell selection; second exits the table.\n if (S.selected.size) { clearSelection(); return; }\n try { window.EditorManager?.clearAll?.(); } catch (err) { /* */ }\n deactivate();\n };\n document.addEventListener('pointerdown', S._down, true);\n document.addEventListener('keydown', S._key, true);\n\n S._reflow = () => { positionToolbar(); updateOverlay(); };\n window.addEventListener('scroll', S._reflow, true);\n window.addEventListener('resize', S._reflow);\n\n // Single bar at the top in docked mode: hide the rich-text placeholder while\n // our bar is up, and re-place ours if docked mode is toggled mid-edit.\n window.CustomRichEditor?.setExternalDockedActive?.(true);\n S._mode = () => positionToolbar();\n document.addEventListener('canvas:rich-toolbar-mode', S._mode);\n\n positionToolbar();\n };\n\n const deactivate = () => {\n if (!S) return;\n const { table, toolbar } = S;\n table.removeEventListener('pointerdown', S._pd);\n table.removeEventListener('pointermove', S._pm);\n document.removeEventListener('pointerup', S._pu);\n table.removeEventListener('mousemove', S._mm);\n table.removeEventListener('keydown', onTableKey);\n table.removeEventListener('paste', onPaste);\n window.removeEventListener('scroll', S._reflow, true);\n window.removeEventListener('resize', S._reflow);\n if (S._mode) document.removeEventListener('canvas:rich-toolbar-mode', S._mode);\n if (toolbar) window.CustomRichEditor?.untrackDockedBar?.(toolbar);\n window.CustomRichEditor?.setExternalDockedActive?.(false);\n document.removeEventListener('pointerdown', S._down, true);\n document.removeEventListener('keydown', S._key, true);\n hideContextMenu();\n unwireCells(table);\n if (toolbar) toolbar.remove();\n if (S.overlay) S.overlay.remove();\n S = null;\n };\n\n /* --------------------------------- init ---------------------------------- */\n\n const init = () => {\n const surface = document.querySelector('.custom-form-design') || document.body;\n const obs = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName !== 'class') continue;\n const el = m.target;\n if (!el.classList || el.dataset.blockType !== 'table') continue;\n if (el.classList.contains('cs-editing')) activate(el);\n else if (S && S.block === el) deactivate();\n }\n });\n obs.observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n\n // Right-click anywhere on a table block → our context menu (table only).\n document.addEventListener('contextmenu', (e) => {\n if (froalaMode()) return;\n const block = e.target.closest && e.target.closest('.cs_block_s[data-block-type=\"table\"]');\n if (!block) { hideContextMenu(); return; }\n e.preventDefault();\n if (!S || S.block !== block) {\n try { window.EditorManager?.select?.(block); } catch (err) { /* */ }\n activate(block);\n }\n const td = e.target.closest('td.cs-cell');\n if (td) S.activeCell = td;\n showContextMenu(e.clientX, e.clientY);\n });\n };\n\n Object.assign(window.TableBlock, { createBlock, activate, deactivate, normalizeCells });\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/synclist.js\">\n/**\n * @fileoverview \"List\" block — synchronised parallel columns of free-floating\n * blocks.\n *\n * Three selectable tiers:\n * List (.cs-synclist-block) — the outer block.\n * Container (.cs-synclist__col) — each column; selectable + resizable, and a\n * FREE canvas (like a Flexible block): the\n * blocks inside are absolutely positioned and\n * drag/resize freely, bounded to the column.\n * Block (.cs-synclist__col > .cs_block_s) — the actual content.\n *\n * Cross-column sync: every block belongs to a GROUP (dataset.slGroup) shared by\n * the matching block in each column. Add / delete / duplicate act on the whole\n * group (one block per column); move / resize on one block are mirrored live to\n * its group siblings (so the columns stay identical), while each block's text /\n * image / table CONTENT is edited individually.\n *\n * Only a fixed set of block types may be added (see ALLOWED). Add and column\n * controls live on a floating toolbar (in <body>, like the Table block).\n *\n * Exposes: window.SyncList.createBlock(cols), .handlePaste(anchor, newBlock)\n */\n(function () {\n window.SyncList = window.SyncList || {};\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2));\n const FC = () => window.FlowCanvas || {};\n\n // Auto-height: the shared column height grows so the tallest block in any\n // column always clears the bottom by BOTTOM_GAP. DEFAULT_COL_H matches the\n // CSS `--col-item-h` fallback and acts as the minimum height.\n const BOTTOM_GAP = 20;\n const DEFAULT_COL_H = 240;\n\n const ALLOWED = [\n { type: 'heading', label: 'Heading' },\n { type: 'body-text', label: 'Body Text' },\n { type: 'image', label: 'Image' },\n { type: 'video', label: 'Video' },\n { type: 'table', label: 'Table' },\n { type: 'button', label: 'Button' },\n { type: 'label-tag', label: 'Label / Tag' },\n { type: 'spacer', label: 'Spacer' },\n { type: 'divider', label: 'Divider' },\n ];\n\n /* ------------------------------ DOM helpers ------------------------------ */\n\n const gridOf = (block) => block.querySelector(':scope > .cs-synclist');\n const colEls = (grid) => Array.from(grid.querySelectorAll(':scope > .cs-synclist__col'));\n const contentBlocks = (root) => Array.from(root.querySelectorAll('.cs-synclist__col > .cs_block_s'));\n\n // A column is itself a `.cs_block_s` so the inline-editor selects it as its\n // own \"Container\", and a position:relative free canvas for its blocks.\n const makeCol = () => {\n const col = document.createElement('div');\n col.className = 'cs_block_s cs-synclist__col';\n col.setAttribute('data', 'Container');\n col.setAttribute('custom-name', 'Container');\n col.dataset.blockType = 'sync-list-col';\n col.id = `block_${hash()}`;\n return col;\n };\n\n // A free-floating content block: absolutely positioned + csInSection so the\n // inline-editor's free drag/resize kicks in, tagged with its sync group.\n const makeBlock = (type, group, left, top) => {\n const inner = FC().createBlock?.(type);\n if (!inner) return null;\n FC().normalizeForFlow?.(inner); // strip the factory's absolute placement\n inner.dataset.csInSection = '1';\n inner.dataset.slGroup = group;\n inner.style.position = 'absolute';\n inner.style.left = `${left}px`;\n inner.style.top = `${top}px`;\n return inner;\n };\n\n // No divider element — columns sit side by side (separated by CSS) and are\n // resized with the Container's own handles. This only strips any stale\n // dividers left over from older documents.\n const dropDividers = (grid) => {\n grid.querySelectorAll(':scope > .cs-synclist__col-divider').forEach((d) => d.remove());\n };\n\n // Strip any stale inline sizing off the columns; the grid template drives\n // their size uniformly (the grid keeps its --col-item-w/--col-item-h vars).\n const resetColWidths = (grid) => colEls(grid).forEach((c) => {\n c.style.width = ''; c.style.height = ''; c.style.maxWidth = ''; c.style.flex = '';\n });\n\n const createBlock = (cols = 3) => {\n const block = document.createElement('div');\n block.className = 'cs_block_s cs-synclist-block';\n block.setAttribute('data', 'List');\n block.setAttribute('custom-name', 'List');\n block.dataset.blockType = 'sync-list';\n block.id = `block_${hash()}`;\n\n const grid = document.createElement('div');\n grid.className = 'cs-synclist';\n grid.id = `dynamic_${hash()}`;\n\n const group = hash();\n for (let c = 0; c < cols; c++) {\n const col = makeCol();\n const blk = makeBlock('heading', group, 0, 0);\n if (blk) col.appendChild(blk);\n grid.appendChild(col);\n }\n dropDividers(grid);\n block.appendChild(grid);\n return block;\n };\n\n /* --------------------------- clone / id helpers -------------------------- */\n\n const regenIds = (root) => {\n const bump = (el) => {\n if (!el.id) return;\n const us = el.id.lastIndexOf('_');\n el.id = `${us > -1 ? el.id.slice(0, us) : el.id}_${hash()}`;\n };\n bump(root);\n root.querySelectorAll('[id]').forEach(bump);\n };\n\n // Strip chrome / selection + re-id a cloned block (keeps dataset.slGroup,\n // csInSection and inline position so the copy stays a valid free block).\n const cleanInner = (inner) => {\n inner.querySelectorAll('[data-cs-chrome], .cs-block-grip, .cs-block-badge, .cs-resize-handle').forEach((e) => e.remove());\n inner.classList.remove('cs-selected', 'cs-editing');\n inner.querySelectorAll('.cs-selected, .cs-editing').forEach((e) => e.classList.remove('cs-selected', 'cs-editing'));\n regenIds(inner);\n };\n\n /* ----------------------------- list contexts ----------------------------- */\n\n // Context for a CONTENT block (not the column / list itself).\n const ctxFromBlock = (block) => {\n if (!block || !block.classList || !block.classList.contains('cs_block_s')) return null;\n if (block.classList.contains('cs-synclist__col') || block.classList.contains('cs-synclist-block')) return null;\n const col = block.closest('.cs-synclist__col');\n if (!col) return null;\n const list = col.closest('.cs-synclist-block');\n const grid = list && gridOf(list);\n if (!grid) return null;\n return { block, list, grid, col, colIndex: colEls(grid).indexOf(col), group: block.dataset.slGroup };\n };\n const isInList = (block) => !!ctxFromBlock(block);\n\n // Context when the block IS a column (the \"Container\" tier).\n const colCtx = (block) => {\n if (!block?.classList?.contains('cs-synclist__col')) return null;\n const list = block.closest('.cs-synclist-block');\n const grid = list && gridOf(list);\n if (!grid) return null;\n return { list, grid, index: colEls(grid).indexOf(block) };\n };\n\n const groupMembers = (list, group) => contentBlocks(list).filter((b) => b.dataset.slGroup === group);\n\n /* --------------------------- cross-column sync --------------------------- */\n\n // Keep a block inside its column: never wider/taller than the column, never\n // positioned past its edges. This stops a block spilling into the next column\n // and caps resize at the container's width/height. Idempotent (only writes\n // when a value changes) so the style observer that calls it can't loop.\n const clampToCol = (block) => {\n const col = block.closest('.cs-synclist__col');\n if (!col) return;\n const cw = col.clientWidth;\n if (!cw) return;\n const set = (p, v) => { if (block.style[p] !== v) block.style[p] = v; };\n let w = block.offsetWidth;\n // Horizontal only: keep the block from spilling sideways. Vertically the\n // block is free to grow taller than the column — autoSizeList() then grows\n // the (shared) column height to fit it (text overflow → taller container),\n // so we deliberately don't cap height or bottom here anymore.\n if (w > cw) { set('width', `${cw}px`); w = cw; }\n let left = parseFloat(block.style.left) || 0;\n let top = parseFloat(block.style.top) || 0;\n if (left < 0) { left = 0; set('left', '0px'); }\n if (top < 0) { top = 0; set('top', '0px'); }\n if (left + w > cw) set('left', `${Math.max(0, cw - w)}px`);\n };\n\n // Grow the List's shared column height so the lowest block bottom across ALL\n // columns clears the bottom edge by BOTTOM_GAP. Columns share --col-item-h, so\n // a tall block in one column lifts every column to the same height (and the\n // 20px gap is preserved below the tallest block). A manual height-resize is\n // remembered on the grid (dataset.slFloorH) and used as the minimum so the\n // user can still make a List taller than its content. Only writes when the\n // value changes, so the ResizeObserver that calls it can't loop.\n const autoSizeList = (list) => {\n if (!list) return;\n const grid = gridOf(list);\n if (!grid) return;\n let maxBottom = 0;\n contentBlocks(list).forEach((b) => {\n const bottom = b.offsetTop + b.offsetHeight;\n if (bottom > maxBottom) maxBottom = bottom;\n });\n // Minimum height = the user's manual resize if there is one, else the\n // default. Content can always push beyond it, but never gets clipped.\n const manual = parseFloat(grid.dataset.slFloorH);\n const floor = Number.isFinite(manual) ? manual : DEFAULT_COL_H;\n const needed = Math.max(floor, Math.ceil(maxBottom + BOTTOM_GAP));\n const cur = parseFloat(grid.style.getPropertyValue('--col-item-h')) || DEFAULT_COL_H;\n if (needed !== cur) grid.style.setProperty('--col-item-h', `${needed}px`);\n };\n\n // Copy a block's geometry (position + size) onto its group siblings in the\n // other columns. Only writes when a value actually differs, so the style\n // MutationObserver that calls this can't loop.\n const mirrorGeometry = (block) => {\n const group = block.dataset.slGroup;\n const list = block.closest('.cs-synclist-block');\n if (!group || !list) return;\n const vals = { left: block.style.left, top: block.style.top, width: block.style.width, height: block.style.height };\n groupMembers(list, group).forEach((sib) => {\n if (sib === block) return;\n Object.keys(vals).forEach((p) => { if (sib.style[p] !== vals[p]) sib.style[p] = vals[p]; });\n });\n };\n\n const afterChange = () => { try { window.generate?.(); } catch (e) { /* */ } };\n\n // Re-assert selection on the List after a structural change (the inline-editor\n // observer tears down selection when new blocks appear).\n const finishStructural = (list) => {\n afterChange();\n if (!list || !list.isConnected) return;\n requestAnimationFrame(() => {\n try { window.EditorManager?.select?.(list); } catch (e) { /* */ }\n activate(list);\n autoSizeList(list);\n });\n };\n\n /* ----------------------------- group operations -------------------------- */\n\n // New block in every column (a new synced group) at a given position.\n const addBlockAt = (list, type, left, top) => {\n const group = hash();\n colEls(gridOf(list)).forEach((col) => {\n const blk = makeBlock(type, group, left, top);\n if (blk) col.appendChild(blk);\n });\n finishStructural(list);\n };\n\n /* ----------------------- reusable component drops ------------------------ */\n // A List holds only single content blocks (free-positioned, synced across\n // columns). A saved component that is itself a group/section/list/flexible\n // container must NOT be injected into a column — reject those and let the\n // canvas drop handler place them in page flow instead.\n const isSimpleComponentHtml = (html) => {\n const tmp = document.createElement('div');\n tmp.innerHTML = html || '';\n const root = tmp.firstElementChild;\n return !!root && !root.matches(\n '.cs-synclist-block, .cs-synclist__col, .cs-group-block, .cs-flexible-block, [data-block-type=\"section-container\"]'\n );\n };\n\n // Build a component instance for one column, with the same synced free-block\n // treatment as makeBlock(). buildComponentBlock regenerates ids on each call,\n // so calling it per column keeps every column's copy independent.\n const makeComponentBlock = (html, group, left, top) => {\n const inner = FC().buildComponentBlock?.(html);\n if (!inner) return null;\n FC().normalizeForFlow?.(inner); // strip any baked-in absolute placement\n inner.dataset.csInSection = '1';\n inner.dataset.slGroup = group;\n inner.style.position = 'absolute';\n inner.style.left = `${left}px`;\n inner.style.top = `${top}px`;\n return inner;\n };\n\n // A reusable component as a new synced group, cloned across every column.\n const addComponentAt = (list, html, left, top) => {\n const group = hash();\n colEls(gridOf(list)).forEach((col) => {\n const blk = makeComponentBlock(html, group, left, top);\n if (blk) col.appendChild(blk);\n });\n finishStructural(list);\n };\n\n // Toolbar \"+ Add block\" — stagger new groups below the last.\n const addRow = (list, type) => {\n const groupCount = new Set(contentBlocks(list).map((b) => b.dataset.slGroup)).size;\n addBlockAt(list, type, 8, 8 + groupCount * 64);\n };\n\n const deleteGroup = (block) => {\n const ctx = ctxFromBlock(block);\n if (!ctx) return;\n groupMembers(ctx.list, ctx.group).forEach((b) => b.remove());\n finishStructural(ctx.list);\n };\n\n const duplicateGroup = (block) => {\n const ctx = ctxFromBlock(block);\n if (!ctx) return null;\n const { list, grid, group } = ctx;\n const cols = colEls(grid);\n const newGroup = hash();\n const byCol = new Map();\n groupMembers(list, group).forEach((b) => byCol.set(b.closest('.cs-synclist__col'), b));\n cols.forEach((col) => {\n const src = byCol.get(col);\n if (!src) return;\n const clone = src.cloneNode(true);\n cleanInner(clone);\n clone.dataset.csInSection = '1';\n clone.dataset.slGroup = newGroup;\n clone.style.position = 'absolute';\n clone.style.left = `${(parseFloat(src.style.left) || 0) + 20}px`;\n clone.style.top = `${(parseFloat(src.style.top) || 0) + 20}px`;\n col.appendChild(clone);\n });\n finishStructural(list);\n return null;\n };\n\n // Badge ▲/▼ on a content block nudges it up/down; the style observer mirrors.\n const nudgeGroup = (block, dir) => {\n const ctx = ctxFromBlock(block);\n if (!ctx) return false;\n const top = Math.max(0, (parseFloat(block.style.top) || 0) + (dir === 'up' ? -20 : 20));\n block.style.top = `${top}px`;\n afterChange();\n return true;\n };\n\n // Paste → a new group offset from the anchor (into the anchor's column +\n // clones in the others).\n const handlePaste = (anchor, newBlock) => {\n const ctx = ctxFromBlock(anchor);\n if (!ctx || !newBlock) return null;\n const { list, grid, colIndex } = ctx;\n const cols = colEls(grid);\n const group = hash();\n const left = (parseFloat(anchor.style.left) || 0) + 20;\n const top = (parseFloat(anchor.style.top) || 0) + 20;\n let placed = null;\n cols.forEach((col, ci) => {\n const blk = (ci === colIndex) ? newBlock : newBlock.cloneNode(true);\n if (ci !== colIndex) cleanInner(blk);\n blk.dataset.csInSection = '1';\n blk.dataset.slGroup = group;\n blk.style.position = 'absolute';\n blk.style.left = `${left}px`;\n blk.style.top = `${top}px`;\n col.appendChild(blk);\n if (ci === colIndex) placed = blk;\n });\n finishStructural(list);\n return placed;\n };\n\n /* ---------------------------- column operations -------------------------- */\n\n const addColumn = (block) => {\n const grid = gridOf(block);\n const cols = colEls(grid);\n const src = cols[cols.length - 1];\n const newCol = makeCol();\n // Clone the last column's blocks, KEEPING each block's slGroup so the new\n // column joins the existing groups (regenIds only re-ids elements).\n (src ? Array.from(src.querySelectorAll(':scope > .cs_block_s')) : []).forEach((b) => {\n const clone = b.cloneNode(true);\n cleanInner(clone);\n clone.dataset.csInSection = '1';\n newCol.appendChild(clone);\n });\n if (!newCol.querySelector(':scope > .cs_block_s')) {\n const blk = makeBlock('heading', hash(), 8, 8);\n if (blk) newCol.appendChild(blk);\n }\n grid.appendChild(newCol);\n dropDividers(grid);\n resetColWidths(grid);\n finishStructural(block);\n };\n\n const deleteColumn = (block, colIndex) => {\n const grid = gridOf(block);\n const cols = colEls(grid);\n if (cols.length <= 1) return;\n const target = (colIndex == null || colIndex < 0 || colIndex >= cols.length) ? cols.length - 1 : colIndex;\n cols[target].remove();\n dropDividers(grid);\n resetColWidths(grid);\n finishStructural(block);\n };\n\n const cleanColumnClone = (col) => {\n col.classList.remove('cs-selected', 'cs-editing');\n // Clear inline sizing so the grid's uniform var rule (or default flex) drives it.\n col.style.width = ''; col.style.height = ''; col.style.maxWidth = ''; col.style.flex = '';\n col.querySelectorAll('[data-cs-chrome], .cs-block-grip, .cs-block-badge, .cs-resize-handle').forEach((e) => e.remove());\n col.querySelectorAll('.cs-selected, .cs-editing').forEach((e) => e.classList.remove('cs-selected', 'cs-editing'));\n regenIds(col);\n };\n\n // Copy/paste of a whole Container: append it as a NEW column. Its child blocks\n // keep their slGroup, so they join the existing sync groups (drag / resize /\n // delete then apply across this new column too).\n const handleColumnPaste = (anchorCol, newCol) => {\n const list = anchorCol?.closest?.('.cs-synclist-block');\n const grid = list && gridOf(list);\n if (!grid || !newCol) return null;\n newCol.classList.add('cs_block_s', 'cs-synclist__col');\n newCol.setAttribute('data', 'Container');\n newCol.setAttribute('custom-name', 'Container');\n newCol.dataset.blockType = 'sync-list-col';\n cleanColumnClone(newCol);\n // Keep children absolute + csInSection (and their slGroup) so they sync.\n Array.from(newCol.querySelectorAll(':scope > .cs_block_s')).forEach((b) => {\n b.dataset.csInSection = '1';\n if (!b.style.position) b.style.position = 'absolute';\n });\n grid.appendChild(newCol);\n resetColWidths(grid);\n finishStructural(list);\n return newCol;\n };\n\n const duplicateColumn = (block, index) => {\n const grid = gridOf(block);\n const src = colEls(grid)[index];\n if (!src) return null;\n const clone = src.cloneNode(true);\n cleanColumnClone(clone);\n src.after(clone);\n dropDividers(grid);\n resetColWidths(grid);\n finishStructural(block);\n return null;\n };\n\n const moveColumn = (block, index, dir) => {\n const grid = gridOf(block);\n const cols = colEls(grid);\n const to = dir === 'up' ? index - 1 : index + 1;\n if (to < 0 || to >= cols.length) return false;\n if (dir === 'up') cols[to].before(cols[index]); else cols[to].after(cols[index]);\n dropDividers(grid);\n finishStructural(block);\n return true;\n };\n\n /* ------------------------------ floating UI ------------------------------ */\n\n let active = null;\n let menuEl = null;\n\n const closeMenu = () => { if (menuEl) { menuEl.remove(); menuEl = null; } };\n\n const openAddMenu = (btn, list) => {\n closeMenu();\n const m = document.createElement('div');\n m.className = 'cs-tbl-menu cs-synclist-menu';\n m.setAttribute('data-cs-chrome', '');\n ALLOWED.forEach((a) => {\n const b = document.createElement('button');\n b.type = 'button';\n b.className = 'cs-tbl-menu__item';\n b.dataset.type = a.type;\n b.textContent = a.label;\n m.appendChild(b);\n });\n m.addEventListener('mousedown', (e) => e.preventDefault());\n m.addEventListener('click', (e) => {\n const t = e.target.closest('[data-type]')?.dataset.type;\n if (!t) return;\n addRow(list, t);\n closeMenu();\n });\n document.body.appendChild(m);\n const r = btn.getBoundingClientRect();\n let left = r.left;\n if (left + m.offsetWidth > window.innerWidth - 8) left = window.innerWidth - m.offsetWidth - 8;\n m.style.left = `${Math.max(8, left)}px`;\n m.style.top = `${r.bottom + 4}px`;\n menuEl = m;\n };\n\n const buildToolbar = (list) => {\n const tb = document.createElement('div');\n tb.className = 'cs-tbl-toolbar cs-synclist-toolbar';\n tb.setAttribute('data-cs-chrome', '');\n tb.innerHTML = `\n <div class=\"cs-tbl-group\">\n <button data-sl=\"add-row\" title=\"Add a block to every column\">+ Add block</button>\n </div>\n <div class=\"cs-tbl-group\">\n <button data-sl=\"add-col\" title=\"Add a column\">+ Column</button>\n <button data-sl=\"del-col\" title=\"Remove last column\">- Column</button>\n </div>`;\n tb.addEventListener('mousedown', (e) => { if (!e.target.closest('input')) e.preventDefault(); });\n tb.addEventListener('click', (e) => {\n const op = e.target.closest('[data-sl]')?.dataset.sl;\n if (!op) return;\n e.preventDefault();\n if (op === 'add-row') return openAddMenu(e.target.closest('button'), list);\n if (op === 'add-col') return addColumn(list);\n if (op === 'del-col') return deleteColumn(list, null);\n });\n document.body.appendChild(tb);\n return tb;\n };\n\n const positionToolbar = () => {\n if (!active) return;\n const r = active.block.getBoundingClientRect();\n const tb = active.toolbar;\n let top = r.top - tb.offsetHeight - 8;\n if (top < 8) top = r.bottom + 8;\n // Anchor the toolbar's RIGHT edge to the list's right edge (width-independent,\n // so it stays put even as the toolbar's own width settles / fonts load).\n // Use clientWidth (excludes the scrollbar) so `right` lines up with the\n // list's right edge from getBoundingClientRect (which also excludes it).\n const vw = document.documentElement.clientWidth || window.innerWidth;\n tb.style.left = 'auto';\n tb.style.right = `${Math.max(8, Math.round(vw - r.right))}px`;\n tb.style.top = `${Math.max(8, top)}px`;\n };\n\n const activate = (list) => {\n if (active && active.block === list) { positionToolbar(); return; }\n if (active) deactivate();\n active = { block: list, toolbar: buildToolbar(list) };\n active._reflow = () => positionToolbar();\n window.addEventListener('scroll', active._reflow, true);\n window.addEventListener('resize', active._reflow);\n positionToolbar();\n // Re-read once layout has settled (the list's rect can shift right after\n // it's created/selected); right-anchoring makes this stable.\n requestAnimationFrame(() => positionToolbar());\n };\n\n const deactivate = () => {\n if (!active) return;\n closeMenu();\n window.removeEventListener('scroll', active._reflow, true);\n window.removeEventListener('resize', active._reflow);\n active.toolbar.remove();\n active = null;\n };\n\n const activeListFromSelection = () => {\n const sel = document.querySelector('.cs_block_s.cs-selected, .cs_block_s.cs-editing');\n if (!sel) return null;\n return sel.classList.contains('cs-synclist-block') ? sel : sel.closest('.cs-synclist-block');\n };\n\n let syncQueued = false;\n const syncActive = () => {\n if (syncQueued) return;\n syncQueued = true;\n requestAnimationFrame(() => {\n syncQueued = false;\n const list = activeListFromSelection();\n if (list) activate(list); else deactivate();\n });\n };\n\n /* -------------------------------- wiring --------------------------------- */\n\n const wrapOverrides = () => {\n const fc = window.FlowCanvas;\n if (!fc || fc._synclistWrapped) return;\n fc._synclistWrapped = true;\n\n const origDelete = fc.deleteBlock;\n window.SyncList._origDelete = origDelete;\n fc.deleteBlock = function (block) {\n const cc = colCtx(block);\n if (cc) return deleteColumn(cc.list, cc.index);\n if (isInList(block)) return deleteGroup(block);\n return origDelete ? origDelete.call(this, block) : undefined;\n };\n\n const origMove = fc.moveBlock;\n fc.moveBlock = function (block, dir) {\n const cc = colCtx(block);\n if (cc) return moveColumn(cc.list, cc.index, dir);\n if (isInList(block)) return nudgeGroup(block, dir);\n return origMove ? origMove.call(this, block, dir) : false;\n };\n\n const origDup = fc.duplicateBlock;\n fc.duplicateBlock = function (block) {\n const cc = colCtx(block);\n if (cc) return duplicateColumn(cc.list, cc.index);\n if (isInList(block)) return duplicateGroup(block);\n return origDup ? origDup.call(this, block) : null;\n };\n };\n\n // Typing into a text block changes its CONTENT (not its `style`), so the\n // style observer never sees it. A ResizeObserver watches each content block's\n // box instead: when text makes it grow/shrink we re-fit the List's height.\n const observedBlocks = new WeakSet();\n let ro = null;\n let roQueue = new Set();\n let roRaf = 0;\n const flushRo = () => {\n roRaf = 0;\n const lists = Array.from(roQueue);\n roQueue.clear();\n lists.forEach((l) => { if (l.isConnected) autoSizeList(l); });\n };\n const observeBlock = (b) => {\n if (!ro || observedBlocks.has(b)) return;\n observedBlocks.add(b);\n ro.observe(b);\n };\n const observeAll = (root) => contentBlocks(root).forEach(observeBlock);\n\n const init = () => {\n wrapOverrides();\n\n const surface = document.querySelector('.custom-form-design') || document.body;\n\n if (typeof ResizeObserver !== 'undefined') {\n ro = new ResizeObserver((entries) => {\n for (const e of entries) {\n const list = e.target.closest?.('.cs-synclist-block');\n if (list) roQueue.add(list);\n }\n if (!roRaf) roRaf = requestAnimationFrame(flushRo);\n });\n }\n\n // Observe existing content blocks and fit each list once on load.\n document.querySelectorAll('.cs-synclist-block').forEach((list) => {\n observeAll(list);\n autoSizeList(list);\n });\n\n // Observe content blocks added later (drop, paste, add-row, add-column …)\n // and re-fit the list they land in.\n new MutationObserver((muts) => {\n for (const m of muts) {\n m.addedNodes.forEach((n) => {\n if (n.nodeType !== 1) return;\n if (n.matches?.('.cs-synclist__col > .cs_block_s')) observeBlock(n);\n n.querySelectorAll?.('.cs-synclist__col > .cs_block_s').forEach(observeBlock);\n const list = n.closest?.('.cs-synclist-block') || n.querySelector?.('.cs-synclist-block');\n if (list) autoSizeList(list);\n });\n }\n }).observe(surface, { childList: true, subtree: true });\n\n // Toolbar visibility follows selection in/out of a List.\n new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === 'class' && m.target.classList?.contains('cs_block_s')) { syncActive(); return; }\n }\n }).observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n\n // Style changes drive two syncs:\n // - a column's inline width → flex-basis (so the resize sticks);\n // - a content block's position/size → mirrored to its group siblings.\n new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName !== 'style') continue;\n const el = m.target;\n if (!(el instanceof HTMLElement) || !el.classList.contains('cs_block_s')) continue;\n if (el.classList.contains('cs-synclist__col')) {\n // Resizing a column feeds the shared --col-item-w/--col-item-h, so EVERY\n // column takes that exact px size (smooth, 1:1 with the drag); width\n // adds .cs-synclist--sized which switches columns from \"fill the row\n // equally\" to fixed px + wrap. Clear inline sizing so the var rule wins.\n const grid = el.closest('.cs-synclist');\n if (grid && (el.style.width || el.style.height)) {\n if (el.style.width) {\n grid.style.setProperty('--col-item-w', el.style.width);\n grid.classList.add('cs-synclist--sized');\n }\n // A manual height-resize becomes the new minimum (floor) for the\n // auto-height; autoSizeList then enforces max(floor, content+gap).\n if (el.style.height) grid.dataset.slFloorH = String(parseFloat(el.style.height) || 0);\n colEls(grid).forEach((c) => { c.style.width = ''; c.style.height = ''; c.style.maxWidth = ''; c.style.flex = ''; });\n if (el.style.height) autoSizeList(grid.closest('.cs-synclist-block'));\n }\n continue;\n }\n if (el.classList.contains('cs-synclist-block')) continue;\n if (el.closest('.cs-synclist__col')) { clampToCol(el); mirrorGeometry(el); autoSizeList(el.closest('.cs-synclist-block')); }\n }\n }).observe(surface, { attributes: true, attributeFilter: ['style'], subtree: true });\n\n // Close the add menu on any outside press.\n document.addEventListener('pointerdown', (e) => {\n if (menuEl && !e.target.closest('.cs-synclist-menu') && !e.target.closest('.cs-synclist-toolbar')) closeMenu();\n }, true);\n\n // Persist after a free drag / resize ends.\n document.addEventListener('pointerup', () => { if (active) afterChange(); });\n\n // Drag a block from the sidebar INTO a Container → drop it as a new synced\n // group at the cursor position (cloned across columns). Capture phase so we\n // beat the canvas drop handler (which would place it in the page flow).\n const overCol = (e) => e.target.closest?.('.cs-synclist__col');\n // Remove every drop highlight (ours + the canvas's blue indicator line).\n const clearDropHighlight = () => {\n document.querySelectorAll('.cs-synclist__col--dropping').forEach((c) => c.classList.remove('cs-synclist__col--dropping'));\n window.FlowCanvas?.hideIndicator?.();\n };\n document.addEventListener('dragover', (e) => {\n const col = overCol(e);\n if (!col) return;\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';\n // Only the column under the cursor is highlighted.\n document.querySelectorAll('.cs-synclist__col--dropping').forEach((c) => { if (c !== col) c.classList.remove('cs-synclist__col--dropping'); });\n col.classList.add('cs-synclist__col--dropping');\n }, true);\n document.addEventListener('drop', (e) => {\n const col = overCol(e);\n if (!col) { clearDropHighlight(); return; }\n const payload = readDropPayload(e);\n const type = payload?.blockType;\n clearDropHighlight();\n\n // Reusable component → drop as a new synced group, but only when it's a\n // single content block. Groups/sections fall through WITHOUT stopping\n // propagation so the canvas drop handler lands them in page flow.\n if (type === 'component') {\n if (!payload.componentHtml || !isSimpleComponentHtml(payload.componentHtml)) return;\n e.preventDefault();\n e.stopPropagation();\n const list = col.closest('.cs-synclist-block');\n const r = col.getBoundingClientRect();\n addComponentAt(list, payload.componentHtml, Math.max(0, e.clientX - r.left - 20), Math.max(0, e.clientY - r.top - 10));\n return;\n }\n\n if (!type || !ALLOWED.some((a) => a.type === type)) return;\n e.preventDefault();\n e.stopPropagation();\n const list = col.closest('.cs-synclist-block');\n const r = col.getBoundingClientRect();\n addBlockAt(list, type, Math.max(0, e.clientX - r.left - 20), Math.max(0, e.clientY - r.top - 10));\n }, true);\n // If the drag is cancelled (Esc / dropped off-canvas), clear any highlight.\n document.addEventListener('dragend', clearDropHighlight, true);\n };\n\n // Read a sidebar drag payload (same sources flow-canvas.js uses).\n const readDropPayload = (e) => {\n const dt = e.dataTransfer;\n const parse = (s) => { try { return JSON.parse(s); } catch (err) { return null; } };\n const direct = (dt && (parse(dt.getData('application/x-brochure-block')) || parse(dt.getData('text/plain'))));\n if (direct?.blockType) return direct;\n try {\n const fb = window.parent?.['__BROCHURE_FLOW_DRAG__'];\n if (fb?.blockType) return fb;\n } catch (err) { /* cross-origin */ }\n return null;\n };\n\n Object.assign(window.SyncList, { createBlock, handlePaste, handleColumnPaste, activate, deactivate });\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/row-col-builder.js\">\n/**\n * @fileoverview Row / column DOM scaffolding and block placement.\n *\n * Exposes:\n * window.FlowCanvas.makeRow()\n * window.FlowCanvas.makeCol(flexGrow?)\n * window.FlowCanvas.makeDivider()\n * window.FlowCanvas.rebuildDividers(row) — re-inserts dividers between cols\n * window.FlowCanvas.resetColFlex(row) — equalize col widths\n * window.FlowCanvas.normalizeForFlow(block) — strip inline absolute styles\n * window.FlowCanvas.placeBlock(doc, block, target) — handles 'between-rows', 'col-edge', 'in-col'\n *\n * In-section placement (target.kind === 'in-section') lives in section-canvas.js.\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const generateHash = () => {\n if (typeof BlockCreator !== 'undefined') {\n const protoHash = BlockCreator.prototype?.generateHash;\n if (typeof protoHash === 'function') {\n return protoHash.call({});\n }\n }\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n return Math.random().toString(16).slice(2) + '-' + Math.random().toString(16).slice(2);\n };\n\n const assignNodeId = (el, type) => {\n if (!el || el.id) return el;\n el.id = `${type}_${generateHash()}`;\n return el;\n };\n\n const makeRow = () => {\n const row = document.createElement('div');\n row.className = 'row-item';\n return assignNodeId(row, 'row');\n };\n\n const makeCol = (flexGrow = 1) => {\n const col = document.createElement('div');\n col.className = 'col-item';\n col.style.flex = `${flexGrow} 1 0`;\n return assignNodeId(col, 'col');\n };\n\n const makeDivider = () => {\n const div = document.createElement('div');\n div.className = 'cs-line-divider';\n return div;\n };\n\n const rebuildDividers = (row) => {\n row.querySelectorAll(':scope > .cs-line-divider').forEach(d => d.remove());\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n for (let i = 0; i < cols.length - 1; i++) {\n cols[i].after(makeDivider());\n }\n };\n\n const resetColFlex = (row) => {\n row.querySelectorAll(':scope > .col-item').forEach(col => {\n col.style.flex = '1 1 0';\n });\n };\n\n const normalizeForFlow = (block) => {\n block.style.position = '';\n block.style.left = '';\n block.style.top = '';\n block.style.width = '';\n block.style.height = '';\n block.style.minHeight = '';\n block.style.maxWidth = '';\n block.style.minWidth = '';\n delete block.dataset.csInSection;\n };\n\n const syncFlexibleContentBounds = (block) => {\n if (!block) return;\n const isFlexibleBlock =\n block.dataset.blockType === 'flexible' ||\n block.classList.contains('cs-flexible-block');\n if (!isFlexibleBlock) return;\n\n const content = block.querySelector(':scope > .cs-flexible-content');\n if (!content) return;\n\n const floor = window.CanvasConfig?.flexible?.minHeight ?? 20;\n\n if (block.style.height) {\n // Manual resize: the inner content must match the exact height the user\n // dragged to. Read it straight from the block's inline height instead of\n // clientHeight (which can lag/diverge and leaves content taller than the\n // block — e.g. block 20px but content stuck at 30px).\n const h = Math.max(floor, Math.round(parseFloat(block.style.height) || 0));\n content.style.minHeight = `${h}px`;\n content.style.height = `${h}px`;\n } else {\n // Auto height: keep a visible floor, otherwise grow with content.\n const h = Math.max(floor, Math.round(block.clientHeight || block.getBoundingClientRect().height || 0));\n content.style.minHeight = `${h}px`;\n content.style.height = '';\n }\n };\n\n /**\n * Insert a block into the document tree at the specified target.\n *\n * @param {HTMLElement} doc - the .cs_margin container\n * @param {HTMLElement} block - block element to insert\n * @param {Object} target - { kind, ... } from drop-zone detection\n */\n const placeBlock = (doc, block, target, clientX, clientY, blockType) => {\n if (!target) return;\n\n // Preserve styles of nested flexible blocks during drop\n // This prevents nested flexible containers from losing their position/size\n const preservedFlexibleStyles = new Map();\n doc.querySelectorAll('.cs-flexible-content').forEach(flexContainer => {\n const wrapper = flexContainer.closest('.cs_block_s');\n if (wrapper) {\n preservedFlexibleStyles.set(wrapper, {\n position: wrapper.style.position,\n left: wrapper.style.left,\n top: wrapper.style.top,\n width: wrapper.style.width,\n height: wrapper.style.height,\n maxWidth: wrapper.style.maxWidth,\n minWidth: wrapper.style.minWidth,\n csInSection: wrapper.dataset.csInSection\n });\n }\n });\n\n\n if (/^predefine-template-\\d+$/.test(blockType) && $(target.parent).hasClass('cs_margin')) {\n $(target.parent).append(block);\n return;\n }\n\n\n if (target.kind === 'between-rows') {\n const parent = target.parent || doc;\n\n // Check if parent is a free canvas (flexible container OR a cover page) -\n // if so, use absolute positioning. A cover page (.cs_page[data-cs-cover])\n // hosts its blocks as absolutely-positioned DIRECT children, with no\n // flexible-content wrapper.\n const isFreeCanvasParent = parent &&\n (parent.classList.contains('cs-flexible-content') || parent.dataset?.csCover === '1');\n if (isFreeCanvasParent) {\n // Restrict certain block types from being placed in flexible containers.\n // Exception: a cover page is a free-move canvas where ALL block types\n // are allowed, so the restriction is bypassed when the flexible\n // container lives inside a `data-cs-cover` page.\n const inCoverPage = !!parent.closest('[data-cs-cover=\"1\"]');\n const RESTRICTED_TYPES = window.FormBlockRegistry?.restrictedInFlexibleTypes() ||\n ['section-container', 'table-repeater', 'list-repeater'];\n if (!inCoverPage && RESTRICTED_TYPES.includes(blockType)) {\n // Fallback: place in doc root instead\n normalizeForFlow(block);\n const row = makeRow();\n const col = makeCol();\n col.appendChild(block);\n row.appendChild(col);\n doc.appendChild(row);\n return;\n }\n\n block.dataset.csInSection = '1';\n block.style.position = 'absolute';\n\n // Check if this is an existing flexible block being moved (already has width/height)\n // If so, preserve its size but update position based on cursor\n const isExistingFlexibleBlock = block.dataset.csInSection === '1' &&\n (block.style.width || block.style.height);\n\n // Insert FIRST so a new block can be measured — offsetWidth/Height are 0\n // while detached, which made the centring + clamp wrong and let the\n // block hang off the page edge (worst at the right edge).\n if (target.beforeRow) {\n target.beforeRow.before(block);\n } else {\n parent.appendChild(block);\n }\n\n if (!isExistingFlexibleBlock) {\n // New block - drop it where the cursor is RELEASED: the cursor maps to\n // the block's top-left corner (not its centre, which pulled a wide\n // default block ~half-its-width to the left). Account for the parent's\n // border so the math matches the absolute-positioning origin (the\n // padding edge), then clamp so the whole block stays inside the page.\n const parentRect = parent.getBoundingClientRect();\n const cs = getComputedStyle(parent);\n const borderL = parseFloat(cs.borderLeftWidth) || 0;\n const borderT = parseFloat(cs.borderTopWidth) || 0;\n const bw = block.offsetWidth || 0;\n const bh = block.offsetHeight || 0;\n let left = clientX - parentRect.left - borderL;\n let top = clientY - parentRect.top - borderT;\n\n left = Math.max(0, Math.min(left, Math.max(0, parent.clientWidth - bw)));\n top = Math.max(0, Math.min(top, Math.max(0, parent.clientHeight - bh)));\n\n block.style.left = `${left}px`;\n block.style.top = `${top}px`;\n }\n syncFlexibleContentBounds(block);\n return;\n }\n\n normalizeForFlow(block);\n const row = makeRow();\n const col = makeCol();\n col.appendChild(block);\n row.appendChild(col);\n // The drop-zone may have decided this drop belongs inside a section's\n // content area instead of the doc root. `target.parent` carries that\n // scope when present.\n if (target.beforeRow) {\n target.beforeRow.before(row);\n } else {\n parent.appendChild(row);\n }\n syncFlexibleContentBounds(block);\n return;\n }\n\n if (target.kind === 'col-edge') {\n normalizeForFlow(block);\n const col = makeCol();\n col.appendChild(block);\n if (target.beforeCol) {\n target.beforeCol.before(col);\n } else {\n target.row.appendChild(col);\n }\n rebuildDividers(target.row);\n syncFlexibleContentBounds(block);\n return;\n }\n\n if (target.kind === 'in-col') {\n normalizeForFlow(block);\n if (target.beforeBlock) {\n target.beforeBlock.before(block);\n } else {\n target.col.appendChild(block);\n }\n }\n\n syncFlexibleContentBounds(block);\n\n // Restore preserved flexible block styles\n preservedFlexibleStyles.forEach((styles, wrapper) => {\n wrapper.style.position = styles.position;\n wrapper.style.left = styles.left;\n wrapper.style.top = styles.top;\n wrapper.style.width = styles.width;\n wrapper.style.height = styles.height;\n wrapper.style.maxWidth = styles.maxWidth;\n wrapper.style.minWidth = styles.minWidth;\n if (styles.csInSection) {\n wrapper.dataset.csInSection = styles.csInSection;\n }\n syncFlexibleContentBounds(wrapper);\n });\n };\n\n Object.assign(window.FlowCanvas, {\n generateHash,\n assignNodeId,\n makeRow,\n makeCol,\n makeDivider,\n rebuildDividers,\n resetColFlex,\n normalizeForFlow,\n syncFlexibleContentBounds,\n placeBlock,\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/group.js\">\n/**\n * @fileoverview Group / ungroup for free-move (cover page) blocks — model + UI.\n *\n * Scoped entirely to cover pages (`.cs_page[data-cs-cover=\"1\"]`) so normal flow\n * pages and the single-block selection in inline-editor.js are untouched.\n *\n * UI:\n * - Drag a rubber-band rectangle over empty cover-page area → every block it\n * touches becomes `.cs-multi-selected`; a floating \"Group\" button appears.\n * - Click a group → inline-editor selects the whole group (via the\n * `FlowCanvas.resolveSelectable` hook); a floating \"Ungroup\" button appears.\n * - Click again (drill in) → the inner child is selected; its \"Ungroup\" pops\n * just that child out.\n * - Ctrl+G groups the marquee selection, Ctrl+Shift+G ungroups.\n *\n * Model (on window.FlowCanvas):\n * groupBlocks(blocks) → group element (or null) — bundle 2+ free blocks\n * ungroupBlocks(group) — dissolve, all kids loose\n * ungroupOne(child) → child — pop one kid out of a group\n *\n * A \"group\" is a normal free-form `.cs_block_s.cs-group-block` (`position:absolute`,\n * `data-cs-in-section=\"1\"`) whose children are absolute blocks positioned relative\n * to the group box, so moving the group moves them together — reusing the existing\n * free-move machinery. Because a group is just a `.cs_block_s`, duplicate / delete /\n * export work on it as-is.\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const FC = window.FlowCanvas;\n const EM = () => window.EditorManager;\n\n const num = (v) => { const n = parseFloat(v); return Number.isNaN(n) ? 0 : n; };\n\n // Position/size of a free block relative to its positioned parent\n // (the cover page, or — for a child — the group box).\n const posOf = (block) => ({\n left: block.style.left ? num(block.style.left) : block.offsetLeft,\n top: block.style.top ? num(block.style.top) : block.offsetTop,\n width: block.offsetWidth,\n height: block.offsetHeight,\n });\n\n const coverOf = (el) => el?.closest?.('[data-cs-cover=\"1\"]') || null;\n const childBlocksOf = (group) =>\n Array.from(group.children).filter((c) => c.classList?.contains('cs_block_s'));\n const coverChildBlocks = (cover) =>\n Array.from(cover.children).filter((c) => c.classList?.contains('cs_block_s'));\n\n const markFree = (block) => {\n block.style.position = 'absolute';\n block.dataset.csInSection = '1';\n };\n\n /* ============================== MODEL ============================== */\n\n FC.groupBlocks = function (blocks) {\n blocks = (blocks || []).filter((b) => b && b.classList?.contains('cs_block_s'));\n if (blocks.length < 2) return null;\n const cover = coverOf(blocks[0]);\n if (!cover) return null;\n\n // Bounding box in cover-page coordinates.\n let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;\n const meta = blocks.map((b) => {\n const p = posOf(b);\n minL = Math.min(minL, p.left);\n minT = Math.min(minT, p.top);\n maxR = Math.max(maxR, p.left + p.width);\n maxB = Math.max(maxB, p.top + p.height);\n return { block: b, p };\n });\n\n const group = document.createElement('div');\n group.className = 'cs_block_s cs-group-block';\n group.dataset.blockType = 'group';\n group.dataset.csInSection = '1';\n group.setAttribute('data', 'Group');\n group.setAttribute('custom-name', 'Group');\n FC.assignNodeId?.(group, 'group');\n group.style.position = 'absolute';\n group.style.left = `${minL}px`;\n group.style.top = `${minT}px`;\n group.style.width = `${maxR - minL}px`;\n group.style.height = `${maxB - minT}px`;\n cover.appendChild(group);\n\n // Reparent children, repositioning relative to the group origin (DOM order\n // preserved by iterating the original list).\n meta.forEach(({ block, p }) => {\n markFree(block);\n block.style.left = `${p.left - minL}px`;\n block.style.top = `${p.top - minT}px`;\n block.classList.remove('cs-multi-selected', 'cs-selected', 'cs-editing');\n group.appendChild(block);\n });\n\n return group;\n };\n\n FC.ungroupBlocks = function (group) {\n if (!group || !group.classList?.contains('cs-group-block')) return;\n const cover = coverOf(group) || group.parentElement;\n if (!cover) return;\n const gp = posOf(group);\n childBlocksOf(group).forEach((child) => {\n const c = posOf(child);\n markFree(child);\n child.style.left = `${gp.left + c.left}px`;\n child.style.top = `${gp.top + c.top}px`;\n cover.appendChild(child);\n });\n group.remove();\n };\n\n // Recompute a group's box to tightly fit its remaining children, adjusting\n // child offsets so they keep their on-screen position.\n const refitGroup = (group) => {\n const kids = childBlocksOf(group);\n if (!kids.length) { group.remove(); return; }\n const gp = posOf(group);\n let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;\n const meta = kids.map((k) => {\n const p = posOf(k);\n minL = Math.min(minL, p.left);\n minT = Math.min(minT, p.top);\n maxR = Math.max(maxR, p.left + p.width);\n maxB = Math.max(maxB, p.top + p.height);\n return { k, p };\n });\n group.style.left = `${gp.left + minL}px`;\n group.style.top = `${gp.top + minT}px`;\n group.style.width = `${maxR - minL}px`;\n group.style.height = `${maxB - minT}px`;\n meta.forEach(({ k, p }) => {\n k.style.left = `${p.left - minL}px`;\n k.style.top = `${p.top - minT}px`;\n });\n };\n\n // Public: grow/shrink a group so its box always wraps every child (called\n // after a child is moved, resized, or pasted in).\n FC.refitGroupToChildren = (group) => {\n if (group && group.classList?.contains('cs-group-block')) refitGroup(group);\n };\n\n FC.ungroupOne = function (child) {\n if (!child) return null;\n const group = child.closest('.cs-group-block');\n if (!group) return null;\n const cover = coverOf(group) || group.parentElement;\n if (!cover) return null;\n\n const gp = posOf(group);\n const c = posOf(child);\n markFree(child);\n child.style.left = `${gp.left + c.left}px`;\n child.style.top = `${gp.top + c.top}px`;\n cover.appendChild(child);\n\n // A group of one is pointless — dissolve it (releasing the last child too).\n const remaining = childBlocksOf(group);\n if (remaining.length <= 1) {\n FC.ungroupBlocks(group);\n } else {\n refitGroup(group);\n }\n return child;\n };\n\n /* ============================== SELECTION + UI ============================== */\n\n /* ---- multi-select state ---- */\n const multi = new Set();\n const clearMulti = () => {\n multi.forEach((b) => b.classList.remove('cs-multi-selected'));\n multi.clear();\n hideToolbar();\n hideBounds();\n };\n const setMulti = (blocks) => {\n multi.forEach((b) => b.classList.remove('cs-multi-selected'));\n multi.clear();\n blocks.forEach((b) => { multi.add(b); b.classList.add('cs-multi-selected'); });\n };\n FC.getMultiSelection = () => [...multi];\n FC.clearMultiSelection = clearMulti;\n\n /* ---- floating toolbar ---- */\n let toolbar = null;\n const ensureToolbar = () => {\n if (toolbar) return toolbar;\n toolbar = document.createElement('div');\n toolbar.className = 'cs-group-toolbar';\n toolbar.setAttribute('data-cs-chrome', '');\n document.body.appendChild(toolbar);\n return toolbar;\n };\n const hideToolbar = () => { if (toolbar) toolbar.style.display = 'none'; };\n\n const placeToolbar = (anchorRect, html, below = false) => {\n const tb = ensureToolbar();\n tb.innerHTML = html;\n tb.style.display = 'inline-flex';\n tb.style.position = 'fixed';\n tb.style.zIndex = '10001';\n tb.style.left = `${Math.max(4, anchorRect.left)}px`;\n tb.style.top = below ? `${anchorRect.bottom + 6}px` : `${Math.max(4, anchorRect.top - 34)}px`;\n };\n\n const bboxOf = (els) => {\n let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;\n els.forEach((el) => {\n const q = el.getBoundingClientRect();\n l = Math.min(l, q.left); t = Math.min(t, q.top);\n r = Math.max(r, q.right); b = Math.max(b, q.bottom);\n });\n return { left: l, top: t, right: r, bottom: b };\n };\n\n /* ---- dotted bounding box around the whole multi-selection (group preview) ---- */\n let boundsEl = null;\n const hideBounds = () => { if (boundsEl) boundsEl.style.display = 'none'; };\n const showBounds = (els) => {\n if (!els || els.length < 2) { hideBounds(); return; }\n if (!boundsEl) {\n boundsEl = document.createElement('div');\n boundsEl.className = 'cs-group-bounds';\n boundsEl.setAttribute('data-cs-chrome', '');\n document.body.appendChild(boundsEl);\n }\n const r = bboxOf(els);\n boundsEl.style.display = 'block';\n boundsEl.style.position = 'fixed';\n boundsEl.style.zIndex = '9999';\n boundsEl.style.left = `${r.left}px`;\n boundsEl.style.top = `${r.top}px`;\n boundsEl.style.width = `${r.right - r.left}px`;\n boundsEl.style.height = `${r.bottom - r.top}px`;\n };\n\n /* ---- align / distribute the multi-selection ---- */\n const A_ICON = {\n left: '<svg viewBox=\"0 0 16 16\"><line x1=\"2\" y1=\"1.5\" x2=\"2\" y2=\"14.5\"/><rect x=\"3.5\" y=\"4\" width=\"9\" height=\"3\"/><rect x=\"3.5\" y=\"9\" width=\"6\" height=\"3\"/></svg>',\n cx: '<svg viewBox=\"0 0 16 16\"><line x1=\"8\" y1=\"1.5\" x2=\"8\" y2=\"14.5\"/><rect x=\"3\" y=\"4\" width=\"10\" height=\"3\"/><rect x=\"4.5\" y=\"9\" width=\"7\" height=\"3\"/></svg>',\n right: '<svg viewBox=\"0 0 16 16\"><line x1=\"14\" y1=\"1.5\" x2=\"14\" y2=\"14.5\"/><rect x=\"3.5\" y=\"4\" width=\"9\" height=\"3\"/><rect x=\"6.5\" y=\"9\" width=\"6\" height=\"3\"/></svg>',\n top: '<svg viewBox=\"0 0 16 16\"><line x1=\"1.5\" y1=\"2\" x2=\"14.5\" y2=\"2\"/><rect x=\"4\" y=\"3.5\" width=\"3\" height=\"9\"/><rect x=\"9\" y=\"3.5\" width=\"3\" height=\"6\"/></svg>',\n cy: '<svg viewBox=\"0 0 16 16\"><line x1=\"1.5\" y1=\"8\" x2=\"14.5\" y2=\"8\"/><rect x=\"4\" y=\"3\" width=\"3\" height=\"10\"/><rect x=\"9\" y=\"4.5\" width=\"3\" height=\"7\"/></svg>',\n bottom: '<svg viewBox=\"0 0 16 16\"><line x1=\"1.5\" y1=\"14\" x2=\"14.5\" y2=\"14\"/><rect x=\"4\" y=\"3.5\" width=\"3\" height=\"9\"/><rect x=\"9\" y=\"6.5\" width=\"3\" height=\"6\"/></svg>',\n distH: '<svg viewBox=\"0 0 16 16\"><rect x=\"1\" y=\"4\" width=\"2.5\" height=\"8\"/><rect x=\"6.75\" y=\"4\" width=\"2.5\" height=\"8\"/><rect x=\"12.5\" y=\"4\" width=\"2.5\" height=\"8\"/></svg>',\n distV: '<svg viewBox=\"0 0 16 16\"><rect x=\"4\" y=\"1\" width=\"8\" height=\"2.5\"/><rect x=\"4\" y=\"6.75\" width=\"8\" height=\"2.5\"/><rect x=\"4\" y=\"12.5\" width=\"8\" height=\"2.5\"/></svg>',\n };\n const aBtn = (action, ic, title) =>\n `<button type=\"button\" class=\"cs-group-toolbar__ico\" data-cs-group-action=\"${action}\" title=\"${title}\">${A_ICON[ic]}</button>`;\n\n // Align every selected block to the SELECTION's bounding box (free-move blocks\n // are absolutely positioned, so we set inline left/top — export-safe).\n const alignSelection = (cmd) => {\n const blocks = [...multi].filter((b) => b.offsetParent);\n if (blocks.length < 2) return;\n let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;\n blocks.forEach((b) => {\n minL = Math.min(minL, b.offsetLeft); minT = Math.min(minT, b.offsetTop);\n maxR = Math.max(maxR, b.offsetLeft + b.offsetWidth); maxB = Math.max(maxB, b.offsetTop + b.offsetHeight);\n });\n const cx = (minL + maxR) / 2, cy = (minT + maxB) / 2;\n blocks.forEach((b) => {\n const w = b.offsetWidth, h = b.offsetHeight;\n if (cmd === 'left') b.style.left = `${Math.round(minL)}px`;\n else if (cmd === 'cx') b.style.left = `${Math.round(cx - w / 2)}px`;\n else if (cmd === 'right') b.style.left = `${Math.round(maxR - w)}px`;\n else if (cmd === 'top') b.style.top = `${Math.round(minT)}px`;\n else if (cmd === 'cy') b.style.top = `${Math.round(cy - h / 2)}px`;\n else if (cmd === 'bottom') b.style.top = `${Math.round(maxB - h)}px`;\n });\n showGroupButton(); showBounds(blocks);\n };\n\n const distributeSelection = (axis) => {\n const blocks = [...multi].filter((b) => b.offsetParent);\n if (blocks.length < 3) return;\n const c = (b) => axis === 'h' ? (b.offsetLeft + b.offsetWidth / 2) : (b.offsetTop + b.offsetHeight / 2);\n blocks.sort((a, b) => c(a) - c(b));\n const c0 = c(blocks[0]), c1 = c(blocks[blocks.length - 1]), step = (c1 - c0) / (blocks.length - 1);\n blocks.forEach((b, i) => {\n if (i === 0 || i === blocks.length - 1) return;\n const target = c0 + step * i;\n if (axis === 'h') b.style.left = `${Math.round(target - b.offsetWidth / 2)}px`;\n else b.style.top = `${Math.round(target - b.offsetHeight / 2)}px`;\n });\n showGroupButton(); showBounds(blocks);\n };\n\n const showGroupButton = () => {\n const blocks = [...multi];\n if (blocks.length < 2) { hideToolbar(); return; }\n let html = aBtn('align-left', 'left', 'Align left') + aBtn('align-cx', 'cx', 'Align centre') + aBtn('align-right', 'right', 'Align right')\n + `<span class=\"cs-group-toolbar__sep\"></span>`\n + aBtn('align-top', 'top', 'Align top') + aBtn('align-cy', 'cy', 'Align middle') + aBtn('align-bottom', 'bottom', 'Align bottom');\n if (blocks.length >= 3) {\n html += `<span class=\"cs-group-toolbar__sep\"></span>` + aBtn('dist-h', 'distH', 'Distribute horizontally') + aBtn('dist-v', 'distV', 'Distribute vertically');\n }\n html += `<span class=\"cs-group-toolbar__sep\"></span>`\n + `<button type=\"button\" class=\"cs-group-toolbar__btn\" data-cs-group-action=\"group\">&#x29C9; Group</button>`;\n placeToolbar(bboxOf(blocks), html);\n };\n\n // Show \"Ungroup\" when a single group (or a child inside a group) is selected.\n const refreshUngroupButton = () => {\n if (multi.size >= 2) return; // group button wins\n const sel = EM()?.getSelected?.();\n if (!sel) { hideToolbar(); return; }\n const isGroup = sel.classList.contains('cs-group-block');\n const inGroup = !isGroup && sel.closest('.cs-group-block');\n if (!isGroup && !inGroup) { hideToolbar(); return; }\n placeToolbar(sel.getBoundingClientRect(),\n `<button type=\"button\" class=\"cs-group-toolbar__btn\" data-cs-group-action=\"ungroup\">&#x29C8; ${isGroup ? 'Ungroup' : 'Ungroup this'}</button>`,\n true);\n };\n\n const doGroup = () => {\n if (multi.size < 2) return;\n const group = FC.groupBlocks([...multi]);\n clearMulti();\n if (group) EM()?.select?.(group);\n };\n\n const doUngroup = () => {\n const sel = EM()?.getSelected?.();\n if (!sel) return;\n const isGroup = sel.classList.contains('cs-group-block');\n const inGroup = !isGroup && sel.closest('.cs-group-block');\n if (!isGroup && !inGroup) return;\n EM()?.clearAll?.();\n hideToolbar();\n if (isGroup) FC.ungroupBlocks(sel);\n else FC.ungroupOne(sel);\n };\n\n // Suppress the click that follows a multi-block drag so inline-editor doesn't\n // collapse the selection to a single block.\n let suppressClick = false;\n\n // Toolbar button clicks (capture phase, stop before inline-editor sees them).\n document.addEventListener('click', (e) => {\n if (suppressClick) { suppressClick = false; e.stopPropagation(); e.preventDefault(); return; }\n const btn = e.target.closest?.('[data-cs-group-action]');\n if (!btn) return;\n e.preventDefault();\n e.stopPropagation();\n const act = btn.dataset.csGroupAction;\n if (act === 'group') doGroup();\n else if (act === 'ungroup') doUngroup();\n else if (act.indexOf('align-') === 0) alignSelection(act.slice(6));\n else if (act.indexOf('dist-') === 0) distributeSelection(act.slice(5));\n }, true);\n\n /* ---- drag the whole selection (marquee multi-select OR a group) ----\n * Becomes an ACTIVE drag only after the pointer moves past a small threshold,\n * so a clean press-release still reaches inline-editor as a click (needed to\n * drill into / select an inner block). No pointer capture is used — the\n * trailing click target stays intact, which is what makes drill-in work. */\n let drag = null;\n\n const snapshot = (blocks) => blocks.map((b) => ({\n block: b,\n left: b.style.left ? num(b.style.left) : b.offsetLeft,\n top: b.style.top ? num(b.style.top) : b.offsetTop,\n }));\n\n const beginPending = (e, blocks, kind) => {\n drag = { startX: e.clientX, startY: e.clientY, active: false, kind, items: snapshot(blocks) };\n };\n\n const pointInBox = (x, y, blocks) => {\n if (!blocks.length) return false;\n const r = bboxOf(blocks);\n return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;\n };\n\n /* ---- marquee ---- */\n let band = null;\n let overlay = null;\n const drawOverlay = (x0, y0, x1, y1) => {\n if (!overlay) {\n overlay = document.createElement('div');\n overlay.className = 'cs-marquee';\n overlay.setAttribute('data-cs-chrome', '');\n document.body.appendChild(overlay);\n }\n overlay.style.display = 'block';\n overlay.style.position = 'fixed';\n overlay.style.zIndex = '10000';\n overlay.style.left = `${Math.min(x0, x1)}px`;\n overlay.style.top = `${Math.min(y0, y1)}px`;\n overlay.style.width = `${Math.abs(x1 - x0)}px`;\n overlay.style.height = `${Math.abs(y1 - y0)}px`;\n };\n\n const onPointerDown = (e) => {\n if (e.button !== 0) return;\n // Ignore our chrome — except the badge move handle, which should still be\n // able to drag a selected group (handled by case 2 below).\n if (e.target.closest('[data-cs-chrome]') && !e.target.closest('[data-cs-move]')) return;\n\n const hitBlock = e.target.closest('.cs_block_s');\n const sel = EM()?.getSelected?.();\n const group = sel && sel.classList.contains('cs-group-block') ? sel : null;\n const cover = e.target.closest('[data-cs-cover=\"1\"]');\n\n // (1) Drag a 2+ marquee selection — from any selected block OR from empty\n // space inside the selection's bounding box.\n if (multi.size >= 2) {\n const onSelBlock = hitBlock && [...multi].some((b) => b === hitBlock || b.contains(e.target));\n const lockedHit = hitBlock && hitBlock.closest('[data-cs-locked=\"1\"]');\n if (!lockedHit && (onSelBlock || (cover && pointInBox(e.clientX, e.clientY, [...multi])))) {\n beginPending(e, [...multi], 'multi'); // threshold drag; click still drills/collapses\n return;\n }\n }\n\n // (2) Drag a selected GROUP from anywhere inside it (a clean click drills in).\n // Locked groups aren't draggable (but stay clickable to drill in).\n if (group && group.dataset.csLocked !== '1' && (e.target === group || group.contains(e.target))) {\n beginPending(e, [group], 'group');\n return;\n }\n\n // (3) Pressed another block → inline-editor owns selection/move.\n if (hitBlock) { if (multi.size) clearMulti(); hideToolbar(); return; }\n\n // (4) Empty cover area → start a marquee.\n if (!cover) return;\n e.preventDefault();\n clearMulti();\n EM()?.clearAll?.();\n band = { cover, x0: e.clientX, y0: e.clientY };\n drawOverlay(e.clientX, e.clientY, e.clientX, e.clientY);\n };\n\n const onPointerMove = (e) => {\n if (drag) {\n const dx = e.clientX - drag.startX;\n const dy = e.clientY - drag.startY;\n if (!drag.active) {\n if (Math.abs(dx) <= 3 && Math.abs(dy) <= 3) return; // below threshold: still a click\n drag.active = true;\n hideToolbar();\n }\n e.preventDefault();\n window.getSelection?.()?.removeAllRanges?.(); // don't text-select while dragging\n drag.items.forEach(({ block, left, top }) => {\n block.style.left = `${left + dx}px`;\n block.style.top = `${top + dy}px`;\n });\n // Keep the selection box visible and following the drag (border stays on).\n if (drag.kind === 'multi') showBounds(drag.items.map((i) => i.block));\n return;\n }\n if (!band) return;\n drawOverlay(band.x0, band.y0, e.clientX, e.clientY);\n const box = {\n left: Math.min(band.x0, e.clientX), top: Math.min(band.y0, e.clientY),\n right: Math.max(band.x0, e.clientX), bottom: Math.max(band.y0, e.clientY),\n };\n const hits = coverChildBlocks(band.cover).filter((c) => {\n const r = c.getBoundingClientRect();\n return !(r.right < box.left || r.left > box.right || r.bottom < box.top || r.top > box.bottom);\n });\n setMulti(hits);\n };\n\n const onPointerUp = () => {\n if (drag) {\n const { active, kind, items } = drag;\n drag = null;\n if (active) {\n // A real drag — keep the selection; swallow the trailing click so\n // inline-editor doesn't collapse/re-select.\n suppressClick = true;\n if (kind === 'multi') { showBounds(items.map((i) => i.block)); showGroupButton(); }\n else requestAnimationFrame(refreshUngroupButton); // group moved → reposition Ungroup\n } else if (kind === 'multi') {\n // Plain click on a selected block → collapse to single-select; the\n // trailing click lands on inline-editor as usual.\n clearMulti();\n }\n // kind 'group' + no drag → do nothing; the trailing click drills into a child.\n return;\n }\n if (band) {\n band = null;\n if (overlay) overlay.style.display = 'none';\n // Only now (on release) draw the dotted bounding box + Group button.\n showBounds([...multi]);\n showGroupButton();\n return;\n }\n requestAnimationFrame(refreshUngroupButton); // selection may have changed via a block click\n };\n\n const onKeydown = (e) => {\n // Multi-select delete: Delete/Backspace removes every marquee-selected block.\n if ((e.key === 'Delete' || e.key === 'Backspace') && multi.size >= 1) {\n const ae = document.activeElement;\n if (ae && (ae.isContentEditable || ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return;\n e.preventDefault();\n const blocks = [...multi];\n clearMulti();\n const del = window.FlowCanvas?.deleteBlock || ((b) => b.remove());\n blocks.forEach((b) => del(b));\n return;\n }\n\n const g = (e.ctrlKey || e.metaKey) && (e.key === 'g' || e.key === 'G');\n if (!g) return;\n if (e.shiftKey) {\n const sel = EM()?.getSelected?.();\n if (sel && (sel.classList.contains('cs-group-block') || sel.closest('.cs-group-block'))) {\n e.preventDefault();\n doUngroup();\n }\n } else if (multi.size >= 2) {\n e.preventDefault();\n doGroup();\n }\n };\n\n const init = () => {\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('pointermove', onPointerMove, true);\n document.addEventListener('pointerup', onPointerUp, true);\n document.addEventListener('pointercancel', onPointerUp, true);\n document.addEventListener('keydown', onKeydown);\n\n // Keep the Ungroup button in sync with inline-editor's selection changes.\n const surface = document.querySelector('.cs_paper') || document.querySelector('.custom-form-design');\n if (surface) {\n let scheduled = false;\n const obs = new MutationObserver(() => {\n if (scheduled) return;\n scheduled = true;\n requestAnimationFrame(() => { scheduled = false; if (!band) refreshUngroupButton(); });\n });\n obs.observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n }\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/context-menu.js\">\n/**\n * @fileoverview Right-click context menu for blocks.\n *\n * Right-click a block → quick actions: Duplicate, Delete, z-order (free blocks),\n * Copy / Paste style, Lock / Unlock. Reuses FlowCanvas.duplicateBlock /\n * deleteBlock; z-order + lock + style-copy are handled here so they work for any\n * free-move block (cover page / section child). Editor-only (menu lives in\n * <body>, never exported). Right-clicking while editing text falls through to\n * the browser's native menu (spellcheck etc.).\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const FC = window.FlowCanvas;\n\n const isFree = (b) => !!(b && b.classList && b.classList.contains('cs_block_s') &&\n (b.dataset.csInSection === '1' || b.classList.contains('cs-flexible-block') ||\n (b.closest && b.closest('[data-cs-cover=\"1\"]'))));\n\n /* ------------------------------ operations -------------------------------- */\n\n // Dense z-index reorder among `.cs_block_s` siblings (cover / section).\n const zOrder = (block, kind) => {\n const parent = block.parentElement;\n if (!parent) return;\n const sibs = Array.from(parent.children).filter((c) => c.matches && c.matches('.cs_block_s'));\n if (sibs.length < 2) return;\n const z = (el) => (parseInt(el.style.zIndex || '0', 10) || 0);\n const ordered = sibs.slice().sort((a, b) => (z(a) - z(b)) || (sibs.indexOf(a) - sibs.indexOf(b)));\n const i = ordered.indexOf(block);\n if (i < 0) return;\n if (kind === 'front') { ordered.splice(i, 1); ordered.push(block); }\n else if (kind === 'back') { ordered.splice(i, 1); ordered.unshift(block); }\n else if (kind === 'forward' && i < ordered.length - 1) { ordered.splice(i, 1); ordered.splice(i + 1, 0, block); }\n else if (kind === 'backward' && i > 0) { ordered.splice(i, 1); ordered.splice(i - 1, 0, block); }\n else return;\n ordered.forEach((el, idx) => { el.style.zIndex = String(idx + 1); });\n };\n\n const toggleLock = (block) => {\n if (block.dataset.csLocked === '1') delete block.dataset.csLocked;\n else block.dataset.csLocked = '1';\n };\n\n // Format painter. Copies the EFFECTIVE (computed) look — typography from the\n // text element, box look from the block — and pastes it as concrete inline\n // styles (export-safe). NOT position/size, so paste never moves the block.\n const textEl = (b) => b.querySelector(':scope > .edit_me') || b.querySelector(':scope > .canvas-block__content') || b;\n const TYPO_KEYS = ['color', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',\n 'textAlign', 'lineHeight', 'letterSpacing', 'textTransform', 'textDecorationLine'];\n const BOX_KEYS = ['backgroundColor',\n 'borderTopWidth', 'borderTopStyle', 'borderTopColor',\n 'borderRightWidth', 'borderRightStyle', 'borderRightColor',\n 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor',\n 'borderLeftWidth', 'borderLeftStyle', 'borderLeftColor',\n 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius',\n 'boxShadow', 'opacity'];\n let styleClip = null;\n const copyStyle = (block) => {\n const boxCs = getComputedStyle(block);\n const typoCs = getComputedStyle(textEl(block));\n styleClip = { typo: {}, box: {} };\n TYPO_KEYS.forEach((k) => { styleClip.typo[k] = typoCs[k]; });\n BOX_KEYS.forEach((k) => { styleClip.box[k] = boxCs[k]; });\n };\n const pasteStyle = (block) => {\n if (!styleClip) return;\n const dst = textEl(block);\n Object.entries(styleClip.typo).forEach(([k, v]) => { if (v) dst.style[k] = v; });\n Object.entries(styleClip.box).forEach(([k, v]) => { if (v) block.style[k] = v; });\n };\n\n /* -------------------------------- the menu -------------------------------- */\n\n let menu = null;\n const closeMenu = () => { if (menu) { menu.remove(); menu = null; } };\n\n const buildItems = (block) => {\n const free = isFree(block);\n const del = FC.deleteBlock || ((b) => b.remove());\n const items = [\n { label: 'Duplicate', hint: '⌘/Ctrl+D', act: () => FC.duplicateBlock && FC.duplicateBlock(block) },\n { label: 'Delete', hint: 'Del', danger: true, act: () => del(block) },\n ];\n if (free) {\n items.push({ sep: true },\n { label: 'Bring to front', act: () => zOrder(block, 'front') },\n { label: 'Bring forward', act: () => zOrder(block, 'forward') },\n { label: 'Send backward', act: () => zOrder(block, 'backward') },\n { label: 'Send to back', act: () => zOrder(block, 'back') });\n }\n items.push({ sep: true }, { label: 'Copy style', act: () => copyStyle(block) });\n if (styleClip) items.push({ label: 'Paste style', act: () => pasteStyle(block) });\n if (free) items.push({ sep: true }, { label: block.dataset.csLocked === '1' ? 'Unlock' : 'Lock', act: () => toggleLock(block) });\n return items;\n };\n\n const openMenu = (x, y, block) => {\n closeMenu();\n menu = document.createElement('div');\n menu.className = 'cs-ctx-menu';\n menu.setAttribute('data-cs-chrome', '');\n buildItems(block).forEach((it) => {\n if (it.sep) { const s = document.createElement('div'); s.className = 'cs-ctx-menu__sep'; menu.appendChild(s); return; }\n const b = document.createElement('button');\n b.type = 'button';\n b.className = 'cs-ctx-menu__item' + (it.danger ? ' is-danger' : '');\n b.innerHTML = `<span>${it.label}</span>${it.hint ? `<span class=\"cs-ctx-menu__hint\">${it.hint}</span>` : ''}`;\n b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); try { it.act(); } catch (err) { /* */ } closeMenu(); });\n menu.appendChild(b);\n });\n menu.addEventListener('pointerdown', (e) => e.stopPropagation(), true);\n document.body.appendChild(menu);\n // Clamp to viewport.\n const w = menu.offsetWidth, h = menu.offsetHeight;\n const vw = window.innerWidth, vh = window.innerHeight;\n menu.style.left = `${Math.min(x, vw - w - 8)}px`;\n menu.style.top = `${Math.min(y, vh - h - 8)}px`;\n };\n\n /* --------------------------------- wiring --------------------------------- */\n\n const init = () => {\n document.addEventListener('contextmenu', (e) => {\n const block = e.target.closest && e.target.closest('.cs_block_s');\n if (!block) { closeMenu(); return; } // empty area → native menu\n // Table blocks own their own context menu (table-block.js) outside Froala\n // mode — bail here so both menus don't open at once on the same right-click.\n const inFroala = (typeof window.isFroalaEditor === 'function') && window.isFroalaEditor();\n if (block.dataset.blockType === 'table' && !inFroala) { closeMenu(); return; }\n // While editing text, defer to the browser's native menu.\n if (window.EditorManager && window.EditorManager.getEditing && window.EditorManager.getEditing() === block) return;\n e.preventDefault();\n try { window.EditorManager && window.EditorManager.select && window.EditorManager.select(block); } catch (err) { /* */ }\n openMenu(e.clientX, e.clientY, block);\n });\n document.addEventListener('pointerdown', (e) => {\n if (menu && !e.target.closest('.cs-ctx-menu')) closeMenu();\n }, true);\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); });\n document.addEventListener('scroll', closeMenu, true);\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/shortcuts-overlay.js\">\n/**\n * @fileoverview Keyboard-shortcuts help overlay.\n *\n * Press “?” (Shift+/) anywhere outside a text field to toggle a cheat-sheet of\n * the editor's shortcuts. Esc or a click on the backdrop closes it. Also\n * openable via window.ShortcutsOverlay.toggle() (e.g. from a host button).\n */\n(function () {\n window.ShortcutsOverlay = window.ShortcutsOverlay || {};\n\n const isMac = /Mac|iPhone|iPad|iPod/i.test((navigator.platform || '') + ' ' + (navigator.userAgent || ''));\n const MOD = isMac ? '⌘' : 'Ctrl';\n\n const SECTIONS = [\n ['General', [\n [`${MOD} Z`, 'Undo'],\n [isMac ? '⌘ ⇧ Z' : 'Ctrl Y', 'Redo'],\n ['?', 'This help'],\n ['Esc', 'Deselect / close'],\n ]],\n ['Blocks', [\n [`${MOD} C`, 'Copy'],\n [`${MOD} V`, 'Paste'],\n [`${MOD} D`, 'Duplicate'],\n ['Del', 'Delete'],\n [`${MOD} R`, 'Rename block'],\n ['Arrows', 'Nudge / reorder (Shift = bigger)'],\n ['Right-click', 'Context menu'],\n ]],\n ['AI Writer', [\n [isMac ? '⌘ H' : 'Alt H', 'Ask Aiden to write'],\n ]],\n ['Pen / Shape', [\n ['✒ then click', 'Add points'],\n ['Hover edge', '+ to add a point'],\n ['Hover point', '× to remove (drag = move)'],\n ['✋', 'Move points / shape'],\n ['Enter', 'Close the shape'],\n [`${MOD} drag-snap`, 'Smart-align guides'],\n [`${MOD} wheel`, 'Zoom (shape designer)'],\n ]],\n ];\n\n let modal = null;\n\n const close = () => { if (modal) { modal.remove(); modal = null; } };\n\n const open = () => {\n if (modal) return;\n modal = document.createElement('div');\n modal.className = 'cs-shortcuts';\n modal.setAttribute('data-cs-chrome', '');\n let cols = '';\n SECTIONS.forEach(([title, rows]) => {\n cols += `<div class=\"cs-shortcuts__group\"><div class=\"cs-shortcuts__title\">${title}</div>`;\n rows.forEach(([k, d]) => {\n cols += `<div class=\"cs-shortcuts__row\"><kbd>${k}</kbd><span>${d}</span></div>`;\n });\n cols += '</div>';\n });\n modal.innerHTML = `\n <div class=\"cs-shortcuts__backdrop\"></div>\n <div class=\"cs-shortcuts__panel\">\n <div class=\"cs-shortcuts__head\">\n <span>Keyboard shortcuts</span>\n <button type=\"button\" class=\"cs-shortcuts__close\" aria-label=\"Close\">✕</button>\n </div>\n <div class=\"cs-shortcuts__cols\">${cols}</div>\n </div>`;\n modal.addEventListener('click', (e) => {\n if (e.target.closest('.cs-shortcuts__close') || e.target.classList.contains('cs-shortcuts__backdrop')) close();\n });\n document.body.appendChild(modal);\n };\n\n const toggle = () => { if (modal) close(); else open(); };\n Object.assign(window.ShortcutsOverlay, { open, close, toggle });\n\n const init = () => {\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') { close(); return; }\n // “?” = Shift + / . Ignore while typing in a field / editing text.\n if (e.key !== '?') return;\n const t = e.target;\n if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;\n if (window.EditorManager && window.EditorManager.getEditing && window.EditorManager.getEditing()) return;\n e.preventDefault();\n toggle();\n });\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/measure-distance.js\">\n/**\n * @fileoverview Figma-style distance measurement overlay.\n *\n * Select a free-move block, hold Ctrl (⌘ on Mac), then hover another free\n * block: an animated overlay draws the gap between the two blocks with px\n * labels — exactly like Figma's measure mode. The selected block gets a solid\n * outline (the reference), the hovered block a dashed marching-ants outline,\n * and the distance is rendered as red measurement lines + value badges.\n *\n * Smart geometry, per axis:\n * • side-by-side → one horizontal gap line\n * • stacked → one vertical gap line\n * • diagonal → both gaps + dotted extension lines (the classic case)\n * • overlapping → four edge-inset distances (left/right/top/bottom)\n *\n * Editor-only chrome: the overlay lives in <body> with data-cs-chrome and is\n * transient (only exists while measuring), so it never reaches export. Works\n * regardless of scroll/zoom because every render reads live getBoundingClientRect.\n *\n * Public API: window.MeasureDistance.{ enable, disable, isActive }.\n * Self-contained — injects its own CSS; the only host edit is the <script> tag.\n */\n(function () {\n // Manager kill-switch (defaults on when the flag is absent).\n if (window.EditorFeatures && window.EditorFeatures.measureDistance === false) return;\n\n window.MeasureDistance = window.MeasureDistance || {};\n\n const SVG_NS = 'http://www.w3.org/2000/svg';\n const TICK = 4; // half-length of an end-cap tick (px)\n const MIN_EDGE = 1; // ignore sub-pixel edge offsets in the overlap case\n // Styling lives in custom-form.css under the \".cs-measure\" section.\n\n // ------------------------------------------------------------------ state\n const DWELL = 250; // ms the modifier must be held before\n // drawing — so Ctrl+C/V/D don't flash it\n let enabled = true;\n let armed = false; // modifier key held\n let dwellTimer = 0; // suppression window (0 = elapsed)\n let overlay = null, svg = null; // DOM handles\n let lastSrc = null, lastTgt = null; // currently drawn pair\n let raf = 0;\n const pointer = { x: 0, y: 0 };\n\n // ------------------------------------------------------------------ helpers\n const modifier = (e) => e.ctrlKey || e.metaKey;\n\n // Restrict to free-positioned blocks (cover page / section / flexible) — the\n // only place free-canvas distance is meaningful. Mirrors inline-editor's\n // isFreeFormBlock.\n const isFree = (b) => !!b && b.classList && b.classList.contains('cs_block_s') &&\n (b.dataset.csInSection === '1' ||\n b.classList.contains('cs-flexible-block') ||\n !!(b.closest && b.closest('[data-cs-cover=\"1\"]')));\n\n const getSource = () => {\n const sel = (window.EditorManager && window.EditorManager.getSelected &&\n window.EditorManager.getSelected()) ||\n document.querySelector('.cs_block_s.cs-selected');\n return isFree(sel) ? sel : null;\n };\n\n const blockUnderPointer = (src) => {\n const el = document.elementFromPoint(pointer.x, pointer.y);\n const block = el && el.closest && el.closest('.cs_block_s');\n if (!block || block === src || !isFree(block)) return null;\n // Don't measure a block against its own ancestor/descendant — that's the\n // chrome of the same thing, not a sibling gap.\n if (src && (src.contains(block) || block.contains(src))) return null;\n return block;\n };\n\n const rectOf = (el) => {\n const r = el.getBoundingClientRect();\n return { left: r.left, top: r.top, right: r.right, bottom: r.bottom,\n w: r.width, h: r.height, cx: r.left + r.width / 2, cy: r.top + r.height / 2 };\n };\n\n const busy = () => (window.EditorManager &&\n ((window.EditorManager.isInteracting && window.EditorManager.isInteracting()) ||\n (window.EditorManager.getEditing && window.EditorManager.getEditing())));\n\n // ------------------------------------------------------------------ drawing\n const line = (x1, y1, x2, y2, cls) => {\n const l = document.createElementNS(SVG_NS, 'line');\n l.setAttribute('x1', x1); l.setAttribute('y1', y1);\n l.setAttribute('x2', x2); l.setAttribute('y2', y2);\n l.setAttribute('pathLength', '1'); // normalise so draw anim works at any length\n l.setAttribute('class', cls);\n svg.appendChild(l);\n };\n const rect = (r, cls) => {\n const el = document.createElementNS(SVG_NS, 'rect');\n el.setAttribute('x', r.left); el.setAttribute('y', r.top);\n el.setAttribute('width', Math.max(0, r.w)); el.setAttribute('height', Math.max(0, r.h));\n el.setAttribute('rx', '2'); el.setAttribute('class', cls);\n svg.appendChild(el);\n };\n const label = (x, y, value) => {\n const d = document.createElement('div');\n d.className = 'cs-measure__label';\n d.style.left = `${x}px`; d.style.top = `${y}px`;\n d.textContent = `${Math.round(value)}px`;\n overlay.appendChild(d);\n };\n\n // A measured span between two parallel edges, with caps and (when the line\n // overshoots a block) dotted extension lines anchoring it to that edge.\n const hMeasure = (x1, x2, yLine, aSpan, bSpan) => {\n line(x1, yLine, x2, yLine, 'cs-measure__line');\n line(x1, yLine - TICK, x1, yLine + TICK, 'cs-measure__cap');\n line(x2, yLine - TICK, x2, yLine + TICK, 'cs-measure__cap');\n // extension: if yLine sits outside a block's vertical span, dot it to the edge\n if (yLine < aSpan[0]) line(x1, aSpan[0], x1, yLine, 'cs-measure__ext');\n else if (yLine > aSpan[1]) line(x1, aSpan[1], x1, yLine, 'cs-measure__ext');\n if (yLine < bSpan[0]) line(x2, bSpan[0], x2, yLine, 'cs-measure__ext');\n else if (yLine > bSpan[1]) line(x2, bSpan[1], x2, yLine, 'cs-measure__ext');\n label((x1 + x2) / 2, yLine, x2 - x1);\n };\n const vMeasure = (y1, y2, xLine, aSpan, bSpan) => {\n line(xLine, y1, xLine, y2, 'cs-measure__line');\n line(xLine - TICK, y1, xLine + TICK, y1, 'cs-measure__cap');\n line(xLine - TICK, y2, xLine + TICK, y2, 'cs-measure__cap');\n if (xLine < aSpan[0]) line(aSpan[0], y1, xLine, y1, 'cs-measure__ext');\n else if (xLine > aSpan[1]) line(aSpan[1], y1, xLine, y1, 'cs-measure__ext');\n if (xLine < bSpan[0]) line(bSpan[0], y2, xLine, y2, 'cs-measure__ext');\n else if (xLine > bSpan[1]) line(bSpan[1], y2, xLine, y2, 'cs-measure__ext');\n label(xLine, (y1 + y2) / 2, y2 - y1);\n };\n\n const measure = (S, T) => {\n // horizontal relationship\n let hGap = null; // {x1,x2, aSpan,bSpan}\n if (T.left >= S.right) hGap = { x1: S.right, x2: T.left, aSpan: [S.top, S.bottom], bSpan: [T.top, T.bottom] };\n else if (T.right <= S.left) hGap = { x1: T.right, x2: S.left, aSpan: [T.top, T.bottom], bSpan: [S.top, S.bottom] };\n // vertical relationship. aSpan must be the horizontal span of the block that\n // owns y1, bSpan the one that owns y2 — so the dotted extension lines anchor\n // to the correct block. (When T is above S, y1 belongs to T, not S.)\n let vGap = null; // {y1,y2, aSpan,bSpan}\n if (T.top >= S.bottom) vGap = { y1: S.bottom, y2: T.top, aSpan: [S.left, S.right], bSpan: [T.left, T.right] };\n else if (T.bottom <= S.top) vGap = { y1: T.bottom, y2: S.top, aSpan: [T.left, T.right], bSpan: [S.left, S.right] };\n\n if (hGap) {\n const yOverlap = vGap ? null : [Math.max(S.top, T.top), Math.min(S.bottom, T.bottom)];\n const yLine = yOverlap ? (yOverlap[0] + yOverlap[1]) / 2 : S.cy; // anchor to source when diagonal\n hMeasure(hGap.x1, hGap.x2, yLine, hGap.aSpan, hGap.bSpan);\n }\n if (vGap) {\n const xOverlap = hGap ? null : [Math.max(S.left, T.left), Math.min(S.right, T.right)];\n const xLine = xOverlap ? (xOverlap[0] + xOverlap[1]) / 2 : S.cx;\n vMeasure(vGap.y1, vGap.y2, xLine, vGap.aSpan, vGap.bSpan);\n }\n\n // Overlapping on both axes → show the four edge-inset distances.\n if (!hGap && !vGap) {\n const yMid = (Math.max(S.top, T.top) + Math.min(S.bottom, T.bottom)) / 2;\n const xMid = (Math.max(S.left, T.left) + Math.min(S.right, T.right)) / 2;\n if (Math.abs(T.left - S.left) >= MIN_EDGE)\n hMeasure(Math.min(S.left, T.left), Math.max(S.left, T.left), yMid, [S.top, S.bottom], [T.top, T.bottom]);\n if (Math.abs(T.right - S.right) >= MIN_EDGE)\n hMeasure(Math.min(S.right, T.right), Math.max(S.right, T.right), yMid, [S.top, S.bottom], [T.top, T.bottom]);\n if (Math.abs(T.top - S.top) >= MIN_EDGE)\n vMeasure(Math.min(S.top, T.top), Math.max(S.top, T.top), xMid, [S.left, S.right], [T.left, T.right]);\n if (Math.abs(T.bottom - S.bottom) >= MIN_EDGE)\n vMeasure(Math.min(S.bottom, T.bottom), Math.max(S.bottom, T.bottom), xMid, [S.left, S.right], [T.left, T.right]);\n }\n };\n\n // ------------------------------------------------------------------ overlay\n const ensureOverlay = () => {\n if (overlay) return;\n overlay = document.createElement('div');\n overlay.className = 'cs-measure';\n overlay.setAttribute('data-cs-chrome', '');\n svg = document.createElementNS(SVG_NS, 'svg');\n svg.setAttribute('class', 'cs-measure__svg');\n overlay.appendChild(svg);\n document.body.appendChild(overlay);\n };\n const clear = () => {\n if (overlay) { overlay.remove(); overlay = null; svg = null; }\n lastSrc = lastTgt = null;\n };\n\n const arm = () => {\n if (armed) return;\n armed = true;\n if (dwellTimer) clearTimeout(dwellTimer);\n dwellTimer = setTimeout(() => { dwellTimer = 0; schedule(); }, DWELL);\n };\n const disarm = () => {\n armed = false;\n if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = 0; }\n clear();\n };\n\n const showHint = (src) => {\n clear();\n ensureOverlay();\n const isMac = /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '');\n const hint = document.createElement('div');\n hint.className = 'cs-measure__hint';\n hint.style.left = `${pointer.x}px`; hint.style.top = `${pointer.y}px`;\n hint.innerHTML = `<b>${isMac ? '⌘' : 'Ctrl'}</b> · hover a block to measure`;\n overlay.appendChild(hint);\n lastSrc = src; lastTgt = 'hint';\n };\n\n // Full rebuild — only called when the measured pair changes (positions are\n // stable while merely hovering, so same-pair moves are a cheap no-op).\n const render = () => {\n raf = 0;\n if (!enabled || !armed) { clear(); return; }\n if (dwellTimer) return; // still inside the suppression window\n if (busy()) { clear(); return; }\n const src = getSource();\n if (!src) { clear(); return; }\n const tgt = blockUnderPointer(src);\n\n if (!tgt) {\n if (lastSrc !== src || lastTgt !== 'hint') showHint(src);\n else if (overlay) { // keep hint glued to the cursor\n const h = overlay.querySelector('.cs-measure__hint');\n if (h) { h.style.left = `${pointer.x}px`; h.style.top = `${pointer.y}px`; }\n }\n return;\n }\n if (src === lastSrc && tgt === lastTgt) return; // already drawn, geometry unchanged\n\n clear();\n ensureOverlay();\n const W = window.innerWidth, H = window.innerHeight;\n svg.setAttribute('width', W); svg.setAttribute('height', H);\n const S = rectOf(src), T = rectOf(tgt);\n rect(S, 'cs-measure__box cs-measure__box--src');\n rect(T, 'cs-measure__box cs-measure__box--tgt');\n measure(S, T);\n lastSrc = src; lastTgt = tgt;\n };\n\n const schedule = () => { if (!raf) raf = requestAnimationFrame(render); };\n\n // ------------------------------------------------------------------ events\n const onKeyDown = (e) => {\n if (!enabled) return;\n if (e.key === 'Escape' && armed) { disarm(); return; }\n if (modifier(e)) arm();\n };\n const onKeyUp = (e) => {\n // Disarm only once no modifier remains held (releasing some other key while\n // Ctrl is still down must not tear the overlay down).\n if (armed && !modifier(e)) disarm();\n };\n const onMove = (e) => {\n pointer.x = e.clientX; pointer.y = e.clientY;\n if (!enabled) return;\n if (modifier(e)) { arm(); schedule(); }\n else if (armed) disarm();\n };\n const onScrollResize = () => { if (armed) { lastTgt = null; schedule(); } };\n const onBlur = () => { if (armed) disarm(); };\n const onDown = () => { if (armed) clear(); }; // a drag/click starts → get out of the way\n\n const init = () => {\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('keyup', onKeyUp, true);\n document.addEventListener('mousemove', onMove, true);\n document.addEventListener('pointerdown', onDown, true);\n window.addEventListener('scroll', onScrollResize, true);\n window.addEventListener('resize', onScrollResize, true);\n window.addEventListener('blur', onBlur);\n };\n\n Object.assign(window.MeasureDistance, {\n enable: () => { enabled = true; },\n disable: () => { enabled = false; armed = false; clear(); },\n isActive: () => !!overlay,\n });\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/zoom-shortcuts.js\">\n/**\n * @fileoverview Canvas-zoom keyboard shortcuts (iframe side).\n *\n * The canvas zoom is owned by the Angular host (it sets `--editor-zoom` and\n * scales the iframe wrapper). When focus is inside this editor iframe, Ctrl/⌘\n * +/−/0 fire HERE, not on the host — so the browser would do its own page\n * zoom instead of the editor's. This module intercepts those presses, blocks\n * the native zoom (preventDefault), and forwards the intent to the host via the\n * standard postMessage channel; the host applies the editor zoom.\n *\n * The host has a matching keydown handler for when focus is on its own chrome,\n * so the shortcut works no matter where focus sits.\n */\n(function () {\n if (window.EditorFeatures && window.EditorFeatures.zoomShortcuts === false) return;\n\n // Map a Ctrl/⌘ key event to a zoom direction, or null if it isn't one.\n const zoomDir = (e) => {\n if (!e.ctrlKey && !e.metaKey) return null;\n const k = e.key;\n if (k === '+' || k === '=' || e.code === 'NumpadAdd') return 'in';\n if (k === '-' || k === '_' || e.code === 'NumpadSubtract') return 'out';\n if (k === '0' || e.code === 'Numpad0' || e.code === 'Digit0') return 'reset';\n return null;\n };\n\n const onKeyDown = (e) => {\n const dir = zoomDir(e);\n if (!dir) return;\n e.preventDefault(); // stop the browser's own page zoom\n try {\n if (window.parent && window.parent !== window) {\n window.parent.postMessage({ source: 'custom-form-twig', type: 'editor:zoom', dir }, '*');\n }\n } catch (err) { /* cross-origin parent — nothing we can do */ }\n };\n\n // Capture phase so we win before any block-level handler swallows the combo.\n document.addEventListener('keydown', onKeyDown, true);\n})();\n\n<\/script>\n <script data-src=\"./js/flow/brand.js\">\n/**\n * @fileoverview Brand Kit — document-wide font application (iframe side).\n *\n * Listens for the Angular shell's `brand:apply-fonts` message and restyles every\n * text element (`.edit_me`) in the canvas with the chosen brand fonts:\n * - heading blocks → the heading font\n * - everything else → the body font\n *\n * Fonts are written as CONCRETE inline `font-family` (not CSS variables) so the\n * change survives the Twig/PDF export, which only resolves var() fallbacks.\n *\n * Colours are applied per-block from the Style-panel brand swatches (concrete\n * inline too), so nothing here is needed for colour.\n */\n(function () {\n const isHeadingBlock = (block) => {\n if (!block) return false;\n const t = (block.dataset && block.dataset.blockType) || '';\n if (t.indexOf('heading') === 0) return true; // 'heading' / 'heading-two'\n if (block.classList && block.classList.contains('add-heading-two')) return true;\n return !!block.querySelector(':scope > .add-heading-two, :scope > .edit_me.add-heading-two');\n };\n\n const applyFonts = (headingFont, bodyFont) => {\n const root = document.querySelector('.cs_paper') || document.querySelector('.custom-form-design') || document.body;\n if (!root) return;\n root.querySelectorAll('.edit_me').forEach((edit) => {\n const block = edit.closest('.cs_block_s');\n const font = isHeadingBlock(block) || edit.classList.contains('add-heading-two') ? headingFont : bodyFont;\n if (font) edit.style.fontFamily = font;\n });\n };\n\n window.addEventListener('message', (e) => {\n const msg = e.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n if (msg.type === 'brand:apply-fonts') {\n applyFonts(msg.headingFont, msg.bodyFont);\n }\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/layer-system.js\">\n/**\n * @fileoverview Photoshop-style layer system for COVER PAGES only.\n *\n * Stacking is controlled purely by inline `z-index` on each block — never by\n * reordering the DOM — so List sync, Section flow and Group child structure are\n * never disturbed, and the order round-trips through twig export / reload (the\n * inline style is part of the serialized DOM).\n *\n * A block's \"layer siblings\" are the `.cs_block_s` elements that share its\n * immediate parent (cover root, a group, a section/list column, …). Within each\n * such container the siblings stack by z-index; because a positioned container\n * (group/section/list) forms its own stacking context, its children stack WITHIN\n * it — exactly Photoshop's nested-layer behaviour.\n *\n * Talks to the right-panel Layers tab over the existing postMessage bus:\n * panel → iframe : layers:request | layers:op {blockId,op} | layers:reorder\n * {blockId,targetId,position}\n * iframe → panel : layers:tree {data:{pageId, selectedId, nodes}}\n * Selection itself reuses the existing `block:select` message.\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const FC = window.FlowCanvas;\n const EM = () => window.EditorManager;\n\n const COVER_SEL = '[data-cs-cover=\"1\"]';\n const isCoverBlock = (b) => !!b?.closest?.(COVER_SEL);\n\n const getZ = (el) => {\n const z = parseInt(el.style.zIndex || '', 10);\n return Number.isNaN(z) ? null : z;\n };\n\n // Current visual (paint) order of a set of sibling blocks, bottom → top.\n // No explicit z-index paints below any explicit one (treated as 0); ties break\n // by DOM order.\n const visualOrder = (sibs) => {\n const idx = new Map(sibs.map((el, i) => [el, i]));\n return sibs.slice().sort((a, b) => {\n const ea = getZ(a) ?? 0;\n const eb = getZ(b) ?? 0;\n if (ea !== eb) return ea - eb;\n return idx.get(a) - idx.get(b);\n });\n };\n\n // Blocks that share `block`'s immediate parent — the set we restack.\n const zSiblings = (block) =>\n Array.from(block.parentElement?.children || []).filter((c) => c.classList?.contains('cs_block_s'));\n\n // Immediate nested layer-children of a block (those whose nearest `.cs_block_s`\n // ancestor is this block) — used to build the tree for groups/sections/lists.\n const childBlocksOf = (el) =>\n Array.from(el.querySelectorAll('.cs_block_s')).filter(\n (b) => b.parentElement?.closest('.cs_block_s') === el\n );\n\n // Top-level blocks of a cover page (its direct .cs_block_s children).\n const topBlocksOf = (cover) =>\n Array.from(cover.children).filter((c) => c.classList?.contains('cs_block_s'));\n\n const applyOrder = (ordered) => ordered.forEach((el, i) => { el.style.zIndex = String(i + 1); });\n\n // Reorder one block among its siblings and re-stamp dense z-index 1..N.\n const op = (block, kind) => {\n const sibs = zSiblings(block);\n if (sibs.length < 2) return;\n const order = visualOrder(sibs);\n const i = order.indexOf(block);\n if (i < 0) return;\n if (kind === 'front') { order.splice(i, 1); order.push(block); }\n else if (kind === 'back') { order.splice(i, 1); order.unshift(block); }\n else if (kind === 'forward' && i < order.length - 1) { order.splice(i, 1); order.splice(i + 1, 0, block); }\n else if (kind === 'backward' && i > 0) { order.splice(i, 1); order.splice(i - 1, 0, block); }\n else return;\n applyOrder(order);\n };\n\n // Drag-reorder: move `block` next to `target` (same parent only). `position`\n // is in TREE order (top layer first), but `order` is bottom→top — so tree\n // 'before' (ABOVE target = higher layer) means insert AFTER target here, and\n // tree 'after' means insert BEFORE. (Getting this inverted is why a dropped\n // layer used to snap back to the end.)\n const reorderTo = (block, target, position) => {\n if (!block || !target || block === target) return;\n if (block.parentElement !== target.parentElement) return; // same container only\n const order = visualOrder(zSiblings(block));\n const from = order.indexOf(block);\n if (from < 0) return;\n order.splice(from, 1);\n let to = order.indexOf(target);\n if (to < 0) return;\n if (position === 'before') to += 1; // tree-above → after in bottom→top order\n order.splice(to, 0, block);\n applyOrder(order);\n };\n\n /* ----------------------------- tree → panel ----------------------------- */\n\n const labelOf = (b) =>\n b.getAttribute('custom-name') || b.dataset.blockType || b.getAttribute('data') || 'Block';\n const typeOf = (b) =>\n b.classList.contains('cs-group-block') ? 'group'\n : b.querySelector(':scope > .section-container-content') ? 'section'\n : b.classList.contains('cs-synclist__col') || b.querySelector(':scope .cs-synclist') ? 'list'\n : (b.dataset.blockType || 'block');\n\n const ensureId = (b) => {\n if (!b.id) (FC.assignNodeId ? FC.assignNodeId(b, 'block') : (b.id = 'block_' + Math.random().toString(16).slice(2)));\n return b.id;\n };\n\n const imageThumb = (b) => {\n const img = b.querySelector('.image-container img, img');\n return img?.getAttribute('src') || null;\n };\n\n const buildNode = (b, selectedId) => {\n ensureId(b);\n const kids = visualOrder(childBlocksOf(b)).reverse(); // top layer first\n return {\n id: b.id,\n label: labelOf(b),\n type: typeOf(b),\n selected: b.id === selectedId,\n hidden: b.dataset.csHidden === '1',\n locked: b.dataset.csLocked === '1',\n thumb: imageThumb(b),\n hasChildren: kids.length > 0,\n children: kids.map((c) => buildNode(c, selectedId)),\n };\n };\n\n // Photoshop \"eye\" — toggle a block's visibility. Use inline `!important` so it\n // beats the cover/group `display:block !important` rules, and round-trips\n // through export/reload (inline style is part of the serialized DOM).\n const toggleVisibility = (b) => {\n if (b.dataset.csHidden === '1') {\n delete b.dataset.csHidden;\n b.style.removeProperty('display');\n } else {\n b.dataset.csHidden = '1';\n b.style.setProperty('display', 'none', 'important');\n }\n };\n\n const toggleLock = (b) => {\n if (b.dataset.csLocked === '1') delete b.dataset.csLocked;\n else b.dataset.csLocked = '1';\n };\n\n const activeCover = () => {\n const sel = EM()?.getSelected?.() || EM()?.getEditing?.();\n return sel?.closest?.(COVER_SEL) || document.querySelector(COVER_SEL) || null;\n };\n\n const sendTree = () => {\n const cover = activeCover();\n const sel = EM()?.getSelected?.() || EM()?.getEditing?.();\n const selId = sel && cover && cover.contains(sel) ? sel.id || null : null;\n const tops = cover ? visualOrder(topBlocksOf(cover)).reverse() : [];\n const nodes = tops.map((b) => buildNode(b, selId));\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'layers:tree',\n data: { pageId: cover?.id || null, selectedId: selId, nodes },\n }, '*');\n };\n\n // Debounced refresh on any structural / selection / style change on the board.\n let scheduled = false;\n const scheduleSend = () => {\n if (scheduled) return;\n scheduled = true;\n requestAnimationFrame(() => { scheduled = false; sendTree(); });\n };\n\n /* ----------------------------- wiring ----------------------------- */\n\n window.addEventListener('message', (e) => {\n const msg = e.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n if (msg.type === 'layers:request') {\n sendTree();\n } else if (msg.type === 'layers:op' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { op(b, msg.op); sendTree(); }\n } else if (msg.type === 'layers:reorder' && msg.blockId && msg.targetId) {\n const b = document.getElementById(msg.blockId);\n const t = document.getElementById(msg.targetId);\n if (b && t && isCoverBlock(b)) { reorderTo(b, t, msg.position); sendTree(); }\n } else if (msg.type === 'layers:visibility' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { toggleVisibility(b); sendTree(); }\n } else if (msg.type === 'layers:rename' && msg.blockId && typeof msg.name === 'string') {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) {\n b.setAttribute('custom-name', msg.name);\n // Keep the on-canvas badge label in sync if the block is selected.\n const lbl = b.querySelector(':scope > .cs-block-badge .cs-block-badge__label');\n if (lbl) lbl.textContent = msg.name;\n sendTree();\n }\n } else if (msg.type === 'layers:lock' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { toggleLock(b); sendTree(); }\n } else if (msg.type === 'layers:duplicate' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { FC.duplicateBlock?.(b); sendTree(); }\n } else if (msg.type === 'layers:delete' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) {\n if (FC.deleteBlock) FC.deleteBlock(b); else b.remove();\n sendTree();\n }\n }\n });\n\n const init = () => {\n const board = document.querySelector('.cs_paper') || document.querySelector('.custom-form-design');\n if (board) {\n const obs = new MutationObserver(scheduleSend);\n obs.observe(board, {\n attributes: true,\n attributeFilter: ['class', 'style'],\n childList: true,\n subtree: true,\n });\n }\n sendTree();\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/drop-zones.js\">\n/**\n * @fileoverview Drop-zone detection and visual indicator.\n *\n * Decides where a dragged block will land given the pointer position, and\n * shows a thin blue line indicating the drop target.\n *\n * Exposes:\n * window.FlowCanvas.findDropTarget(doc, canvas, clientX, clientY) → { target, indicator } | null\n * window.FlowCanvas.showIndicator(hint)\n * window.FlowCanvas.hideIndicator()\n *\n * Drop target kinds:\n * between-rows — new row at gap\n * col-edge — new column inside an existing row\n * in-col — into an existing column (between blocks or empty col)\n * in-section — inside a section's content area (free placement)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const cfg = (window.CanvasConfig && window.CanvasConfig.dropZone) || {};\n const ROW_EDGE_GAP = cfg.rowEdgeGap ?? 12;\n const COL_EDGE_GAP = cfg.colEdgeGap ?? 24;\n\n // A \"free canvas\" is any root that positions its children absolutely:\n // a flexible container, or a cover page (`.cs_page[data-cs-cover]`). Drops\n // into one are placed by cursor position, not woven into row/col flow.\n const isFreeCanvas = (el) =>\n !!el && (el.classList?.contains('cs-flexible-content') || el.matches?.('[data-cs-cover=\"1\"]'));\n\n // ---------------------------------------------------------------------------\n // Section drop target — sections now act as nested row/col flow canvases\n // (same model as the document root). Returning null falls through to the\n // standard row/col logic, but with the section's content area passed in\n // as the scoped root via `findDropTargetIn`.\n //\n // Returns the innermost section content element under the cursor, or null\n // when the cursor isn't over any section.\n // ---------------------------------------------------------------------------\n const findSectionUnderCursor = (canvas, clientX, clientY) => {\n const sections = Array.from(canvas.querySelectorAll('.section-container-content, .cs-flexible-content'));\n // Walk in reverse so an inner section wins over an outer one when nested.\n for (let i = sections.length - 1; i >= 0; i--) {\n const section = sections[i];\n const rect = section.getBoundingClientRect();\n if (clientX >= rect.left && clientX <= rect.right &&\n clientY >= rect.top && clientY <= rect.bottom) {\n return section;\n }\n }\n return null;\n };\n\n // ---------------------------------------------------------------------------\n // In-column target (between blocks or empty col)\n // ---------------------------------------------------------------------------\n const findInColTarget = (col, clientY) => {\n const blocks = Array.from(col.children).filter(c => !c.matches('.cs-line-divider'));\n const rect = col.getBoundingClientRect();\n\n if (blocks.length === 0) {\n return {\n target: { kind: 'in-col', col, beforeBlock: null },\n indicator: { type: 'horizontal', top: rect.top + rect.height / 2 - 1, left: rect.left, right: rect.right }\n };\n }\n\n for (let i = 0; i < blocks.length; i++) {\n const bRect = blocks[i].getBoundingClientRect();\n const mid = (bRect.top + bRect.bottom) / 2;\n if (clientY < mid) {\n return {\n target: { kind: 'in-col', col, beforeBlock: blocks[i] },\n indicator: { type: 'horizontal', top: bRect.top - 3, left: rect.left, right: rect.right }\n };\n }\n }\n\n const lastRect = blocks[blocks.length - 1].getBoundingClientRect();\n return {\n target: { kind: 'in-col', col, beforeBlock: null },\n indicator: { type: 'horizontal', top: lastRect.bottom + 1, left: rect.left, right: rect.right }\n };\n };\n\n // ---------------------------------------------------------------------------\n // Column-level routing inside a row\n // ---------------------------------------------------------------------------\n const findColTarget = (row, clientX, clientY) => {\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n if (cols.length === 0) {\n const rect = row.getBoundingClientRect();\n return {\n target: { kind: 'col-edge', row, beforeCol: null },\n indicator: { type: 'vertical', left: rect.left, top: rect.top, bottom: rect.bottom }\n };\n }\n\n const firstRect = cols[0].getBoundingClientRect();\n if (clientX < firstRect.left + COL_EDGE_GAP) {\n return {\n target: { kind: 'col-edge', row, beforeCol: cols[0] },\n indicator: { type: 'vertical', left: firstRect.left - 4, top: firstRect.top, bottom: firstRect.bottom }\n };\n }\n\n const lastRect = cols[cols.length - 1].getBoundingClientRect();\n if (clientX > lastRect.right - COL_EDGE_GAP) {\n return {\n target: { kind: 'col-edge', row, beforeCol: null },\n indicator: { type: 'vertical', left: lastRect.right + 1, top: lastRect.top, bottom: lastRect.bottom }\n };\n }\n\n for (let i = 0; i < cols.length; i++) {\n const col = cols[i];\n const rect = col.getBoundingClientRect();\n if (clientX >= rect.left && clientX <= rect.right) {\n if (i < cols.length - 1) {\n const nextRect = cols[i + 1].getBoundingClientRect();\n if (clientX > rect.right - COL_EDGE_GAP && clientX < nextRect.left + COL_EDGE_GAP) {\n return {\n target: { kind: 'col-edge', row, beforeCol: cols[i + 1] },\n indicator: { type: 'vertical', left: (rect.right + nextRect.left) / 2 - 1, top: rect.top, bottom: rect.bottom }\n };\n }\n }\n return findInColTarget(col, clientY);\n }\n }\n return findInColTarget(cols[cols.length - 1], clientY);\n };\n\n // ---------------------------------------------------------------------------\n // Top-level: row-level routing\n //\n // When the cursor is over a section's content area we treat that area as\n // a nested doc: same row/col flow detection, just scoped to the section.\n // The dropped block becomes a real child of the section's row tree, so\n // the section's height grows with content (no more absolute positioning\n // that left tables hanging outside the section's box).\n // ---------------------------------------------------------------------------\n const findDropTarget = (doc, canvas, clientX, clientY, blockType) => {\n const section = findSectionUnderCursor(canvas, clientX, clientY);\n let root = section || doc;\n let isHeaderFooter = false;\n\n if (!section && root.classList.contains('cs_margin')) {\n // Check if cursor is over header or footer\n const header = root.querySelector(':scope > .cs-page-header');\n const footer = root.querySelector(':scope > .cs-page-footer');\n const main = root.querySelector(':scope > .body-main-content');\n\n if (header) {\n const headerRect = header.getBoundingClientRect();\n if (clientY >= headerRect.top && clientY <= headerRect.bottom) {\n root = header;\n isHeaderFooter = true;\n } else if (footer) {\n const footerRect = footer.getBoundingClientRect();\n if (clientY >= footerRect.top && clientY <= footerRect.bottom) {\n root = footer;\n isHeaderFooter = true;\n } else if (main) {\n root = main;\n }\n } else if (main) {\n root = main;\n }\n } else if (main) {\n root = main;\n }\n }\n\n // Header/footer are themselves rows with columns as direct children\n // Main content area contains rows as direct children\n let rows = [];\n if (isHeaderFooter) {\n // Header/footer is a single row, so use it directly for column targeting\n rows = [root];\n } else {\n rows = Array.from(root.querySelectorAll(':scope > .row-item'));\n }\n\n if (rows.length === 0) {\n const rootRect = root.getBoundingClientRect();\n // Don't show indicator for flexible containers, but show flexible bounds highlight\n const isFlexible = isFreeCanvas(root);\n let indicator = null;\n if (!isFlexible) {\n indicator = { type: 'horizontal', top: rootRect.top + 4, left: rootRect.left, right: rootRect.right };\n } else {\n // Show flexible container bounds as a subtle highlight\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n }\n return {\n target: { kind: 'between-rows', beforeRow: null, parent: root },\n indicator: indicator\n };\n }\n\n // For flexible containers, never show line indicator - only show bounds highlight\n const isFlexible = isFreeCanvas(root);\n const rootRect = root.getBoundingClientRect();\n\n const firstRect = rows[0].getBoundingClientRect();\n if (clientY < firstRect.top + ROW_EDGE_GAP) {\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: firstRect.top - 4, left: firstRect.left, right: firstRect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: rows[0], parent: root },\n indicator: indicator\n };\n }\n\n const lastRect = rows[rows.length - 1].getBoundingClientRect();\n if (clientY > lastRect.bottom - ROW_EDGE_GAP) {\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: lastRect.bottom + 4, left: lastRect.left, right: lastRect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: null, parent: root },\n indicator: indicator\n };\n }\n\n for (let i = 0; i < rows.length; i++) {\n const row = rows[i];\n const rect = row.getBoundingClientRect();\n if (i < rows.length - 1) {\n const nextRect = rows[i + 1].getBoundingClientRect();\n if (clientY > rect.bottom - ROW_EDGE_GAP && clientY < nextRect.top + ROW_EDGE_GAP) {\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: (rect.bottom + nextRect.top) / 2 - 2, left: rect.left, right: rect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: rows[i + 1], parent: root },\n indicator: indicator\n };\n }\n }\n if (clientY >= rect.top && clientY <= rect.bottom) {\n return findColTarget(row, clientX, clientY);\n }\n }\n\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: lastRect.bottom + 4, left: lastRect.left, right: lastRect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: null, parent: root },\n indicator: indicator\n };\n };\n\n // ---------------------------------------------------------------------------\n // Visual indicator (single shared element)\n // ---------------------------------------------------------------------------\n let indicatorEl = null;\n\n let flexibleHighlightEl = null;\n\n const showIndicator = (hint) => {\n if (!hint) {\n if (indicatorEl) {\n indicatorEl.style.display = 'none';\n indicatorEl.style.visibility = 'hidden';\n }\n if (flexibleHighlightEl) {\n flexibleHighlightEl.style.display = 'none';\n flexibleHighlightEl.style.visibility = 'hidden';\n }\n return;\n }\n\n if (!indicatorEl) {\n indicatorEl = document.createElement('div');\n indicatorEl.className = 'cs-drop-indicator';\n indicatorEl.style.zIndex = '9999';\n document.body.appendChild(indicatorEl);\n }\n indicatorEl.classList.remove('cs-drop-indicator--horizontal', 'cs-drop-indicator--vertical');\n\n // For flexible highlight, hide the line indicator\n if (hint.type === 'flexible-highlight') {\n indicatorEl.style.display = 'none';\n indicatorEl.style.visibility = 'hidden';\n } else if (hint.type === 'horizontal') {\n indicatorEl.style.display = 'block';\n indicatorEl.style.visibility = 'visible';\n indicatorEl.classList.add('cs-drop-indicator--horizontal');\n indicatorEl.style.top = `${hint.top}px`;\n indicatorEl.style.left = `${hint.left}px`;\n indicatorEl.style.width = `${hint.right - hint.left}px`;\n indicatorEl.style.height = '1px';\n indicatorEl.style.overflow = 'hidden';\n } else {\n indicatorEl.style.display = 'block';\n indicatorEl.style.visibility = 'visible';\n indicatorEl.classList.add('cs-drop-indicator--vertical');\n indicatorEl.style.left = `${hint.left}px`;\n indicatorEl.style.top = `${hint.top}px`;\n indicatorEl.style.height = `${hint.bottom - hint.top}px`;\n indicatorEl.style.width = '1px';\n indicatorEl.style.overflow = 'hidden';\n }\n\n // Show subtle highlight for flexible container bounds if specified\n if (hint.flexibleBounds) {\n if (!flexibleHighlightEl) {\n flexibleHighlightEl = document.createElement('div');\n flexibleHighlightEl.className = 'cs-flexible-highlight';\n flexibleHighlightEl.style.position = 'fixed';\n flexibleHighlightEl.style.pointerEvents = 'none';\n flexibleHighlightEl.style.backgroundColor = 'rgba(92, 92, 255, 0.05)';\n flexibleHighlightEl.style.border = '1px solid rgba(92, 92, 255, 0.2)';\n flexibleHighlightEl.style.zIndex = '9998';\n flexibleHighlightEl.style.visibility = 'hidden';\n document.body.appendChild(flexibleHighlightEl);\n }\n const bounds = hint.flexibleBounds;\n flexibleHighlightEl.style.display = 'block';\n flexibleHighlightEl.style.visibility = 'visible';\n flexibleHighlightEl.style.left = `${bounds.left}px`;\n flexibleHighlightEl.style.top = `${bounds.top}px`;\n flexibleHighlightEl.style.width = `${bounds.width}px`;\n flexibleHighlightEl.style.height = `${bounds.height}px`;\n } else if (flexibleHighlightEl) {\n flexibleHighlightEl.style.display = 'none';\n flexibleHighlightEl.style.visibility = 'hidden';\n }\n };\n\n const hideIndicator = () => {\n if (indicatorEl) {\n indicatorEl.style.display = 'none';\n indicatorEl.style.visibility = 'hidden';\n }\n if (flexibleHighlightEl) {\n flexibleHighlightEl.style.display = 'none';\n flexibleHighlightEl.style.visibility = 'hidden';\n }\n };\n\n Object.assign(window.FlowCanvas, {\n findDropTarget,\n showIndicator,\n hideIndicator,\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/col-resize.js\">\n/**\n * @fileoverview Column resize via draggable divider.\n *\n * Attaches pointer handlers to the canvas (capture phase, so they run before\n * inline-editor.js's bubble-phase handlers). Drag a .cs-line-divider to resize\n * the two adjacent columns; their combined width is preserved.\n *\n * Exposes:\n * window.FlowCanvas.initColResize(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const COL_MIN_WIDTH = (window.CanvasConfig?.column?.minWidth) ?? 60;\n\n window.FlowCanvas.initColResize = function (canvas) {\n let colResize = null;\n\n canvas.addEventListener('pointerdown', (event) => {\n const divider = event.target.closest?.('.cs-line-divider');\n if (!divider) return;\n\n event.preventDefault();\n event.stopPropagation();\n event.stopImmediatePropagation();\n\n const prevCol = divider.previousElementSibling;\n const nextCol = divider.nextElementSibling;\n if (!prevCol || !nextCol || !prevCol.matches('.col-item') || !nextCol.matches('.col-item')) return;\n\n const prevRect = prevCol.getBoundingClientRect();\n const nextRect = nextCol.getBoundingClientRect();\n const totalWidth = prevRect.width + nextRect.width;\n const startX = event.clientX;\n const prevStartWidth = prevRect.width;\n\n const row = divider.closest('.row-item');\n if (row) {\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n cols.forEach(c => c.dataset.startWidth = c.getBoundingClientRect().width);\n cols.forEach(c => c.style.flex = `${c.dataset.startWidth} 0 0`);\n }\n\n divider.classList.add('cs-line-divider--active');\n try { divider.setPointerCapture?.(event.pointerId); } catch (e) { }\n\n colResize = { prevCol, nextCol, totalWidth, startX, prevStartWidth, divider, pointerId: event.pointerId };\n }, true);\n\n canvas.addEventListener('pointermove', (event) => {\n if (!colResize) return;\n const { prevCol, nextCol, totalWidth, startX, prevStartWidth } = colResize;\n const dx = event.clientX - startX;\n\n const prevW = Math.max(COL_MIN_WIDTH, Math.min(totalWidth - COL_MIN_WIDTH, prevStartWidth + dx));\n const nextW = totalWidth - prevW;\n\n prevCol.style.flex = `${prevW} 0 0`;\n nextCol.style.flex = `${nextW} 0 0`;\n }, true);\n\n const endResize = () => {\n if (!colResize) return;\n colResize.divider.classList.remove('cs-line-divider--active');\n try { colResize.divider.releasePointerCapture?.(colResize.pointerId); } catch (e) { }\n colResize = null;\n };\n canvas.addEventListener('pointerup', endResize, true);\n canvas.addEventListener('pointercancel', endResize, true);\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/section-canvas.js\">\n/**\n * @fileoverview Section content area — one-time migration.\n *\n * Sections used to render as absolute-positioning mini-canvases (every\n * dropped child got `position: absolute`). They now render as row/col\n * flow containers like the doc root, so the section height grows\n * naturally as content stretches (eg. a table picking up more rows\n * from a {% for %} loop). Drop placement is handled centrally in\n * `drop-zones.js` + `row-col-builder.js`.\n *\n * This file now only contains a startup migration: any old block left\n * over with `position: absolute` inside a section is rehomed into a\n * fresh row/col pair and stripped of its inline coordinates. Without\n * this, previously-saved documents would render with their old\n * absolute layout sticking out of the new flow box.\n *\n * Exposes: window.FlowCanvas.migrateLegacySectionLayouts()\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const makeRow = () => {\n if (typeof window.FlowCanvas.makeRow === 'function') {\n return window.FlowCanvas.makeRow();\n }\n const row = document.createElement('div');\n row.className = 'row-item';\n window.FlowCanvas.assignNodeId?.(row, 'row');\n return row;\n };\n const makeCol = () => {\n if (typeof window.FlowCanvas.makeCol === 'function') {\n return window.FlowCanvas.makeCol();\n }\n const col = document.createElement('div');\n col.className = 'col-item';\n col.style.flex = '1 1 0';\n window.FlowCanvas.assignNodeId?.(col, 'col');\n return col;\n };\n\n const stripAbsolute = (block) => {\n block.style.position = '';\n block.style.left = '';\n block.style.top = '';\n block.style.width = '';\n block.style.maxWidth = '';\n delete block.dataset.csInSection;\n };\n\n window.FlowCanvas.migrateLegacySectionLayouts = function () {\n document.querySelectorAll('.section-container-content').forEach((section) => {\n // Clear any leftover minHeight/position from the absolute era — flow\n // layout sizes the section by content alone.\n section.style.position = '';\n section.style.minHeight = '';\n section.style.height = '';\n\n // The outer .cs_block_s wrapper used to store a fixed height back\n // when sections rendered as absolute mini-canvases (so the user's\n // last manual resize was preserved). With flow layout the wrapper\n // must size with its child rows — strip any inline height/min-\n // height so the table can push the section down naturally.\n const wrapper = section.closest('.cs_block_s');\n if (wrapper) {\n wrapper.style.height = '';\n wrapper.style.minHeight = '';\n }\n\n // Pull every legacy absolute child, sort by visual top so the\n // resulting flow preserves the user's vertical intent, then rebuild\n // as one block per row inside the section.\n const legacy = Array.from(section.children).filter((c) => {\n return c.classList?.contains('cs_block_s') &&\n (c.dataset?.csInSection === '1' || c.style?.position === 'absolute');\n });\n if (!legacy.length) return;\n\n legacy.sort((a, b) => (parseFloat(a.style.top) || 0) - (parseFloat(b.style.top) || 0));\n legacy.forEach((block) => {\n stripAbsolute(block);\n const row = makeRow();\n const col = makeCol();\n col.appendChild(block);\n row.appendChild(col);\n section.appendChild(row);\n });\n });\n };\n\n // Run once at startup; the cleanup observer (initCleanupObserver) handles\n // ongoing structural maintenance.\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', window.FlowCanvas.migrateLegacySectionLayouts);\n } else {\n window.FlowCanvas.migrateLegacySectionLayouts();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/cleanup-observer.js\">\n/**\n * @fileoverview Auto-cleanup of empty columns and rows.\n *\n * When a block is removed (Delete key, postMessage, manual remove, etc.),\n * walk the document and:\n * - Remove any column with no block content.\n * - Redistribute remaining columns' flex so survivors reclaim the freed width.\n * - Remove any row with no columns.\n *\n * Exposes:\n * window.FlowCanvas.cleanupEmpty(doc) — run cleanup pass (idempotent)\n * window.FlowCanvas.initCleanupObserver(doc) — start watching for removals\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n let running = false;\n\n const colHasContent = (col) => {\n return !!col.querySelector('.cs_block_s, .canvas-block');\n };\n\n // Clean every flow root in the tree: the doc itself plus each\n // .section-container-content (sections now act as nested flow\n // canvases, so they accumulate empty rows/cols the same way).\n const cleanupOneRoot = (root) => {\n let changed = false;\n const rows = Array.from(root.querySelectorAll(':scope > .row-item'));\n for (const row of rows) {\n if (row.matches('.cs-page-header, .cs-page-footer')) continue;\n\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n let removedAny = false;\n\n // Remove empty columns\n for (const col of cols) {\n if (!colHasContent(col)) {\n col.remove();\n removedAny = true;\n changed = true;\n }\n }\n\n // If columns were removed, rebuild dividers and flex layout\n if (removedAny) {\n window.FlowCanvas.rebuildDividers?.(row);\n window.FlowCanvas.resetColFlex?.(row);\n }\n\n // After cleanup, remove orphaned dividers (shouldn't happen but be safe)\n const remainingCols = row.querySelectorAll(':scope > .col-item');\n if (remainingCols.length === 0) {\n // No columns left - remove all dividers and the row\n row.querySelectorAll(':scope > .cs-line-divider').forEach(d => d.remove());\n row.remove();\n changed = true;\n } else if (removedAny) {\n // Double-check that divider count matches column count (n-1 dividers for n columns)\n const dividerCount = row.querySelectorAll(':scope > .cs-line-divider').length;\n const columnCount = remainingCols.length;\n const expectedDividerCount = Math.max(0, columnCount - 1);\n if (dividerCount !== expectedDividerCount) {\n // Divider count mismatch - rebuild again\n window.FlowCanvas.rebuildDividers?.(row);\n changed = true;\n }\n }\n }\n return changed;\n };\n\n const cleanupEmpty = (doc) => {\n if (running) return false;\n running = true;\n let changed = false;\n try {\n changed = cleanupOneRoot(doc) || changed;\n // Every flow root in the tree needs its own pass. Rows live directly\n // under a `.cs_margin` page wrapper, so when cleanup is invoked with the\n // canvas (`.custom-form-design`) as `doc` — as the Delete-key / badge\n // path does via getCanvas() — `cleanupOneRoot(doc)` finds no `:scope >\n // .row-item` and top-level empty columns would survive (showing the\n // \"Drop block here\" placeholder). Include `.cs_margin` here so that path\n // reaches them too. (When `doc` is already a `.cs_margin`, querySelectorAll\n // only matches descendants, so there's no double pass.)\n doc.querySelectorAll('.cs_margin, .body-main-content, .section-container-content').forEach((container) => {\n changed = cleanupOneRoot(container) || changed;\n });\n } finally {\n running = false;\n }\n return changed;\n };\n\n const initCleanupObserver = (doc) => {\n const observer = new MutationObserver((mutations) => {\n let blockRemoved = false;\n for (const m of mutations) {\n if (m.type !== 'childList') continue;\n for (const node of m.removedNodes) {\n if (node.nodeType !== 1) continue;\n if (node.matches?.('.cs_block_s, .canvas-block') ||\n node.querySelector?.('.cs_block_s, .canvas-block')) {\n blockRemoved = true;\n break;\n }\n }\n if (blockRemoved) break;\n }\n if (blockRemoved) cleanupEmpty(doc);\n });\n observer.observe(doc, { childList: true, subtree: true });\n return observer;\n };\n\n Object.assign(window.FlowCanvas, {\n cleanupEmpty,\n initCleanupObserver,\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/block-reorder.js\">\n/**\n * @fileoverview Internal block drag-and-drop using pointer events.\n *\n * Why not HTML5 native drag? Native drag conflicts with Froala / inline-editor\n * pointerdown handlers on text content and has quirky drag-image behavior. We\n * use raw pointer events on dedicated chrome handles for reliability.\n *\n * UX:\n * - Every top-level flow block gets a `.cs-block-grip` (⋮⋮) handle in its\n * top-left corner (visible on hover).\n * - Mouse-down on the grip or selected-state `.cs-block-badge` starts a\n * tracked drag. The block goes 40%\n * transparent and we show the same blue drop indicator used by sidebar\n * drops (powered by drop-zones.js).\n * - On pointerup, the block is detached and re-inserted at the computed\n * drop target. Cleanup observer prunes empty columns.\n *\n * Supports all four drop zone kinds (between-rows, col-edge, in-col, in-section).\n *\n * Exposes:\n * window.FlowCanvas.initBlockReorder(canvas, doc)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const isFlowBlock = (el) => {\n return el && el.matches?.('.cs_block_s, .canvas-block') &&\n el.closest('.cs-flow-canvas') &&\n !el.dataset.csInSection &&\n el.parentElement?.matches?.('.col-item');\n };\n\n const ensureGrip = (block) => {\n if (block.querySelector(':scope > .cs-block-grip')) return;\n const grip = document.createElement('div');\n grip.className = 'cs-block-grip';\n grip.setAttribute('data-cs-chrome', '');\n grip.setAttribute('title', 'Drag to reorder');\n // 6-dot grip pattern — the universal \"drag to move\" affordance.\n grip.innerHTML = `\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"currentColor\" aria-hidden=\"true\">\n <circle cx=\"4\" cy=\"3\" r=\"1.4\"/>\n <circle cx=\"10\" cy=\"3\" r=\"1.4\"/>\n <circle cx=\"4\" cy=\"7\" r=\"1.4\"/>\n <circle cx=\"10\" cy=\"7\" r=\"1.4\"/>\n <circle cx=\"4\" cy=\"11\" r=\"1.4\"/>\n <circle cx=\"10\" cy=\"11\" r=\"1.4\"/>\n </svg>`;\n block.appendChild(grip);\n };\n\n const ensureGripsOnAll = (doc) => {\n doc.querySelectorAll('.col-item > .cs_block_s, .col-item > .canvas-block').forEach(ensureGrip);\n };\n\n const findReorderHandle = (target) => {\n // Badge action buttons (move/duplicate/delete) live inside the badge but\n // are clicks, not drag handles — never start a reorder on them.\n if (target?.closest?.('[data-cs-action]')) return null;\n return target?.closest?.('.cs-block-grip, .cs-block-badge') || null;\n };\n\n window.FlowCanvas.initBlockReorder = function (canvas, doc) {\n ensureGripsOnAll(doc);\n const observer = new MutationObserver(() => ensureGripsOnAll(doc));\n observer.observe(doc, { childList: true, subtree: true });\n\n const FC = window.FlowCanvas;\n let drag = null; // { block, grip, pointerId }\n\n // ---- pointerdown on a reorder handle ----\n canvas.addEventListener('pointerdown', (event) => {\n const handle = findReorderHandle(event.target);\n if (!handle) return;\n const block = handle.closest?.('.cs_block_s, .canvas-block');\n if (!block || !isFlowBlock(block)) return;\n\n // Lone block on the page → nowhere to drop. Don't start a drag (and don't\n // swallow the event) so no pointless blue drop-indicator line appears.\n if (FC.canReorder && !FC.canReorder(block)) return;\n\n event.preventDefault();\n event.stopPropagation();\n\n drag = { block, grip: handle, pointerId: event.pointerId };\n block.classList.add('cs-block--dragging');\n handle.setPointerCapture?.(event.pointerId);\n canvas.style.cursor = 'grabbing';\n }, true);\n\n // ---- pointermove: compute drop target and show indicator ----\n canvas.addEventListener('pointermove', (event) => {\n if (!drag) return;\n const result = FC.findDropTarget?.(doc, canvas, event.clientX, event.clientY);\n if (result) {\n FC.showIndicator?.(result.indicator);\n canvas._pendingReorderTarget = result.target;\n } else {\n FC.hideIndicator?.();\n canvas._pendingReorderTarget = null;\n }\n });\n\n // ---- pointerup: place block at target ----\n const finishDrag = (event) => {\n if (!drag) return;\n const { block, grip, pointerId } = drag;\n try { grip.releasePointerCapture?.(pointerId); } catch (e) { }\n block.classList.remove('cs-block--dragging');\n canvas.style.cursor = '';\n FC.hideIndicator?.();\n\n const target = canvas._pendingReorderTarget;\n canvas._pendingReorderTarget = null;\n drag = null;\n\n if (!target) return;\n\n // Detach from old parent, reinsert at new location.\n block.remove();\n FC.placeBlock?.(doc, block, target);\n };\n\n canvas.addEventListener('pointerup', finishDrag);\n canvas.addEventListener('pointercancel', finishDrag);\n\n // ---- cancel on Escape ----\n document.addEventListener('keydown', (event) => {\n if (event.key !== 'Escape' || !drag) return;\n drag.block.classList.remove('cs-block--dragging');\n canvas.style.cursor = '';\n FC.hideIndicator?.();\n canvas._pendingReorderTarget = null;\n drag = null;\n });\n };\n\n // ---------------------------------------------------------------------------\n // Programmatic move (used by the block badge \"move up / down\" actions).\n //\n // - If the block shares its column with siblings → reorder within the column.\n // - Otherwise (single block in the column) → move the whole ROW up / down\n // among its sibling rows. This matches the common single-column document\n // flow where each block sits on its own row.\n // ---------------------------------------------------------------------------\n const directBlocks = (col) => (\n col ? Array.from(col.children).filter((c) => c.matches?.('.cs_block_s, .canvas-block')) : []\n );\n\n const siblingRows = (parent) => (\n parent ? Array.from(parent.children).filter(\n (c) => c.matches?.('.row-item') && !c.matches('.cs-page-header, .cs-page-footer')\n ) : []\n );\n\n const siblingCols = (row) => (\n row ? Array.from(row.children).filter((c) => c.matches?.('.col-item')) : []\n );\n\n window.FlowCanvas.moveBlock = function (block, dir) {\n if (!block || (dir !== 'up' && dir !== 'down')) return false;\n const col = block.closest('.col-item');\n if (!col) return false;\n\n const blocks = directBlocks(col);\n let moved = false;\n\n if (blocks.length > 1) {\n // Reorder within the column.\n const i = blocks.indexOf(block);\n if (dir === 'up' && i > 0) { blocks[i - 1].before(block); moved = true; }\n if (dir === 'down' && i < blocks.length - 1) { blocks[i + 1].after(block); moved = true; }\n } else {\n // Move the whole row among its siblings.\n const row = block.closest('.row-item');\n const parent = row?.parentElement;\n const rows = siblingRows(parent);\n const i = rows.indexOf(row);\n if (dir === 'up' && i > 0) { rows[i - 1].before(row); moved = true; }\n if (dir === 'down' && i >= 0 && i < rows.length - 1) { rows[i + 1].after(row); moved = true; }\n }\n\n if (moved) {\n block.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n }\n return moved;\n };\n\n // True when there is somewhere to drag the block TO:\n // - the column holds more than one block (reorder within the column), OR\n // - the row has more than one column (drag between columns), OR\n // - there is more than one movable row (drag between rows).\n // ONLY a lone block — one row, one column, one block — has none of these, so\n // the drag handler skips starting a drag (no drop-indicator highlight). A row\n // with multiple columns DOES allow reorder, so the column highlight shows.\n window.FlowCanvas.canReorder = function (block) {\n if (!block) return false;\n const col = block.closest('.col-item');\n if (!col) return false;\n if (directBlocks(col).length > 1) return true;\n const row = block.closest('.row-item');\n if (siblingCols(row).length > 1) return true;\n return siblingRows(row?.parentElement).length > 1;\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/field-panel.js\">\n/**\n * @fileoverview Field bridge — emits the list of bindable fields for the\n * currently selected repeater block to the parent Angular app via postMessage.\n *\n * The parent (app.ts / app.html) renders the actual field chips inside its\n * existing properties side panel. This module only computes the list and\n * notifies the parent when the selection changes.\n *\n * Nested-repeat scoping\n * ---------------------\n * When the selected block sits *inside* one or more ancestor repeaters, the\n * suggested expressions are written relative to the innermost ancestor alias:\n *\n * {% for visit in mainContent.visitDetails %}\n * {% for feedback in visit.arriveOnSiteFeedback %}\n * {{ feedback.answer }} ← scope = feedback (innermost)\n * {% endfor %}\n * {% endfor %}\n *\n * Nested arrays found inside the alias scope (e.g. `arriveOnSiteFeedback` inside\n * each `visit`) are exposed as `kind: 'array'` rows so the parent UI can offer\n * them as binding targets for a child repeater.\n *\n * Message contract:\n * { source: 'custom-form-twig', type: 'fields:available',\n * data: { repeatPath, repeatAlias, fields: [{key, kind, expr, arrayPath?}, ...] } }\n * { source: 'custom-form-twig', type: 'fields:cleared' }\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n // -------------------------------------------------------------------------\n // Binding-data lookup (read from parent window)\n // -------------------------------------------------------------------------\n const getBindingData = () => {\n try {\n const getter = window.parent?.__BROCHURE_FLOW_GET_BINDING_DATA__;\n if (typeof getter === 'function') return getter();\n return window.parent?.__BROCHURE_FLOW_BINDING_DATA__ ?? null;\n } catch (e) { return null; }\n };\n\n const resolvePath = (data, path) => {\n if (!data || !path) return undefined;\n const parts = path.split('.');\n let cur = data;\n for (const p of parts) {\n if (cur == null) return undefined;\n cur = cur[p];\n }\n return cur;\n };\n\n // From an array, sample the first item — that is what we use to infer the\n // shape of each iteration.\n const sampleItem = (arr) => (Array.isArray(arr) && arr.length > 0) ? arr[0] : null;\n\n // For a given sample object, return scalar fields and nested-array fields\n // separately. The scope is the alias the parent {% for %} introduces so all\n // returned `expr` values are alias-relative.\n const buildFieldsForScope = (sample, alias) => {\n if (!sample || typeof sample !== 'object') return [];\n return Object.keys(sample).map((key) => {\n const value = sample[key];\n const kind = Array.isArray(value)\n ? 'array'\n : (value !== null && typeof value === 'object') ? 'object' : 'value';\n\n const out = { key, kind, expr: `{{ ${alias}.${key} }}` };\n\n if (kind === 'array') {\n out.arrayPath = `${alias}.${key}`;\n out.count = value.length;\n const inner = sampleItem(value);\n out.preview = inner && typeof inner === 'object'\n ? Object.keys(inner).slice(0, 3).join(', ')\n : '';\n }\n return out;\n });\n };\n\n // -------------------------------------------------------------------------\n // Ancestor-repeater chain.\n //\n // Walks UP from a selected block collecting EVERY ancestor that carries a\n // `data-repeat-path`. The innermost (closest to the selection) becomes the\n // active scope; the rest let us resolve nested-alias paths against real\n // sample data.\n // -------------------------------------------------------------------------\n // Walk UP from a block, collecting every ancestor repeater's chain. A block\n // can carry either a single binding (data-repeat-path + -alias) OR a full\n // multi-level chain (data-repeat-chain = JSON). The multi-level form expands\n // into multiple chain entries, so a child block dropped inside sees ALL the\n // alias namespaces its parent introduces.\n const parseChainAttr = (json) => {\n if (!json) return null;\n try {\n const parsed = JSON.parse(json);\n if (!Array.isArray(parsed)) return null;\n return parsed.filter((s) => s && s.path && s.alias);\n } catch (e) { return null; }\n };\n\n const findRepeaterChain = (block) => {\n const chain = [];\n if (!block) return chain;\n let cur = block;\n while (cur) {\n const multi = parseChainAttr(cur.dataset?.repeatChain);\n if (multi && multi.length) {\n // Push in reverse so the final chain.reverse() below restores the\n // outermost-first order. Spread each step so extra fields like\n // `kind: 'map'` and `keyAlias` survive — the chain resolver and\n // twig generator both need them.\n for (let i = multi.length - 1; i >= 0; i--) {\n chain.push({ ...multi[i] });\n }\n } else if (cur.dataset?.repeatPath) {\n chain.push({\n path: cur.dataset.repeatPath,\n alias: cur.dataset.repeatAlias || 'item'\n });\n }\n if (cur.matches?.('.cs_margin, .cs-flow-canvas') || cur.tagName === 'BODY') break;\n cur = cur.parentElement;\n }\n // chain is currently innermost-first; reverse to outermost-first, then\n // dedupe steps whose path was already seen. Modal-saved chains on a\n // child block typically include the same outer loops their ancestor\n // section also carries — without dedup, resolveChainSample iterates\n // the same array twice which works but is wasteful.\n const reversed = chain.reverse();\n const seen = new Set();\n const out = [];\n for (const step of reversed) {\n if (seen.has(step.path)) continue;\n seen.add(step.path);\n out.push(step);\n }\n return out;\n };\n\n // Given a repeater chain like\n // [{ path: 'mainContent.visitDetails', alias: 'visit' },\n // { path: 'visit.arriveOnSiteFeedback', alias: 'feedback' }]\n // resolve the chain against real binding data and return the sample item\n // representing one iteration of the innermost loop.\n const resolveChainSample = (chain, bindingData) => {\n if (!chain.length) return null;\n\n let sample = null;\n let arr = null;\n const aliasNamespace = {};\n\n for (const step of chain) {\n const path = step.path;\n const firstSegment = path.split('.')[0];\n let base;\n let remainder;\n if (Object.prototype.hasOwnProperty.call(aliasNamespace, firstSegment)) {\n base = aliasNamespace[firstSegment];\n remainder = path.slice(firstSegment.length + 1);\n } else {\n base = bindingData;\n remainder = path;\n }\n\n arr = remainder ? resolvePath(base, remainder) : base;\n // Map step: the path resolves to a date-keyed object whose values\n // are arrays. The \"sample\" of one iteration is the FIRST value\n // (an array of labour items), which the next step will then\n // sample further.\n if (step.kind === 'map' && arr && typeof arr === 'object' && !Array.isArray(arr)) {\n const firstKey = Object.keys(arr)[0];\n sample = firstKey != null ? arr[firstKey] : null;\n } else {\n sample = sampleItem(arr);\n }\n if (!sample) return null;\n aliasNamespace[step.alias] = sample;\n }\n\n return sample;\n };\n\n // -------------------------------------------------------------------------\n // Message helpers\n // -------------------------------------------------------------------------\n let lastSentKey = null;\n\n const sendFields = (chain) => {\n const innermost = chain[chain.length - 1];\n const data = getBindingData();\n const sample = resolveChainSample(chain, data);\n\n // Map-step terminus: the innermost saved step iterates a date-keyed\n // object whose values are arrays. Field chips for \"an array\" would\n // just be numeric indices, which isn't useful — what the user\n // actually wants is the shape of one labour item. So when the\n // resolved sample is an Array, we sample its first item for fields\n // and report the alias unchanged (the same alias they'd use in the\n // implicit inner loop).\n let displaySample = sample;\n if (Array.isArray(sample)) {\n displaySample = sample.length ? sample[0] : null;\n }\n\n const fields = displaySample ? buildFieldsForScope(displaySample, innermost.alias) : [];\n\n const key = `${innermost.path}::${innermost.alias}::${fields.length}::${chain.length}`;\n if (key === lastSentKey) return;\n lastSentKey = key;\n\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'fields:available',\n data: {\n repeatPath: innermost.path,\n repeatAlias: innermost.alias,\n fields,\n ancestorChain: chain\n }\n }, '*');\n } catch (e) { /* ignore */ }\n };\n\n const sendCleared = () => {\n if (lastSentKey === null) return;\n lastSentKey = null;\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'fields:cleared'\n }, '*');\n } catch (e) { /* ignore */ }\n };\n\n // -------------------------------------------------------------------------\n // Selection watcher\n // -------------------------------------------------------------------------\n const checkSelection = () => {\n const selected = document.querySelector(\n '.cs-flow-canvas .cs_block_s.cs-selected, ' +\n '.cs-flow-canvas .cs_block_s.cs-editing'\n );\n const chain = findRepeaterChain(selected);\n if (chain.length) {\n sendFields(chain);\n } else if (selected) {\n // Block is selected but not inside a repeater — show root-level variables\n const bindingData = getBindingData();\n if (bindingData && typeof bindingData === 'object') {\n const rootFields = buildFieldsForScope(bindingData, 'mainContent');\n const key = `root::mainContent::${rootFields.length}::0`;\n if (key !== lastSentKey) {\n lastSentKey = key;\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'fields:available',\n data: {\n repeatPath: '',\n repeatAlias: 'mainContent',\n fields: rootFields,\n ancestorChain: []\n }\n }, '*');\n } catch (e) { /* ignore */ }\n }\n }\n } else {\n sendCleared();\n }\n };\n\n window.FlowCanvas.initFieldPanel = function (canvas) {\n const observer = new MutationObserver(() => {\n requestAnimationFrame(checkSelection);\n });\n observer.observe(canvas, {\n subtree: true,\n attributes: true,\n attributeFilter: ['class', 'data-repeat-path', 'data-repeat-alias']\n });\n checkSelection();\n };\n\n // -------------------------------------------------------------------------\n // Tree builder for the binding modal — walks every array reachable from a\n // starting scope, including arrays nested inside other arrays, and emits\n // indented rows. Each row carries the *full chain* needed to reproduce that\n // path at runtime so the twig generator can wrap the block in multiple\n // {% for %} loops.\n //\n // Each row shape:\n // {\n // path: 'visit.arriveOnSiteFeedback', // display path (relative to scope)\n // fullPath: 'mainContent.visitDetails[0].arriveOnSiteFeedback', // for debug\n // count: 5,\n // preview: 'name, answered, answer',\n // depth: 1, // 0 = top-level item under scope\n // chain: [ // every for-loop needed to reach here\n // { path: 'mainContent.visitDetails', alias: 'visit' },\n // { path: 'visit.arriveOnSiteFeedback', alias: '__leaf__' } // alias replaced on apply\n // ],\n // scope: 'root' | 'ancestor'\n // }\n //\n // `seedChain` lets the caller prefix every emitted row with parent for-loops\n // that already exist (when scoping from an ancestor section).\n // -------------------------------------------------------------------------\n const defaultAliasFor = (key, depth) => {\n // Heuristic singularize: drop trailing 's' / 'es' / 'ies'. Falls back to\n // the original key if nothing matches. Aliases are only PLACEHOLDERS —\n // the user can rename the leaf alias in the modal; intermediate aliases\n // stay as-is.\n if (!key) return `item${depth}`;\n if (/ies$/i.test(key)) return key.slice(0, -3) + 'y';\n if (/[^aeiou]es$/i.test(key)) return key.slice(0, -2);\n if (/s$/i.test(key) && key.length > 2) return key.slice(0, -1);\n return key;\n };\n\n // Detect whether an object is a \"map of arrays\" — i.e. its values are\n // all arrays. This is the shape Twig iterates with\n // {% for key, list in obj %}\n // For these we emit ONE loopable row (with key+value aliases) plus a\n // child row representing the inner array's items.\n const isMapOfArrays = (obj) => {\n if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;\n const keys = Object.keys(obj);\n if (!keys.length) return false;\n return keys.every((k) => Array.isArray(obj[k]));\n };\n\n const buildFullArrayTree = (sample, scopeAlias, seedChain, scope) => {\n const rows = [];\n if (!sample || typeof sample !== 'object') return rows;\n\n // Walk every key. For nested arrays we emit a row and recurse into\n // their sample item. For nested plain objects we DON'T emit a row\n // but still recurse so deeper arrays surface (otherwise dot-paths\n // like `visit.labourTimeDetails.date[\"...\"]` would be invisible).\n // For map-of-arrays objects (date-keyed lists) we emit one row that\n // loops `key, value` pairs, plus a deeper row for the inner array.\n const walk = (obj, pathPrefix, chain, depth, recurseAlias) => {\n Object.keys(obj).forEach((key) => {\n const value = obj[key];\n const relPath = pathPrefix ? `${pathPrefix}.${key}` : key;\n\n if (Array.isArray(value)) {\n const inner = sampleItem(value);\n const childAlias = defaultAliasFor(key, depth + 1);\n const row = {\n path: relPath,\n count: value.length,\n preview: inner && typeof inner === 'object'\n ? Object.keys(inner).slice(0, 3).join(', ')\n : String(inner ?? ''),\n depth,\n chain: [\n ...chain,\n { path: relPath, alias: childAlias }\n ],\n scope\n };\n rows.push(row);\n if (inner && typeof inner === 'object') {\n walk(inner, childAlias, row.chain, depth + 1, childAlias);\n }\n return;\n }\n\n if (value && typeof value === 'object') {\n // Map-of-arrays (eg. labourTimeDetails.date) → emit ONE\n // composite loopable row. Selecting it produces TWO nested\n // for-loops in the generated twig: an outer\n // {% for key, list in path %}\n // pair, plus an inner\n // {% for item in list %}\n // so the user can immediately bind the inner item's fields\n // without having to pick two rows. The user's \"Loop variable\n // name\" input edits the INNER alias (the actual row variable\n // they'll reference in cells).\n if (isMapOfArrays(value)) {\n const sampleKey = Object.keys(value)[0];\n const innerArr = value[sampleKey];\n const keyAlias = defaultAliasFor(key + 'Key', depth + 1);\n const valueAlias = defaultAliasFor(key, depth + 1) + 's';\n const innerSample = sampleItem(innerArr);\n const innerAlias = defaultAliasFor(key + 'Item', depth + 2);\n const fullChain = [\n ...chain,\n { path: relPath, alias: valueAlias, keyAlias, kind: 'map' },\n { path: valueAlias, alias: innerAlias },\n ];\n const row = {\n path: relPath,\n count: innerArr.length,\n preview: innerSample && typeof innerSample === 'object'\n ? Object.keys(innerSample).slice(0, 3).join(', ')\n : String(innerSample ?? ''),\n depth,\n chain: fullChain,\n scope,\n kind: 'map'\n };\n rows.push(row);\n\n if (innerSample && typeof innerSample === 'object') {\n walk(innerSample, innerAlias, fullChain, depth + 1, innerAlias);\n }\n return;\n }\n\n // Plain nested object — descend without emitting a row.\n walk(value, relPath, chain, depth, recurseAlias);\n }\n });\n };\n\n walk(sample, scopeAlias, seedChain, 0, scopeAlias);\n return rows;\n };\n\n // Public utility — also used by custom-form.js to compute scoped arrays for\n // the binding modal when a user is dropping a new repeater inside an existing\n // repeater scope. Walks the ancestor chain ABOVE the dropped block (not\n // including itself), resolves each alias against real binding data, and\n // returns the arrays that exist inside the innermost ancestor's iteration.\n window.FlowCanvas.computeScopedArrays = function (block, bindingData) {\n const ancestor = block?.parentElement || null;\n const chain = findRepeaterChain(ancestor);\n if (!chain.length) return null;\n\n const innermost = chain[chain.length - 1];\n const sample = resolveChainSample(chain, bindingData);\n if (!sample) return { alias: innermost.alias, arrays: [] };\n\n // Tree-aware: full nested-array tree relative to the innermost ancestor.\n // Seed chain = the ancestor for-loops that already exist; rows append on\n // top of those so the twig generator can produce the full nested\n // {% for %} stack from a single block.\n const seedChain = chain.map((s) => ({ path: s.path, alias: s.alias }));\n const arrays = buildFullArrayTree(sample, innermost.alias, seedChain, 'ancestor');\n\n return { alias: innermost.alias, arrays };\n };\n\n // Build the FULL tree starting from root binding data — used when a block\n // is dropped on the canvas root (no ancestor repeater).\n window.FlowCanvas.buildRootArrayTree = function (bindingData) {\n if (!bindingData || typeof bindingData !== 'object') return [];\n // Walk the object tree looking for arrays. When we find one, emit it as\n // a depth-0 row and recurse into its first item for deeper arrays.\n const rows = [];\n\n const walkObject = (obj, pathPrefix) => {\n if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;\n Object.keys(obj).forEach((key) => {\n const value = obj[key];\n const nextPath = pathPrefix ? `${pathPrefix}.${key}` : key;\n if (Array.isArray(value)) {\n const inner = sampleItem(value);\n const alias = defaultAliasFor(key, 1);\n const row = {\n path: nextPath,\n count: value.length,\n preview: inner && typeof inner === 'object'\n ? Object.keys(inner).slice(0, 3).join(', ')\n : String(inner ?? ''),\n depth: 0,\n chain: [{ path: nextPath, alias }],\n scope: 'root'\n };\n rows.push(row);\n // Recurse into first item to surface deeper arrays.\n if (inner && typeof inner === 'object') {\n const deeper = buildFullArrayTree(inner, alias, row.chain, 'root');\n // Bump depths so they're relative to this top-level row.\n deeper.forEach((d) => { d.depth = d.depth + 1; });\n rows.push(...deeper);\n }\n } else if (value && typeof value === 'object') {\n walkObject(value, nextPath);\n }\n });\n };\n\n walkObject(bindingData, '');\n return rows;\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/history-manager.js\">\n/**\n * @fileoverview Canvas history manager — undo / redo for block-level\n * actions (add, remove, move, binding changes, page splits).\n *\n * Design (DOM-snapshot, observer-driven):\n *\n * 1. We observe the canvas tree with a MutationObserver. Whenever\n * blocks / rows / cols / pages are added or removed (or their\n * data-repeat-* / data-twig-* attributes change), we take a\n * snapshot of the canvas innerHTML.\n *\n * 2. A burst of mutations from a single user action (eg. dropping a\n * block creates a row + col + block all at once) is collapsed\n * into ONE history entry via a 300ms debounce.\n *\n * 3. Undo restores the canvas innerHTML to the previous snapshot.\n * Redo restores to the next one.\n *\n * 4. During a restore we set `suspended = true` so the observer\n * doesn't record the restoration itself as a new action.\n *\n * 5. We DON'T track inline text edits — Froala owns its own undo\n * stack for that. (Text edits arrive as `characterData` mutations,\n * which we ignore.)\n *\n * Why DOM snapshot instead of command pattern?\n *\n * The codebase has many mutation sites scattered across modules\n * (placeBlock, page splits, binding-modal apply, reorder, cleanup\n * observer, etc.). Instrumenting every one is invasive and easy to\n * forget — a missed site = silently broken undo. Observing the DOM\n * covers EVERY mutation uniformly without touching any existing\n * logic, which the user explicitly asked for.\n *\n * Memory: snapshots cap at HISTORY_LIMIT entries (default 50). At ~50KB\n * per snapshot for a typical document, worst case is ~2.5MB — well\n * within budget.\n *\n * Exposes:\n * window.FlowCanvas.initHistory(canvas)\n * window.FlowCanvas.undo()\n * window.FlowCanvas.redo()\n * window.FlowCanvas.suspendHistory(fn) — run fn without recording\n * window.FlowCanvas.getHistoryState() — { undoCount, redoCount }\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const HISTORY_LIMIT = 50;\n const COMMIT_DEBOUNCE_MS = 300;\n\n // Snapshot stacks. `past` holds older states (the most recent is the\n // one we'd revert to on undo). `future` holds states the user undid;\n // any new action clears `future` (standard editor behaviour).\n const past = [];\n const future = [];\n let baseline = ''; // the snapshot the canvas currently matches\n let canvasRef = null; // root we snapshot\n\n let suspended = false;\n let pendingCommit = null;\n\n // Take a fresh snapshot of the canvas. Returns the HTML string we'd\n // restore on undo. We snapshot innerHTML of the canvas (which\n // contains the cs_margin pages); the canvas element itself stays in\n // place so listeners attached to it survive the restore.\n const snapshot = () => canvasRef ? canvasRef.innerHTML : '';\n\n const restore = (html) => {\n if (!canvasRef) return;\n suspended = true;\n try {\n canvasRef.innerHTML = html;\n baseline = html;\n // Tell downstream observers (twig generator, field panel, etc.)\n // that the tree has changed via a synthetic mutation. They\n // already react to childList mutations on the canvas, which the\n // innerHTML assignment triggers naturally — no manual nudge\n // needed beyond clearing our suspend flag.\n } finally {\n // Let any synchronous mutation handlers finish before we resume,\n // otherwise their cleanup pass would record a fresh entry on\n // top of the restored state.\n requestAnimationFrame(() => { suspended = false; });\n }\n };\n\n // Commit the current canvas state to history. Called after the\n // debounce window expires for a burst of mutations.\n const commit = () => {\n pendingCommit = null;\n if (suspended || !canvasRef) return;\n const next = snapshot();\n if (next === baseline) return; // nothing actually changed\n past.push(baseline);\n if (past.length > HISTORY_LIMIT) past.shift();\n baseline = next;\n // Any new committed change invalidates the redo stack — once the\n // user diverges from the previous future, that future is gone.\n future.length = 0;\n };\n\n const scheduleCommit = () => {\n if (suspended) return;\n if (pendingCommit) clearTimeout(pendingCommit);\n pendingCommit = setTimeout(commit, COMMIT_DEBOUNCE_MS);\n };\n\n // Public: run `fn` without history capturing. Used by code that\n // performs migrations or other behind-the-scenes mutations that\n // shouldn't be exposed as undoable user actions.\n const suspendHistory = (fn) => {\n const wasSuspended = suspended;\n suspended = true;\n try { fn(); }\n finally {\n requestAnimationFrame(() => { suspended = wasSuspended; });\n }\n };\n\n const undo = () => {\n if (pendingCommit) {\n // Flush pending burst first so the user gets the most recent\n // state into the undo stack before stepping back.\n clearTimeout(pendingCommit);\n commit();\n }\n if (!past.length) return false;\n future.push(baseline);\n const prev = past.pop();\n restore(prev);\n return true;\n };\n\n const redo = () => {\n if (!future.length) return false;\n past.push(baseline);\n const next = future.pop();\n restore(next);\n return true;\n };\n\n const getHistoryState = () => ({\n undoCount: past.length,\n redoCount: future.length,\n });\n\n // ---------------------------------------------------------------------------\n // Init: attach the observer and the keyboard shortcuts.\n //\n // Watches childList (block/row/col add/remove) and selected attribute\n // changes (data-repeat-*, data-twig-if, data-page) so binding /\n // condition / page edits are also captured. Inline style and class\n // changes are ignored — they're cosmetic and would flood the stack\n // with selection / hover noise.\n // ---------------------------------------------------------------------------\n window.FlowCanvas.initHistory = function (canvas) {\n if (!canvas) return;\n canvasRef = canvas;\n // Defer the first baseline snapshot until other startup work has\n // finished (section migration, page creation, etc.). Otherwise the\n // user's very first action would have nothing to undo back to AND\n // any startup mutation would be recorded as a phantom user action.\n suspended = true;\n requestAnimationFrame(() => {\n baseline = snapshot();\n suspended = false;\n });\n\n const obs = new MutationObserver((mutations) => {\n if (suspended) return;\n // Reject mutations that are clearly NOT user-edits: characterData\n // (inline text edits — Froala territory) and style/class changes.\n let interesting = false;\n for (const m of mutations) {\n if (m.type === 'childList') {\n // Skip mutations that ONLY add/remove chrome elements —\n // they're our own decoration, not user content.\n const isChrome = (n) =>\n n.nodeType === 1 && (\n n.hasAttribute?.('data-cs-chrome') ||\n n.classList?.contains('cs-overflow-mark') ||\n n.classList?.contains('cs-block-grip') ||\n n.classList?.contains('cs-block-badge') ||\n n.classList?.contains('section-binding-info')\n );\n const added = Array.from(m.addedNodes).filter((n) => !isChrome(n));\n const removed = Array.from(m.removedNodes).filter((n) => !isChrome(n));\n if (added.length || removed.length) { interesting = true; break; }\n } else if (m.type === 'attributes') {\n interesting = true; break;\n }\n }\n if (interesting) scheduleCommit();\n });\n obs.observe(canvas, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\n 'data-repeat-path',\n 'data-repeat-alias',\n 'data-repeat-chain',\n 'data-twig-if',\n 'data-page',\n ],\n });\n\n // Keyboard shortcuts. We bind on document so the focus can be\n // anywhere in the iframe. If the user is typing inside a\n // contenteditable (Froala) we defer to its own undo handler.\n document.addEventListener('keydown', (e) => {\n const inEditable = e.target?.isContentEditable ||\n e.target?.tagName === 'INPUT' ||\n e.target?.tagName === 'TEXTAREA';\n if (inEditable) return;\n const ctrl = e.ctrlKey || e.metaKey;\n if (!ctrl) return;\n const key = e.key.toLowerCase();\n if (key === 'z' && !e.shiftKey) {\n e.preventDefault();\n undo();\n } else if (key === 'y' || (key === 'z' && e.shiftKey)) {\n e.preventDefault();\n redo();\n }\n });\n };\n\n window.FlowCanvas.undo = undo;\n window.FlowCanvas.redo = redo;\n window.FlowCanvas.suspendHistory = suspendHistory;\n window.FlowCanvas.getHistoryState = getHistoryState;\n})();\n\n<\/script>\n <script data-src=\"./js/flow/inline-insert.js\">\n/**\n * @fileoverview Hover-based inline insert control for the flow canvas.\n *\n * Shows a small \"+\" button on the left edge of the current insertion line.\n * Clicking it opens a block picker and inserts the selected block using the\n * same createBlock/placeBlock path as sidebar drag/drop.\n *\n * Exposes:\n * window.FlowCanvas.initInlineInsert(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n // Menu sections come straight from the shared block registry — add a block\n // there with `inInlineMenu: true` and it appears here automatically.\n const getInlineSections = () => (\n window.FormBlockRegistry?.sections('inInlineMenu') || []\n );\n\n const clamp = (value, min, max) => Math.min(Math.max(value, min), max);\n\n // How close to a column's left/right edge the pointer must be before we offer\n // a \"new column beside this block\" drop instead of an in-column insert.\n const COL_EDGE_GAP = (window.CanvasConfig?.dropZone?.colEdgeGap) ?? 24;\n\n // When the pointer is directly over a block's content the user almost always\n // wants an in-column insert, so we only flip to the vertical \"new column\"\n // mode within a much tighter band of the true edge. This stops the indicator\n // from jumping to vertical while casually hovering blocks in a multi-column\n // row (the full COL_EDGE_GAP still applies over a column's empty area).\n const COL_EDGE_GAP_OVER_BLOCK = 6;\n\n const directChildren = (root, selector) => {\n if (!root) return [];\n return Array.from(root.children).filter((child) => child.matches?.(selector));\n };\n\n const blockChildrenOfCol = (col) => (\n directChildren(col, '.cs_block_s, .canvas-block')\n );\n\n const rowChildrenOfRoot = (root) => (\n directChildren(root, '.row-item')\n );\n\n const isContentRoot = (node) => (\n !!node && (\n node.classList?.contains('cs_margin') ||\n node.classList?.contains('body-main-content') ||\n node.classList?.contains('section-container-content')\n )\n );\n\n const isInteractiveChrome = (target) => (\n !!target?.closest?.(\n '.cs-block-grip, .cs-line-divider, [data-cs-chrome], .fr-toolbar, .fr-popup, .fr-modal, .fr-tooltip'\n )\n );\n\n const resolveInColTarget = (col, clientY) => {\n const blocks = blockChildrenOfCol(col);\n if (!blocks.length) {\n return { target: { kind: 'in-col', col, beforeBlock: null } };\n }\n\n for (let i = 0; i < blocks.length; i++) {\n const rect = blocks[i].getBoundingClientRect();\n const mid = (rect.top + rect.bottom) / 2;\n if (clientY < mid) {\n return { target: { kind: 'in-col', col, beforeBlock: blocks[i] } };\n }\n }\n\n return { target: { kind: 'in-col', col, beforeBlock: null } };\n };\n\n // When the pointer sits near the left/right edge of a column, offer a\n // \"new column beside this block\" drop (col-edge) instead of an in-column\n // insert. Returns null when the pointer is comfortably inside the column.\n const resolveColEdge = (col, clientX, gap = COL_EDGE_GAP) => {\n const row = col.closest('.row-item');\n if (!row) return null;\n const rect = col.getBoundingClientRect();\n const cols = directChildren(row, '.col-item');\n\n if (clientX <= rect.left + gap) {\n return { target: { kind: 'col-edge', row, beforeCol: col } };\n }\n if (clientX >= rect.right - gap) {\n const idx = cols.indexOf(col);\n return { target: { kind: 'col-edge', row, beforeCol: cols[idx + 1] || null } };\n }\n return null;\n };\n\n const computeGeometry = (target, doc, clientX, clientY) => {\n if (!target || !doc) return null;\n\n if (target.kind === 'col-edge' && target.row) {\n const rowRect = target.row.getBoundingClientRect();\n const cols = directChildren(target.row, '.col-item');\n let x;\n if (target.beforeCol) {\n x = target.beforeCol.getBoundingClientRect().left;\n } else if (cols.length) {\n x = cols[cols.length - 1].getBoundingClientRect().right;\n } else {\n x = rowRect.left;\n }\n // The line spans the whole row height, but the \"+\" handle tracks the\n // pointer's Y (clamped inside the row) so it stays next to the cursor —\n // just like the horizontal/in-column case. Without this the handle pins\n // to the row's top corner and appears to jump away the moment the hover\n // switches from an in-column (horizontal) to a new-column (vertical)\n // insert.\n return {\n vertical: true,\n x,\n top: rowRect.top,\n bottom: rowRect.bottom,\n y: clamp(clientY, rowRect.top + 16, rowRect.bottom - 16),\n };\n }\n\n if (target.kind === 'in-col' && target.col) {\n const rect = target.col.getBoundingClientRect();\n const blocks = blockChildrenOfCol(target.col);\n let lineY = clamp(clientY, rect.top + 12, rect.bottom - 12);\n if (blocks.length) {\n if (target.beforeBlock) {\n lineY = target.beforeBlock.getBoundingClientRect().top;\n } else {\n lineY = blocks[blocks.length - 1].getBoundingClientRect().bottom;\n }\n }\n return {\n left: rect.left,\n right: rect.right,\n y: lineY,\n };\n }\n\n if (target.kind === 'between-rows') {\n const root = isContentRoot(target.parent) ? target.parent : doc;\n const rootRect = root.getBoundingClientRect();\n const rows = rowChildrenOfRoot(root).filter((row) => {\n return !row.matches('.cs-page-header, .cs-page-footer');\n });\n\n let lineY;\n if (!rows.length) {\n // Empty page: the very first insert always pins to the page top,\n // regardless of where the pointer is (so a hover in the centre still\n // drops the first block at the top). Once a block exists this branch\n // is skipped and the line follows the pointer / sits between rows.\n lineY = rootRect.top + 14;\n } else if (target.beforeRow) {\n lineY = target.beforeRow.getBoundingClientRect().top;\n } else {\n lineY = rows[rows.length - 1].getBoundingClientRect().bottom;\n }\n\n return {\n left: rootRect.left,\n right: rootRect.right,\n y: lineY,\n };\n }\n\n return null;\n };\n\n const renderMenuSections = (menuEl, onChoose) => {\n menuEl.innerHTML = '';\n getInlineSections().forEach((section) => {\n const sectionEl = document.createElement('div');\n sectionEl.className = 'cs-inline-insert-menu__section';\n\n const titleEl = document.createElement('div');\n titleEl.className = 'cs-inline-insert-menu__title';\n titleEl.textContent = section.title;\n sectionEl.appendChild(titleEl);\n\n section.items.forEach((item) => {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'cs-inline-insert-menu__item';\n button.dataset.blockType = item.type;\n button.innerHTML = `\n <span class=\"cs-inline-insert-menu__icon\">${item.icon}</span>\n <span class=\"cs-inline-insert-menu__label\">${item.label}</span>\n `;\n button.addEventListener('click', (event) => {\n event.preventDefault();\n event.stopPropagation();\n onChoose(item);\n });\n sectionEl.appendChild(button);\n });\n\n menuEl.appendChild(sectionEl);\n });\n };\n\n window.FlowCanvas.initInlineInsert = function (canvas) {\n if (!canvas || canvas.dataset.inlineInsertInit === '1') return;\n canvas.dataset.inlineInsertInit = '1';\n\n const FC = window.FlowCanvas || {};\n const paper = canvas.closest('.cs_paper') || canvas;\n let enabled = window.CanvasConfig?.inlineInsert?.enabled !== false;\n\n const plusEl = document.createElement('button');\n plusEl.type = 'button';\n plusEl.className = 'cs-inline-insert';\n plusEl.setAttribute('aria-label', 'Add content');\n plusEl.setAttribute('title', 'Add content');\n plusEl.innerHTML = '<span>+</span>';\n\n const lineEl = document.createElement('div');\n lineEl.className = 'cs-inline-insert-line';\n\n const menuEl = document.createElement('div');\n menuEl.className = 'cs-inline-insert-menu';\n\n document.body.appendChild(lineEl);\n document.body.appendChild(plusEl);\n document.body.appendChild(menuEl);\n\n const state = {\n doc: null,\n target: null,\n clientX: 0,\n clientY: 0,\n geometry: null,\n open: false,\n visible: false,\n };\n\n const hideVisuals = () => {\n state.visible = false;\n lineEl.classList.remove('is-visible');\n lineEl.classList.remove('is-active');\n plusEl.classList.remove('is-visible', 'is-open');\n };\n\n const closeMenu = ({ keepVisuals = false } = {}) => {\n state.open = false;\n menuEl.classList.remove('is-open');\n plusEl.classList.remove('is-open');\n lineEl.classList.remove('is-active');\n if (!keepVisuals) hideVisuals();\n };\n\n const showVisuals = (geometry) => {\n if (!enabled) return;\n state.visible = true;\n // Cover pages show only the \"+\" — the line is distracting there.\n if (geometry.plusOnly) lineEl.classList.remove('is-visible');\n else lineEl.classList.add('is-visible');\n plusEl.classList.add('is-visible');\n\n if (geometry.vertical) {\n // New-column indicator: vertical line on the block's left/right edge.\n lineEl.classList.add('cs-inline-insert-line--vertical');\n plusEl.classList.add('cs-inline-insert--vertical');\n lineEl.style.left = `${geometry.x}px`;\n lineEl.style.top = `${geometry.top}px`;\n lineEl.style.width = '';\n lineEl.style.height = `${Math.max(32, geometry.bottom - geometry.top)}px`;\n\n plusEl.style.left = `${geometry.x}px`;\n plusEl.style.top = `${geometry.y ?? geometry.top}px`;\n } else {\n // New-row / in-column indicator: horizontal line.\n lineEl.classList.remove('cs-inline-insert-line--vertical');\n plusEl.classList.remove('cs-inline-insert--vertical');\n lineEl.style.left = `${geometry.left}px`;\n lineEl.style.top = `${geometry.y}px`;\n lineEl.style.height = '';\n lineEl.style.width = `${Math.max(32, geometry.right - geometry.left)}px`;\n\n plusEl.style.left = `${(geometry.plusX ?? geometry.left) - 14}px`;\n plusEl.style.top = `${geometry.y}px`;\n }\n };\n\n const positionMenu = () => {\n if (!state.geometry) return;\n const g = state.geometry;\n const anchorY = g.y ?? g.top;\n const anchorX = g.vertical ? g.x : (g.plusX ?? g.left);\n const menuHeight = Math.min(420, menuEl.offsetHeight || 420);\n const maxTop = Math.max(12, window.innerHeight - menuHeight - 12);\n const maxLeft = Math.max(12, window.innerWidth - 288);\n const top = clamp(anchorY - 12, 12, maxTop);\n const left = clamp(anchorX + 18, 12, maxLeft);\n menuEl.style.top = `${top}px`;\n menuEl.style.left = `${left}px`;\n };\n\n // Cover pages (.cs_page[data-cs-cover]) are free-move canvases — included\n // here so the insert \"+\" works on them too (it tracks the pointer there).\n const findActiveDoc = (clientX, clientY) => {\n const docs = Array.from(paper.querySelectorAll('.cs_margin, .cs_page[data-cs-cover=\"1\"]'));\n for (const doc of docs) {\n const rect = doc.getBoundingClientRect();\n if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {\n return doc;\n }\n }\n return docs[0] || null;\n };\n\n const resolveTarget = (doc, clientX, clientY, eventTarget) => {\n const hoveredCol = eventTarget?.closest?.('.col-item');\n if (hoveredCol && doc.contains(hoveredCol)) {\n // Single-column rows only: if the cursor is near the row's top/bottom\n // edge, prefer between-rows (new row) over in-col. In a multi-column\n // row each column's top/bottom is still a valid in-col target, so we\n // leave that behaviour untouched.\n const row = hoveredCol.closest('.row-item');\n const blocks = blockChildrenOfCol(hoveredCol);\n const rowCols = row ? directChildren(row, '.col-item') : [];\n if (row && blocks.length && rowCols.length === 1 && typeof FC.findDropTarget === 'function') {\n const rowRect = row.getBoundingClientRect();\n const ROW_EDGE_THRESHOLD = 14;\n if (clientY <= rowRect.top + ROW_EDGE_THRESHOLD || clientY >= rowRect.bottom - ROW_EDGE_THRESHOLD) {\n const r = FC.findDropTarget(doc, paper, clientX, clientY);\n if (r?.target) return r;\n }\n }\n\n // Near the column's left/right edge → offer a new column beside it.\n // Tighten that edge band while hovering a block so an in-column insert\n // stays the default and the indicator doesn't flip to vertical mid-column.\n const overBlock = !!eventTarget?.closest?.('.cs_block_s, .canvas-block');\n const gap = overBlock ? COL_EDGE_GAP_OVER_BLOCK : COL_EDGE_GAP;\n const edge = resolveColEdge(hoveredCol, clientX, gap);\n if (edge) return edge;\n return resolveInColTarget(hoveredCol, clientY);\n }\n if (!doc || typeof FC.findDropTarget !== 'function') return null;\n const result = FC.findDropTarget(doc, paper, clientX, clientY);\n if (!result?.target) return null;\n // Keep col-edge as a real new-column drop (vertical indicator).\n return result;\n };\n\n const refreshHover = (clientX, clientY, eventTarget) => {\n if (!enabled) {\n hideVisuals();\n return;\n }\n if (state.open) return;\n if (canvas.querySelector('.cs-block--dragging')) {\n hideVisuals();\n return;\n }\n // While any block is being edited (typing / selecting text), the insert\n // indicator must stay hidden — it overlaps the editing surface and makes\n // mouse text-selection awkward. `.cs-editing` is the shared edit-mode\n // marker set by inline-editor.js for every editable block type.\n if (document.querySelector('.cs_block_s.cs-editing')) {\n hideVisuals();\n return;\n }\n if (eventTarget?.closest?.('.cs-inline-insert, .cs-inline-insert-menu')) {\n if (state.geometry) showVisuals(state.geometry);\n return;\n }\n if (isInteractiveChrome(eventTarget)) {\n hideVisuals();\n return;\n }\n\n const doc = findActiveDoc(clientX, clientY);\n if (!doc) {\n hideVisuals();\n return;\n }\n\n const insideCanvas = eventTarget?.closest?.('.custom-form-design');\n if (!insideCanvas) {\n hideVisuals();\n return;\n }\n\n // Cover page: a free-move canvas with no rows/columns. The indicator is a\n // full-width horizontal line with the \"+\" on the left (same look as a\n // content page), but its Y follows the pointer; a click drops the block at\n // the cursor (absolute placement, same path as a sidebar drag onto the\n // cover). Resolve the cover from the hovered element so we only activate\n // when truly over one.\n const coverEl = eventTarget?.closest?.('[data-cs-cover=\"1\"]');\n if (coverEl) {\n // On a cover page the line only shows in the IDLE state — never while a\n // block is selected, being edited, moved, or resized. (Move requires a\n // selected block, resize requires an editing block, so checking\n // selected + editing here covers all of those interactions.)\n if (coverEl.querySelector('.cs-selected, .cs-multi-selected, .cs-editing')) {\n hideVisuals();\n return;\n }\n const coverRect = coverEl.getBoundingClientRect();\n // The \"+\" is an edge affordance: it appears only when the cursor is\n // within EDGE px of the page's left or right edge — left edge → \"+\" on\n // the left, right edge → \"+\" on the right. Anywhere in between shows\n // nothing (the line was distracting; a centre \"+\" isn't wanted).\n const EDGE = 30;\n const distLeft = clientX - coverRect.left;\n const distRight = coverRect.right - clientX;\n let plusX = null;\n if (distLeft >= 0 && distLeft <= EDGE && distLeft <= distRight) {\n plusX = coverRect.left;\n } else if (distRight >= 0 && distRight <= EDGE) {\n plusX = coverRect.right;\n }\n if (plusX === null) {\n hideVisuals();\n return;\n }\n state.doc = coverEl;\n state.target = { kind: 'between-rows', beforeRow: null, parent: coverEl };\n state.clientX = clientX;\n state.clientY = clientY;\n state.geometry = {\n left: coverRect.left,\n right: coverRect.right,\n y: clamp(clientY, coverRect.top + 8, coverRect.bottom - 8),\n plusOnly: true, // cover: show only the \"+\", the line is distracting\n plusX,\n };\n showVisuals(state.geometry);\n return;\n }\n\n const result = resolveTarget(doc, clientX, clientY, eventTarget);\n if (!result?.target) {\n hideVisuals();\n return;\n }\n\n const geometry = computeGeometry(result.target, doc, clientX, clientY);\n if (!geometry) {\n hideVisuals();\n return;\n }\n\n state.doc = doc;\n state.target = result.target;\n state.clientX = clientX;\n state.clientY = clientY;\n state.geometry = geometry;\n showVisuals(geometry);\n };\n\n const chooseItem = (item) => {\n if (!enabled) return;\n if (!state.doc || !state.target) return;\n FC.insertPayloadAtTarget?.({\n payload: {\n blockType: item.type,\n label: item.label,\n },\n activeDoc: state.doc,\n target: state.target,\n clientX: state.clientX,\n clientY: state.clientY,\n });\n closeMenu();\n };\n\n renderMenuSections(menuEl, chooseItem);\n\n plusEl.addEventListener('click', (event) => {\n event.preventDefault();\n event.stopPropagation();\n if (!enabled) return;\n if (!state.target || !state.geometry) return;\n state.open = !state.open;\n plusEl.classList.toggle('is-open', state.open);\n menuEl.classList.toggle('is-open', state.open);\n lineEl.classList.toggle('is-active', state.open);\n if (state.open) {\n positionMenu();\n }\n });\n\n plusEl.addEventListener('mouseenter', () => {\n if (!enabled) return;\n lineEl.classList.add('is-active');\n });\n\n plusEl.addEventListener('mouseleave', () => {\n if (!enabled || state.open) return;\n lineEl.classList.remove('is-active');\n });\n\n document.addEventListener('pointermove', (event) => {\n if (!enabled || state.open) return;\n refreshHover(event.clientX, event.clientY, event.target);\n }, true);\n\n document.addEventListener('scroll', () => {\n if (state.open) positionMenu();\n else hideVisuals();\n }, true);\n\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') {\n closeMenu();\n }\n });\n\n document.addEventListener('pointerdown', (event) => {\n const clickedInsideMenu = event.target.closest?.('.cs-inline-insert-menu, .cs-inline-insert');\n if (clickedInsideMenu) return;\n if (state.open) closeMenu();\n }, true);\n\n document.addEventListener('dragstart', () => closeMenu(), true);\n document.addEventListener('dragenter', () => closeMenu(), true);\n document.addEventListener('drop', () => closeMenu(), true);\n\n const docsObserver = new MutationObserver(() => {\n if (!document.contains(state.doc)) {\n closeMenu();\n }\n });\n docsObserver.observe(paper, { childList: true, subtree: true });\n\n const applyEnabledState = (nextEnabled) => {\n enabled = !!nextEnabled;\n if (window.CanvasConfig?.inlineInsert) {\n window.CanvasConfig.inlineInsert.enabled = enabled;\n }\n if (!enabled) {\n closeMenu();\n hideVisuals();\n }\n };\n\n applyEnabledState(enabled);\n\n Object.assign(window.FlowCanvas, {\n isInlineInsertEnabled: () => enabled,\n setInlineInsertEnabled: (nextEnabled) => {\n applyEnabledState(nextEnabled);\n return enabled;\n },\n // Let other modules force the insert indicator away immediately — e.g.\n // the editor calls this the moment a block enters edit mode, so the line\n // and \"+\" handle vanish without waiting for the next pointermove.\n hideInlineInsert: () => closeMenu(),\n });\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/copy-paste.js\">\n/**\n * @fileoverview Block copy / paste for the flow canvas (Ctrl+C / Ctrl+V).\n *\n * UX:\n * - Select a block (single click → selected state, not editing).\n * - Ctrl+C → the selected block (markup + content + styles) is copied to an\n * internal clipboard.\n * - Ctrl+V → a fresh copy is inserted into the SAME column, right after the\n * currently selected block — the same place a freshly added block\n * would land. The new copy then becomes the selected block.\n *\n * If there is no selected block at paste time, the copy is appended as a new row\n * at the end of the active document.\n *\n * While the user is editing text inside a block (Froala active) we defer to the\n * browser's native copy/paste so normal text editing keeps working.\n *\n * Exposes:\n * window.FlowCanvas.initCopyPaste(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n // Internal clipboard. Holds the cleaned outerHTML of the last copied block so\n // each paste is an independent, fully-detached copy.\n let clipboardHtml = null;\n\n // The page the user last interacted with (content page or cover page). Used so\n // a paste with no selected block lands on the ACTIVE page — not always page 1.\n let activePage = null;\n\n const hash = () => (window.FlowCanvas.generateHash\n ? window.FlowCanvas.generateHash()\n : Math.random().toString(16).slice(2));\n\n // Strip editor chrome + transient state from a clone so the pasted block\n // starts clean. Chrome (grips, badges, resize handles) is re-added on demand.\n const cleanClone = (clone) => {\n clone\n .querySelectorAll('[data-cs-chrome], .cs-block-grip, .cs-block-badge, .cs-resize-handle, .cs-overflow-mark')\n .forEach((el) => el.remove());\n\n const scrub = (el) => {\n el.classList?.remove('cs-selected', 'cs-editing', 'cs-block--dragging');\n // Drop contenteditable so the copy isn't stuck in an editing state, but\n // KEEP Froala's fr-view / fr-element classes — they carry the rendering\n // styles (e.g. table borders/layout) the block needs to display.\n if (el.hasAttribute?.('contenteditable')) el.removeAttribute('contenteditable');\n };\n scrub(clone);\n clone.querySelectorAll('*').forEach(scrub);\n return clone;\n };\n\n // Give the clone (and every descendant carrying an id) a brand-new id so we\n // never end up with duplicate ids in the DOM. The prefix is preserved so the\n // twig generator / style code keeps recognising the element kind.\n const regenerateIds = (root) => {\n const reassign = (el) => {\n if (!el.id) return;\n const prefix = el.id.includes('_') ? el.id.slice(0, el.id.lastIndexOf('_')) : el.id;\n el.id = `${prefix}_${hash()}`;\n };\n reassign(root);\n root.querySelectorAll('[id]').forEach(reassign);\n };\n\n // The whole multi-page board. Pages (content pages AND cover pages) live in\n // separate `.custom-form-design` wrappers under one `.cs_paper`, so a single\n // page's canvas does NOT contain blocks on the other pages. Containment checks\n // must use the board, or copy/paste from a cover page falls back to page 1.\n const boardOf = (canvas) =>\n canvas?.closest?.('.cs_paper') || document.querySelector('.cs_paper') || canvas;\n\n const isFlowBlock = (el, canvas) => (\n el && el.matches?.('.cs_block_s, .canvas-block') &&\n boardOf(canvas).contains(el) &&\n !el.dataset.csInSection &&\n el.parentElement?.matches?.('.col-item')\n );\n\n // Containers that hold absolutely-positioned (\"free\") children: a flexible\n // box, a cover page, or a group. A block's free parent is its IMMEDIATE such\n // container — so paste/duplicate lands back in the SAME place (same cover\n // page, same group, same flexible box).\n const FREE_PARENT_SEL = '.cs-flexible-content, .cs-group-block, [data-cs-cover=\"1\"]';\n const freeParentOf = (el, canvas) => {\n if (!el || !boardOf(canvas).contains(el)) return null;\n const p = el.parentElement;\n return p && p.matches?.(FREE_PARENT_SEL) ? p : null;\n };\n\n // A block that lives inside any free-positioning container (flexible / cover /\n // group). Its paste/duplicate should land back inside that same container.\n const isFlexibleChild = (el, canvas) => !!freeParentOf(el, canvas);\n\n const copySelected = () => {\n const EM = window.EditorManager;\n const block = EM?.getSelected?.();\n if (!block) return false;\n\n const clone = cleanClone(block.cloneNode(true));\n clipboardHtml = clone.outerHTML;\n\n // Also overwrite the SYSTEM clipboard with this block's text. The paste\n // handler treats \"an image sits on the clipboard\" as a newer external copy\n // that should out-rank the in-memory block — so a picture copied earlier\n // must not linger and hijack a fresh block copy. Writing here clears it.\n // Best-effort: if clipboard-write isn't permitted we still have clipboardHtml.\n try {\n const text = (block.innerText || '').trim() || ' ';\n navigator.clipboard?.writeText?.(text)?.catch?.(() => { });\n } catch (e) { /* clipboard API unavailable — ignore */ }\n return true;\n };\n\n // Build a detached block element from the stored clipboard markup.\n const buildPasteBlock = () => {\n if (!clipboardHtml) return null;\n const tmp = document.createElement('div');\n tmp.innerHTML = clipboardHtml;\n const block = tmp.firstElementChild;\n if (!block) return null;\n regenerateIds(block);\n return block;\n };\n\n const colsOfRow = (row) => (\n row ? Array.from(row.children).filter((c) => c.matches?.('.col-item')) : []\n );\n\n // Where should a new block land, relative to an anchor block?\n // - anchor's row has MULTIPLE columns → place in the same column, right\n // after the anchor (keeps the multi-column layout).\n // - anchor's row has a SINGLE column → create a brand-new row right after\n // the current one (like adding a fresh block to a row).\n // Fall back to a new row at the end of the doc when there is no anchor.\n const resolvePasteTarget = (canvas, anchor) => {\n // Anchor inside a free-positioning container (flexible / cover / group) →\n // paste back into that SAME container as an absolute child.\n const freeParent = freeParentOf(anchor, canvas);\n if (freeParent) {\n return { freeParent, target: { kind: 'in-free', parent: freeParent } };\n }\n\n if (isFlowBlock(anchor, canvas)) {\n const col = anchor.closest('.col-item');\n const row = anchor.closest('.row-item');\n const doc = anchor.closest('.cs_margin');\n if (col && row && doc) {\n if (colsOfRow(row).length > 1) {\n return { doc, target: { kind: 'in-col', col, beforeBlock: anchor.nextElementSibling || null } };\n }\n return {\n doc,\n target: { kind: 'between-rows', parent: row.parentElement || doc, beforeRow: row.nextElementSibling || null },\n };\n }\n }\n\n // No usable anchor: drop onto the ACTIVE page (the last page the user\n // touched), honouring cover pages (absolute) vs content pages (flow).\n const board = boardOf(canvas);\n const page = activePage && board.contains(activePage) ? activePage : null;\n if (page && page.matches('[data-cs-cover=\"1\"]')) {\n return { freeParent: page, target: { kind: 'in-free', parent: page } };\n }\n const doc = (page && page.matches('.cs_margin') ? page : null) || board.querySelector('.cs_margin');\n if (!doc) return null;\n return { doc, target: { kind: 'between-rows', parent: doc, beforeRow: null } };\n };\n\n // Place a ready-built block next to an anchor, then select it (immediate\n // feedback + becomes the next paste/duplicate anchor).\n const placeAndSelect = (canvas, newBlock, anchor) => {\n if (!newBlock) return null;\n\n // List-aware paste. Two cases, both keep the synced behaviour:\n // - a whole Container (column) was copied → add it as a new column whose\n // children clone into the existing sync groups (handleColumnPaste);\n // - a content block was copied while a column is the anchor → paste into\n // that column + clone across the others as a new group (handlePaste).\n if (anchor?.closest?.('.cs-synclist__col') && window.SyncList) {\n const isColumn = newBlock.classList?.contains('cs-synclist__col');\n const fn = isColumn ? window.SyncList.handleColumnPaste : window.SyncList.handlePaste;\n const placed = fn && fn(anchor, newBlock);\n if (placed) {\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(placed)) return;\n placed.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n placed.click();\n });\n return placed;\n }\n }\n\n const placement = resolvePasteTarget(canvas, anchor);\n if (!placement) return null;\n\n if (placement.freeParent) {\n // Append as an absolutely-positioned child of the SAME container (cover\n // page / group / flexible box), offset 10px so the copy is visible.\n const parent = placement.freeParent;\n newBlock.dataset.csInSection = '1';\n newBlock.style.position = 'absolute';\n const left = parseFloat(newBlock.style.left) || 0;\n const top = parseFloat(newBlock.style.top) || 0;\n newBlock.style.left = `${left + 10}px`;\n newBlock.style.top = `${top + 10}px`;\n parent.appendChild(newBlock);\n const wrapper = parent.closest('.cs-flexible-block') || parent;\n window.FlowCanvas?.syncFlexibleContentBounds?.(wrapper);\n // Pasted into a group → grow the group so it wraps the new child.\n if (parent.classList.contains('cs-group-block')) {\n window.FlowCanvas?.refitGroupToChildren?.(parent);\n }\n } else {\n window.FlowCanvas?.placeBlock?.(placement.doc, newBlock, placement.target);\n }\n\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(newBlock)) return;\n newBlock.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n newBlock.click();\n });\n return newBlock;\n };\n\n // The block a freshly-pasted block should anchor next to: the current\n // selection, but only when it's a real flow/list/flexible block we know how\n // to place beside. Returns null otherwise (→ append at end of the doc).\n const currentAnchor = (canvas) => {\n const anchor = window.EditorManager?.getSelected?.();\n const inList = !!anchor?.closest?.('.cs-synclist__col');\n return (inList || isFlowBlock(anchor, canvas) || isFlexibleChild(anchor, canvas)) ? anchor : null;\n };\n\n const pasteBlock = (canvas) => (\n !!placeAndSelect(canvas, buildPasteBlock(), currentAnchor(canvas))\n );\n\n /* ------------------- external clipboard → new block ----------------------- */\n // Pasting content copied from another site/app (when NOT editing a block)\n // auto-creates the matching block, just like adding it from the sidebar and\n // then filling in the content:\n // - image data → an Image block showing the pasted picture;\n // - text → a Textarea (body-text) block holding the pasted text.\n\n // Escape plain text for safe innerHTML and keep line breaks visible (newlines\n // → <br>) so a multi-line paste reads the same as when it was copied.\n const textToHtml = (text) => text\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\\r\\n?|\\n/g, '<br>');\n\n // Build an Image block populated with the pasted picture. Mirrors the\n // image-upload handler in flow-canvas.js: drop the \"click to select\"\n // placeholder and append a real <img> the container styles fill.\n const buildPastedImageBlock = (dataUrl) => {\n const block = window.FlowCanvas.createBlock?.('image');\n if (!block) return null;\n const container = block.querySelector('.image-container');\n if (container) {\n container.querySelector('.img-btn')?.remove();\n container.querySelector('img')?.remove();\n const img = document.createElement('img');\n img.src = dataUrl;\n img.alt = 'Pasted image';\n container.appendChild(img);\n }\n return block;\n };\n\n // Build a Textarea (body-text) block holding the pasted text.\n const buildPastedTextBlock = (text) => {\n const block = window.FlowCanvas.createBlock?.('body-text');\n if (!block) return null;\n const editable = block.querySelector('.edit_me');\n if (editable) editable.innerHTML = textToHtml(text);\n return block;\n };\n\n // Place the clipboard's image as a NEW Image block on the canvas, next to\n // `anchor`. Used both for plain canvas paste AND to push an image OUT of a\n // text block (a picture must never live inside a Title/Textarea — it lands in\n // the parent instead). Returns true when an image was found and placement\n // kicked off.\n //\n // A pasted image file (\"Copy image\") is always handled. An <img> embedded in\n // copied rich HTML (e.g. a web-page region) is only handled when\n // `includeHtmlImg` is set — on the canvas a text+image region stays a text\n // block (keeps the text); ejecting from a text block extracts the picture.\n const placePastedImageBlock = (canvas, clipboardData, anchor, includeHtmlImg = false) => {\n if (!clipboardData) return false;\n\n const imageItem = Array.from(clipboardData.items || [])\n .find((it) => it.kind === 'file' && it.type.startsWith('image/'));\n if (imageItem) {\n const file = imageItem.getAsFile();\n if (file) {\n const reader = new FileReader();\n reader.onload = (e) => {\n placeAndSelect(canvas, buildPastedImageBlock(e.target.result), anchor);\n };\n reader.readAsDataURL(file);\n return true;\n }\n }\n\n if (includeHtmlImg) {\n const html = clipboardData.getData('text/html') || '';\n const src = html.match(/<img\\b[^>]*\\bsrc\\s*=\\s*[\"']([^\"']+)[\"']/i)?.[1];\n if (src) {\n placeAndSelect(canvas, buildPastedImageBlock(src), anchor);\n return true;\n }\n }\n return false;\n };\n\n // Public: duplicate a specific block (used by the badge \"duplicate\" action).\n // Clones the live block (content + styles) and drops the copy next to it.\n window.FlowCanvas.duplicateBlock = (block) => {\n if (!block) return null;\n const canvas = block.closest('.cs-flow-canvas') || document.querySelector('.cs-flow-canvas');\n if (!canvas) return null;\n const clone = cleanClone(block.cloneNode(true));\n regenerateIds(clone);\n const anchor = (isFlowBlock(block, canvas) || isFlexibleChild(block, canvas)) ? block : null;\n return placeAndSelect(canvas, clone, anchor);\n };\n\n /* ----------------------- reusable component library ----------------------- */\n // Capture/insert reuse the exact clone/clean/regenerate/place pipeline so a\n // saved component behaves like a freshly-dropped block (single OR a container\n // block such as a section/flexible that groups several children).\n const getCanvasEl = () =>\n document.querySelector('.cs-flow-canvas') || document.querySelector('.custom-form-design');\n\n // Build a fresh, id-unique block element from stored component HTML.\n window.FlowCanvas.buildComponentBlock = (html) => {\n if (!html) return null;\n const tmp = document.createElement('div');\n tmp.innerHTML = html;\n const block = tmp.firstElementChild;\n if (!block) return null;\n regenerateIds(block);\n return block;\n };\n\n // Snapshot the currently selected block as a reusable component.\n window.FlowCanvas.captureComponent = () => {\n const block = window.EditorManager?.getSelected?.();\n if (!block) return null;\n const clone = cleanClone(block.cloneNode(true));\n const isGroup = !!clone.querySelector('.cs_block_s, .canvas-block, .row-item');\n const thumbnail = (block.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 40)\n || block.getAttribute('custom-name') || block.dataset.blockType || 'Component';\n return { html: clone.outerHTML, kind: isGroup ? 'group' : 'single', thumbnail };\n };\n\n // Insert a component (click path) next to the current selection.\n window.FlowCanvas.insertComponentHtml = (html) => {\n const canvas = getCanvasEl();\n const block = window.FlowCanvas.buildComponentBlock(html);\n if (!canvas || !block) return false;\n const anchor = window.EditorManager?.getSelected?.();\n const useAnchor = (isFlowBlock(anchor, canvas) || isFlexibleChild(anchor, canvas)) ? anchor : null;\n return !!placeAndSelect(canvas, block, useAnchor);\n };\n\n window.FlowCanvas.initCopyPaste = function (canvas) {\n if (!canvas || canvas.dataset.copyPasteInit === '1') return;\n canvas.dataset.copyPasteInit = '1';\n\n // Track the page the user last pressed on, so a paste with no selection\n // lands on the active page (cover or content) instead of always page 1.\n document.addEventListener('pointerdown', (e) => {\n const p = e.target?.closest?.('.cs_margin, .cs_page[data-cs-cover=\"1\"]');\n if (p) activePage = p;\n }, true);\n\n // Expose the active page so other features (e.g. the per-page background\n // shape designer) can target the page the user is currently working on.\n window.FlowCanvas.getActivePage = () =>\n (activePage && document.contains(activePage) ? activePage : null);\n\n document.addEventListener('keydown', (event) => {\n const ctrl = event.ctrlKey || event.metaKey;\n if (!ctrl) return;\n\n const key = event.key.toLowerCase();\n if (key !== 'c' && key !== 'v' && key !== 'd') return;\n\n // While editing text inside a block, defer to native copy/paste.\n if (window.EditorManager?.getEditing?.()) return;\n\n const target = event.target;\n const inEditable = target?.isContentEditable ||\n target?.tagName === 'INPUT' ||\n target?.tagName === 'TEXTAREA';\n if (inEditable) return;\n\n // Ctrl/Cmd+D → duplicate the selected block in place.\n if (key === 'd') {\n const sel = window.EditorManager?.getSelected?.();\n if (sel) {\n event.preventDefault();\n window.FlowCanvas.duplicateBlock?.(sel);\n }\n return;\n }\n\n // Only handle COPY here. Paste (Ctrl+V) is deliberately left to the native\n // `paste` listener below, so it can inspect the REAL system clipboard.\n // That's what lets a freshly-copied external image/text win over a\n // previously-copied internal block — hijacking paste here would always\n // re-insert the in-memory block and never even look at the clipboard.\n if (key === 'c') {\n // Only hijack copy when a block is actually selected; otherwise let the\n // browser copy any plain text selection normally.\n if (copySelected()) event.preventDefault();\n }\n });\n\n // Native paste. Two contexts:\n // 1. Editing a text block (or focused in a field) → text blocks accept\n // TEXT ONLY. A pasted picture never lands inside a Title/Textarea; it\n // is ejected to the parent as its own Image block instead, while any\n // accompanying text still pastes into the block.\n // 2. Not editing (canvas) → drop the highest-priority clipboard content\n // as a new block: external image → Image, else internal block, else\n // external text → Textarea.\n //\n // CAPTURE phase (the `true` below): this must run BEFORE the active text\n // editor's (Froala's) own paste handler so we can stop it from inserting an\n // image into a text block. Cases we don't handle return early WITHOUT\n // stopping propagation, so the editor / table handlers still run normally.\n document.addEventListener('paste', (event) => {\n const target = event.target;\n const clipboardData = event.clipboardData;\n\n const inEditable = target?.isContentEditable ||\n target?.tagName === 'INPUT' ||\n target?.tagName === 'TEXTAREA';\n\n // --- text-editing context: keep images out of text blocks ---\n if (window.EditorManager?.getEditing?.() || inEditable) {\n // Plain fields can't hold images, and the Table block runs its own\n // text-only paste handler — leave both to their native behaviour.\n if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return;\n if (target?.closest?.('table')) return;\n\n // Does the clipboard carry an image? Either a pasted file (\"Copy\n // image\") or an <img> embedded in copied rich HTML (a web-page region).\n const html = clipboardData?.getData('text/html') || '';\n const hasImageFile = Array.from(clipboardData?.items || [])\n .some((it) => it.kind === 'file' && it.type.startsWith('image/'));\n const htmlHasImage = /<img\\b/i.test(html);\n\n // No image → let the native (rich) text paste run untouched.\n if (!hasImageFile && !htmlHasImage) return;\n\n // Image present → block the paste so no picture is inserted into the\n // text block. preventDefault alone is NOT enough: the active editor\n // (Froala) inserts the image programmatically from its OWN paste\n // handler, immune to the default-action cancel. stopImmediatePropagation\n // (this listener runs at capture phase, before the editor's handler)\n // stops that handler from ever firing. Then keep any accompanying plain\n // text in the block and eject the image to the parent as an Image block.\n event.preventDefault();\n event.stopImmediatePropagation();\n const text = clipboardData?.getData('text/plain') || '';\n if (text) {\n try { document.execCommand('insertText', false, text); } catch (e) { /* */ }\n }\n\n // Anchor the new Image block next to this text block so it lands right\n // in the parent (column / row), not somewhere unrelated.\n const editingBlock = window.EditorManager?.getEditing?.();\n const board = boardOf(canvas);\n const onCanvas = board.contains(target) ||\n (editingBlock && board.contains(editingBlock));\n if (onCanvas) {\n const anchor = currentAnchor(canvas) ||\n ((editingBlock && (isFlowBlock(editingBlock, canvas) || isFlexibleChild(editingBlock, canvas)))\n ? editingBlock : null);\n placePastedImageBlock(canvas, clipboardData, anchor, true);\n }\n return;\n }\n\n // --- canvas context: decide what Ctrl+V drops in ---\n // Priority:\n // 1. An external IMAGE on the clipboard. It can only have come from a\n // copy made AFTER any internal block copy (an internal copy never\n // writes to the system clipboard), so it reflects the latest intent.\n // This is the fix for \"copy a block, copy an image elsewhere, paste →\n // the block came back\": now the image wins.\n // 2. A previously-copied internal block (held in memory).\n // 3. External text.\n const anchor = currentAnchor(canvas);\n if (placePastedImageBlock(canvas, clipboardData, anchor)) {\n event.preventDefault();\n return;\n }\n if (clipboardHtml && pasteBlock(canvas)) {\n event.preventDefault();\n return;\n }\n const text = clipboardData?.getData('text/plain');\n if (text && text.trim()) {\n placeAndSelect(canvas, buildPastedTextBlock(text), anchor);\n event.preventDefault();\n }\n }, true);\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/active-page.js\">\n/**\n * @fileoverview Scroll-driven \"active page\" tracker.\n *\n * As the user scrolls (or clicks) through the document, the page currently in\n * view is marked as the active/selected page by adding a class to its `.cs_page`\n * wrapper — so the rest of the editor can identify which page the user is\n * working on:\n *\n * - Cover page (.cs_page[data-cs-cover=\"1\"]) → `cs_selected`\n * - Content page (.cs_page wrapping a .cs_margin) → `cs_selected_border`\n *\n * Only ONE page carries a selection class at a time. These classes are\n * editor-only and are stripped from the exported markup by the Twig generator\n * (common-twig-generator.js).\n *\n * Exposes:\n * window.FlowCanvas.getSelectedPage() — the selected `.cs_page` element\n * window.FlowCanvas.getSelectedDrawablePage() — cover `.cs_page`, or the content\n * page's inner `.cs_margin`\n * (what per-page features target)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const PAGE_SEL = '.cs_page';\n const SEL_CLASSES = ['cs_selected', 'cs_selected_border'];\n\n let selectedPage = null;\n let rafId = 0;\n\n // This script runs inside the editor iframe, but the iframe is sized to its\n // full content height — so it never scrolls itself; the HOST (Angular shell)\n // scroll container is what actually moves. We must therefore measure page\n // visibility against the HOST viewport, mapping each page's iframe-local rect\n // into host coordinates via the iframe element's position in the host.\n const hostWin = (() => {\n try { return window.parent && window.parent !== window ? window.parent : window; } catch (e) { return window; }\n })();\n const isEmbedded = hostWin !== window;\n const frameEl = (() => { try { return window.frameElement || null; } catch (e) { return null; } })();\n\n const allPages = () => Array.from(document.querySelectorAll(PAGE_SEL));\n\n // Viewport height + the vertical offset to add to an iframe-local rect to get\n // its position in the visible (host) viewport.\n const viewport = () => {\n if (isEmbedded && frameEl) {\n let frameTop = 0;\n try { frameTop = frameEl.getBoundingClientRect().top; } catch (e) { frameTop = 0; }\n const vh = hostWin.innerHeight || hostWin.document?.documentElement?.clientHeight || 0;\n return { vh, offset: frameTop };\n }\n return { vh: window.innerHeight || document.documentElement.clientHeight || 0, offset: 0 };\n };\n\n // The page whose visible area best covers the viewport centre wins. A page\n // that straddles the centre line always beats one that's merely partly\n // visible, so the \"current\" page is stable while scrolling.\n const pickMostVisible = () => {\n const pages = allPages();\n if (!pages.length) return null;\n const { vh, offset } = viewport();\n if (!vh) return pages[0];\n const centerY = vh / 2;\n let best = null, bestScore = -Infinity;\n for (const p of pages) {\n const r = p.getBoundingClientRect();\n if (r.height === 0) continue;\n const top = r.top + offset; // host-viewport coordinates\n const bottom = r.bottom + offset;\n if (bottom <= 0 || top >= vh) continue; // off-screen\n const overlap = Math.max(0, Math.min(bottom, vh) - Math.max(top, 0));\n const containsCenter = top <= centerY && bottom >= centerY;\n const score = overlap + (containsCenter ? 1e7 : 0);\n if (score > bestScore) { bestScore = score; best = p; }\n }\n return best || pages[0];\n };\n\n // Report \"page X of Y\" to the host shell, but only when it actually changes\n // (apply() runs on every scroll frame, so guard against spamming postMessage).\n let lastPostedKey = '';\n const postActivePage = (page) => {\n const pages = allPages();\n const index = pages.indexOf(page) + 1; // 1-based; 0 if not found\n const total = pages.length;\n // This page's own background image (set per-page by flow-canvas) so the host\n // panel preview reflects whichever page is in view.\n const drawable = page.matches('[data-cs-cover=\"1\"]') ? page : (page.querySelector(':scope > .cs_margin') || page);\n const bgImage = (drawable && drawable.dataset.csBgImage) || '';\n const key = `${index}/${total}`;\n if (key === lastPostedKey) return;\n lastPostedKey = key;\n try {\n hostWin.postMessage({ source: 'custom-form-twig', type: 'page:active', index, total, bgImage }, '*');\n } catch (e) { /* parent on different origin — ignore */ }\n };\n\n const apply = (page) => {\n if (!page) return;\n selectedPage = page;\n allPages().forEach((p) => {\n if (p !== page) p.classList.remove(...SEL_CLASSES);\n });\n const isCover = page.matches('[data-cs-cover=\"1\"]');\n page.classList.toggle('cs_selected', isCover);\n page.classList.toggle('cs_selected_border', !isCover);\n postActivePage(page);\n };\n\n // While we're deliberately scrolling to a just-added page, suppress the\n // scroll-driven recompute so it can't momentarily reselect the old page\n // (the page-add MutationObserver fires before the scroll has moved).\n let focusLock = false;\n let focusLockTimer = 0;\n\n const update = () => { if (!focusLock) apply(pickMostVisible()); };\n\n const scheduleUpdate = () => {\n if (rafId) return;\n rafId = requestAnimationFrame(() => { rafId = 0; update(); });\n };\n\n /* ------------------------- scroll to a given page ------------------------- */\n\n // The nearest scrollable ancestor of the iframe in the HOST document.\n const findScrollable = (node) => {\n let n = node ? node.parentElement : null;\n while (n && n.nodeType === 1) {\n let oy = '';\n try { oy = hostWin.getComputedStyle(n).overflowY; } catch (e) { /* */ }\n if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1) return n;\n n = n.parentElement;\n }\n return null;\n };\n\n const hostScroller = () => {\n if (isEmbedded && frameEl) {\n try { return frameEl.closest('.canvas-stage') || findScrollable(frameEl); } catch (e) { /* */ }\n }\n return null;\n };\n\n // Scroll the host so `page` sits near the top of the viewport, then keep it\n // selected. The iframe is resized by the host asynchronously after a page is\n // added, so the target may be momentarily out of scroll range — retry until\n // it's reachable (or attempts run out).\n const scrollHostToPage = (page) => {\n const scroller = hostScroller();\n if (!scroller) {\n try { page.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { /* */ }\n return;\n }\n const PAD = 24;\n let tries = 0;\n const attempt = () => {\n tries += 1;\n let frameTop = 0;\n try { frameTop = frameEl.getBoundingClientRect().top; } catch (e) { frameTop = 0; }\n const sRect = scroller.getBoundingClientRect();\n const pageTopInScroller =\n scroller.scrollTop + (frameTop + page.getBoundingClientRect().top) - sRect.top;\n const target = Math.max(0, pageTopInScroller - PAD);\n const maxTop = scroller.scrollHeight - scroller.clientHeight;\n // Iframe hasn't grown to include the new page yet → wait and retry.\n if (target > maxTop + 2 && tries < 15) { hostWin.setTimeout(attempt, 40); return; }\n try { scroller.scrollTo({ top: Math.min(target, maxTop), behavior: 'smooth' }); }\n catch (e) { scroller.scrollTop = Math.min(target, maxTop); }\n };\n attempt();\n };\n\n /* -------------------------------- public --------------------------------- */\n\n window.FlowCanvas.getSelectedPage = () =>\n (selectedPage && document.contains(selectedPage) ? selectedPage : null);\n\n // The element per-page features should target: a cover IS its own page; a\n // content wrapper exposes its inner .cs_margin.\n window.FlowCanvas.getSelectedDrawablePage = () => {\n const sel = window.FlowCanvas.getSelectedPage();\n if (!sel) return null;\n if (sel.matches('[data-cs-cover=\"1\"]')) return sel;\n return sel.querySelector(':scope > .cs_margin') || sel;\n };\n\n // Scroll to a page (e.g. a freshly added one) and mark it selected. `el` may\n // be the `.cs_page` wrapper itself or any element inside it (e.g. a .cs_margin).\n window.FlowCanvas.focusPage = (el) => {\n if (!el) return;\n const page = el.closest(PAGE_SEL) || el;\n apply(page); // select immediately, don't wait for scroll\n focusLock = true; // hold the selection through the scroll\n if (focusLockTimer) hostWin.clearTimeout(focusLockTimer);\n focusLockTimer = hostWin.setTimeout(() => {\n focusLock = false; focusLockTimer = 0; update();\n }, 1200);\n scrollHostToPage(page);\n };\n\n /* --------------------------------- init ---------------------------------- */\n\n const init = () => {\n // `true` (capture) catches scrolls of any inner scroll container too, since\n // the scroll event doesn't bubble. The REAL scroller is in the host (the\n // iframe is full-height and never scrolls itself), so listen there too.\n window.addEventListener('scroll', scheduleUpdate, true);\n window.addEventListener('resize', scheduleUpdate);\n if (isEmbedded) {\n try {\n hostWin.addEventListener('scroll', scheduleUpdate, true);\n hostWin.addEventListener('resize', scheduleUpdate);\n } catch (e) { /* cross-origin host — fall back to iframe listeners */ }\n }\n\n // Clicking into a page selects it immediately (don't wait for a scroll).\n document.addEventListener('pointerdown', (e) => {\n const p = e.target?.closest?.(PAGE_SEL);\n if (p) apply(p);\n }, true);\n\n // Re-evaluate when pages are added / removed. Pages attach as direct\n // children of .cs_paper, so a shallow childList watch is enough (and avoids\n // firing on every nested content edit).\n const root = document.querySelector('.cs_paper') || document.body;\n if (root) new MutationObserver(scheduleUpdate).observe(root, { childList: true });\n\n update();\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/image-zoom.js\">\n/**\n * @fileoverview Zoom + pan for image blocks while they're being edited.\n *\n * When an image block is in edit mode (`.cs-image-block.cs-editing`):\n * - Mouse wheel / trackpad scroll over the image zooms in/out, anchored to\n * the pointer (focal zoom).\n * - Once zoomed past 1x, dragging the image pans it within the block.\n *\n * The image stays clipped by `.image-container { overflow: hidden }`; we never\n * resize the block — we only scale/translate the <img> via a CSS transform.\n * State is written back to the <img> (inline `transform` + `data-cs-zoom/-pan-*`)\n * so it survives re-render, persists when editing stops, and serializes on\n * export (the inline style is cloned with the DOM).\n *\n * Exposes:\n * window.FlowCanvas.initImageZoom(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const MIN_ZOOM = 1; // 1x = the default object-fit: cover framing\n const MAX_ZOOM = 5; // hard cap so users can't lose the image entirely\n const ZOOM_STEP = 0.0015; // wheel delta → multiplicative zoom sensitivity\n\n const clamp = (v, min, max) => Math.min(Math.max(v, min), max);\n\n // Resolve the editable image under an event target, or null. Requires the\n // block to actually be in edit mode and to hold a real <img> (not the upload\n // placeholder button), so a fresh/empty image block is left alone.\n const resolveEditingImg = (target) => {\n const container = target?.closest?.('.image-container');\n if (!container) return null;\n const block = container.closest('.cs-image-block');\n if (!block || !block.classList.contains('cs-editing')) return null;\n const img = container.querySelector('img');\n if (!img) return null;\n return { block, container, img };\n };\n\n const readState = (img) => ({\n zoom: parseFloat(img.dataset.csZoom) || MIN_ZOOM,\n x: parseFloat(img.dataset.csPanX) || 0,\n y: parseFloat(img.dataset.csPanY) || 0,\n });\n\n // Clamp + write the transform. Pan is bounded so the scaled image always\n // keeps covering the container (no empty gaps at the edges). Returns the\n // values actually applied so callers can chain off the clamped result.\n const applyState = (img, container, next) => {\n const block = container.closest('.cs-image-block');\n const zoom = clamp(next.zoom, MIN_ZOOM, MAX_ZOOM);\n const rect = container.getBoundingClientRect();\n const maxX = (rect.width * (zoom - 1)) / 2;\n const maxY = (rect.height * (zoom - 1)) / 2;\n const x = clamp(next.x || 0, -maxX, maxX);\n const y = clamp(next.y || 0, -maxY, maxY);\n\n img.dataset.csZoom = zoom.toFixed(4);\n img.dataset.csPanX = x.toFixed(2);\n img.dataset.csPanY = y.toFixed(2);\n img.style.transformOrigin = 'center center';\n img.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;\n img.draggable = false; // kill the native image drag-ghost while interacting\n if (block) block.classList.toggle('cs-img-zoomed', zoom > MIN_ZOOM + 0.001);\n return { zoom, x, y };\n };\n\n // Re-apply (and re-clamp) the stored zoom/pan for a container's image. Called\n // after the frame shape changes, since a new shape can change the container's\n // size/aspect-ratio and therefore the valid pan range.\n window.FlowCanvas.refreshImageZoom = function (container) {\n const img = container?.querySelector?.('img');\n if (img) applyState(img, container, readState(img));\n };\n\n window.FlowCanvas.initImageZoom = function (canvas) {\n if (!canvas || canvas.dataset.imageZoomInit === '1') return;\n canvas.dataset.imageZoomInit = '1';\n\n /* ----------------------------- wheel = zoom ----------------------------- */\n const onWheel = (event) => {\n const ctx = resolveEditingImg(event.target);\n if (!ctx) return; // not over an editing image → let the page scroll\n event.preventDefault();\n event.stopPropagation();\n\n const { container, img } = ctx;\n const cur = applyState(img, container, readState(img)); // normalise first\n const rect = container.getBoundingClientRect();\n\n // Pointer offset from the container centre (the transform's origin).\n const u = event.clientX - (rect.left + rect.width / 2);\n const v = event.clientY - (rect.top + rect.height / 2);\n\n // The image-space point currently under the cursor — kept fixed so the\n // zoom grows/shrinks around the pointer instead of the centre.\n const focalX = (u - cur.x) / cur.zoom;\n const focalY = (v - cur.y) / cur.zoom;\n\n const factor = Math.exp(-event.deltaY * ZOOM_STEP);\n const zoom = clamp(cur.zoom * factor, MIN_ZOOM, MAX_ZOOM);\n\n applyState(img, container, {\n zoom,\n x: u - zoom * focalX,\n y: v - zoom * focalY,\n });\n };\n\n /* ------------------------------ drag = pan ------------------------------ */\n let pan = null;\n\n const onPointerDown = (event) => {\n if (event.pointerType === 'mouse' && event.button !== 0) return;\n const ctx = resolveEditingImg(event.target);\n if (!ctx) return;\n const cur = applyState(ctx.img, ctx.container, readState(ctx.img));\n if (cur.zoom <= MIN_ZOOM + 0.001) return; // no room to pan until zoomed\n\n // Own the gesture: stop the inline-editor's block-move/resize handlers\n // from also reacting to this press.\n event.preventDefault();\n event.stopPropagation();\n\n pan = {\n block: ctx.block,\n img: ctx.img,\n container: ctx.container,\n startX: event.clientX,\n startY: event.clientY,\n baseX: cur.x,\n baseY: cur.y,\n pointerId: event.pointerId,\n };\n try { ctx.img.setPointerCapture(event.pointerId); } catch (e) { /* */ }\n ctx.block.classList.add('cs-img-panning');\n };\n\n const onPointerMove = (event) => {\n if (!pan) return;\n event.preventDefault();\n applyState(pan.img, pan.container, {\n zoom: readState(pan.img).zoom,\n x: pan.baseX + (event.clientX - pan.startX),\n y: pan.baseY + (event.clientY - pan.startY),\n });\n };\n\n const endPan = () => {\n if (!pan) return;\n try { pan.img.releasePointerCapture(pan.pointerId); } catch (e) { /* */ }\n pan.block.classList.remove('cs-img-panning');\n pan = null;\n };\n\n // Bind to the whole board (.cs_paper) — not just page 1's canvas — so image\n // zoom/pan also works for images on added pages and cover pages, which live\n // in their own sibling `.custom-form-design` wrappers.\n const board = canvas.closest('.cs_paper') || canvas;\n // wheel must be non-passive so preventDefault can stop page scroll.\n board.addEventListener('wheel', onWheel, { passive: false });\n // Capture phase so we claim the press before the block move/resize logic.\n board.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('pointermove', onPointerMove, true);\n document.addEventListener('pointerup', endPan, true);\n document.addEventListener('pointercancel', endPan, true);\n // Belt-and-braces: suppress the browser's native image drag inside an\n // editing image (otherwise a pan can start a ghost-drag of the picture).\n board.addEventListener('dragstart', (event) => {\n if (resolveEditingImg(event.target)) event.preventDefault();\n }, true);\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/page-shape-designer.js\">\n/**\n * @fileoverview Per-page background shape designer.\n *\n * Opens a full-screen modal whose drawing stage matches the real page's\n * width × height (aspect ratio). The user designs a vector shape with the\n * SAME pen tool used by the Pen Shape block (reused via window.PenShape).\n *\n * Shapes are PAGE-SPECIFIC: each page (a content page `.cs_margin` or a cover\n * page `.cs_page[data-cs-cover]`) carries its own `.cs-page-shape-bg` layer.\n * The designer targets ONE page at a time (defaulting to the page the user is\n * working on) and a page selector in the modal lets the user switch between\n * pages and add / edit / remove a shape on each independently. Saving applies\n * the design only to the pages edited in that session; other pages are left\n * untouched and newly-added pages start blank.\n *\n * The injected layer (.cs-page-shape-bg) is plain DOM inside the page — NOT\n * marked [data-cs-chrome] — so the Twig generator clones it and it exports to\n * the PDF. Critical styles are inlined so it renders even if a stylesheet is\n * missing.\n *\n * Opened from the Angular \"Style → Page Settings\" button via postMessage\n * (page-shape:open), wired in flow-canvas.js.\n *\n * Exposes:\n * window.PageShapeDesigner.open() — open the designer on the active page\n * window.PageShapeDesigner.removeFromActive() — remove the shape from the active page\n * window.PageShapeDesigner.clearAll() — remove the shape from every page\n */\n(function () {\n window.PageShapeDesigner = window.PageShapeDesigner || {};\n\n const LAYER_CLASS = 'cs-page-shape-bg';\n const PAGE_SEL = '.cs_margin, .cs_page[data-cs-cover=\"1\"]';\n const DEFAULT_W = 794, DEFAULT_H = 1123; // A4 @96dpi fallback\n\n let modal = null;\n let block = null;\n let targetPage = null; // the page currently shown in the designer\n let pageList = []; // pages captured when the modal opened (select order)\n let sessionDesigns = null; // Map<pageEl, design|null> edited during this session\n let uidSeq = 0; // ensures every injected layer gets globally-unique def ids\n\n // The modal is rendered in the HOST document (the Angular shell), NOT inside\n // this iframe — so it reads as a true root-level modal (like the save-as\n // modal) instead of being clipped to the canvas panel. Pages still live in\n // THIS document, so the page helpers keep using `document`.\n const hostWin = (() => { try { return window.parent && window.parent !== window ? window.parent : window; } catch (e) { return window; } })();\n const hostDoc = hostWin.document;\n\n // The modal + pen styling lives in editor.css, which the iframe loads but the\n // host page does not. Inject it into the host once so the modal is styled.\n const ensureHostStyles = () => {\n if (hostDoc === document) return; // standalone (not embedded) → already has it\n if (hostDoc.getElementById('cs-pen-host-styles')) return;\n const ownLink = document.querySelector('link[href*=\"editor.css\"]');\n const href = ownLink ? ownLink.getAttribute('href') : './editor/editor.css';\n const link = hostDoc.createElement('link');\n link.id = 'cs-pen-host-styles';\n link.rel = 'stylesheet';\n // Resolve relative to THIS iframe's document so the host can find the file.\n link.href = new URL(href, document.baseURI).href;\n hostDoc.head.appendChild(link);\n };\n\n /* ------------------------------ page helpers ------------------------------ */\n\n const getPageDims = () => {\n const cs = getComputedStyle(document.documentElement);\n const w = parseFloat(cs.getPropertyValue('--cs-page-width')) || DEFAULT_W;\n const h = parseFloat(cs.getPropertyValue('--cs-page-min-height')) || DEFAULT_H;\n return { w, h };\n };\n\n // Every page (content + cover) in document order.\n const getAllPages = () => Array.from(document.querySelectorAll(PAGE_SEL));\n const getPagesRoot = () => document.querySelector('.cs_paper')\n || document.querySelector('.cs_page')\n || document.querySelector('.custom-form-design');\n\n // A human label for the page selector, e.g. \"Cover Page 2\" / \"Content Page 1\".\n const labelPages = (pages) => {\n let cover = 0, content = 0;\n return pages.map((p) => {\n if (p.matches('[data-cs-cover=\"1\"]')) { cover += 1; return `Cover Page ${cover}`; }\n content += 1; return `Content Page ${content}`;\n });\n };\n\n // The page the user is currently working on — used as the default target.\n // Prefer the scroll-driven selection (the page in view), then the last page\n // the user clicked, then the first page.\n const resolveActivePage = () => {\n const sel = window.FlowCanvas?.getSelectedDrawablePage?.();\n if (sel && document.contains(sel) && sel.matches(PAGE_SEL)) return sel;\n const ap = window.FlowCanvas?.getActivePage?.();\n if (ap && document.contains(ap) && ap.matches(PAGE_SEL)) return ap;\n return getAllPages()[0] || null;\n };\n\n /* ---------------------- inject / read the bg layer ----------------------- */\n\n // Clone an <svg> and make every def id unique so multiple pages don't clash\n // (duplicate ids in one document make all gradients/patterns resolve to the\n // first one). Rewrites url(#id) references in fill/stroke too.\n const uniquifyIds = (svg, suffix) => {\n svg.querySelectorAll('[id]').forEach((el) => {\n const oldId = el.id;\n const newId = `${oldId}_${suffix}`;\n el.id = newId;\n svg.querySelectorAll('[fill],[stroke]').forEach((node) => {\n ['fill', 'stroke'].forEach((attr) => {\n const v = node.getAttribute(attr);\n if (v && v.includes(`#${oldId}`)) {\n node.setAttribute(attr, v.replace(`#${oldId})`, `#${newId})`));\n }\n });\n });\n });\n };\n\n // Inject the given design into ONE page (or, when design is empty, remove any\n // existing layer from that page). `design` = { svg, penPath, penStyle } | null.\n const injectLayer = (pageEl, design) => {\n pageEl.querySelectorAll(`:scope > .${LAYER_CLASS}`).forEach((el) => el.remove());\n if (!design || !design.svg) return;\n\n const layer = document.createElement('div');\n layer.className = LAYER_CLASS;\n layer.setAttribute('aria-hidden', 'true');\n // Inline the critical styles so the layer renders in the exported PDF even\n // if editor.css isn't loaded. z-index:0 keeps it above the page background\n // but below page content (which is forced to z-index:1 in custom-form.css).\n // Negative z-index + isolation are NOT used here because some PDF engines\n // (wkhtmltopdf) don't honour them and the shape would vanish.\n layer.style.cssText =\n 'position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden;';\n // Stash the editable model so re-opening the designer restores the shape.\n layer.dataset.penPath = design.penPath || '';\n layer.dataset.penStyle = design.penStyle || '';\n\n const wrap = document.createElement('div');\n wrap.innerHTML = design.svg;\n const svg = wrap.querySelector('svg');\n if (!svg) return;\n svg.setAttribute('preserveAspectRatio', 'none');\n svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;';\n uniquifyIds(svg, `pg${uidSeq += 1}`);\n layer._csShapeUniq = true; // mark so the new-page watcher won't re-uniquify\n layer.appendChild(svg);\n\n // Insert first so it paints first; z-index keeps it under content anyway.\n pageEl.insertBefore(layer, pageEl.firstChild);\n };\n\n // Read the design currently stored on a page (so the designer can restore it).\n const readDesignFromPage = (pageEl) => {\n if (!pageEl) return null;\n const layer = pageEl.querySelector(`:scope > .${LAYER_CLASS}`);\n if (!layer) return null;\n const svg = layer.querySelector('svg');\n return {\n svg: svg ? svg.outerHTML : '',\n penPath: layer.dataset.penPath || '',\n penStyle: layer.dataset.penStyle || '',\n };\n };\n\n /* --------------------------- block <-> design ----------------------------- */\n\n // Capture whatever is drawn in the editor right now as a design (or null when\n // nothing is drawn → treated as \"remove the shape\"). Ends the pen session so\n // the final <path>/<defs> are written + rendered.\n const captureBlock = () => {\n if (!block) return null;\n try { window.PenShape?.deactivate?.(); } catch (e) { /* */ }\n const svg = block.querySelector('.cs-pen-svg');\n const hasShape = svg && Array.from(svg.querySelectorAll('.cs-pen-fill'))\n .some((p) => (p.getAttribute('d') || '').trim().length > 0);\n if (!hasShape) return null;\n const clean = svg.cloneNode(true);\n return {\n svg: clean.outerHTML,\n penPath: block.dataset.penPath || '',\n penStyle: block.dataset.penStyle || '',\n };\n };\n\n // Load a design (or a blank shape) into the editor block, then repaint.\n const loadBlock = (design) => {\n if (!block) return;\n if (design && design.penPath) {\n block.dataset.penPath = design.penPath;\n block.dataset.penStyle = design.penStyle || '';\n } else {\n block.dataset.penPath = JSON.stringify({ paths: [] });\n block.dataset.penStyle = '';\n }\n try { window.PenShape.renderShape(block); } catch (e) { /* */ }\n };\n\n // (Re)start the pen session on the block and hand the engine the modal's\n // side panels. Deferred a frame so the stage has real dimensions.\n const activateBlock = () => {\n requestAnimationFrame(() => {\n if (!block || !modal) return;\n window.PenShape.activate(block);\n window.PenShape.setLayersPanel?.(modal.querySelector('[data-layers-list]'));\n window.PenShape.setPropsPanel?.(modal.querySelector('[data-props-host]'));\n layoutStage();\n });\n };\n\n /* --------------------------------- modal ---------------------------------- */\n\n const buildModal = (dims) => {\n const el = hostDoc.createElement('div');\n el.className = 'cs-page-shape-modal';\n el.innerHTML = `\n <div class=\"cs-page-shape-modal__backdrop\"></div>\n <div class=\"cs-page-shape-modal__panel\">\n <header class=\"cs-page-shape-modal__header\">\n <div class=\"cs-page-shape-modal__title\">\n Design Page Background\n <span class=\"cs-page-shape-modal__dims\">${Math.round(dims.w)} × ${Math.round(dims.h)} px</span>\n </div>\n <label class=\"cs-page-shape-modal__pagepick\">\n Page\n <select data-page-select></select>\n </label>\n <div class=\"cs-page-shape-modal__actions\">\n <button type=\"button\" data-act=\"clear\" class=\"cs-page-shape-btn cs-page-shape-btn--ghost\">Clear</button>\n <button type=\"button\" data-act=\"cancel\" class=\"cs-page-shape-btn cs-page-shape-btn--ghost\">Cancel</button>\n <button type=\"button\" data-act=\"save\" class=\"cs-page-shape-btn cs-page-shape-btn--primary\">Save &amp; Apply</button>\n </div>\n </header>\n <div class=\"cs-page-shape-modal__body\">\n <aside class=\"cs-page-shape-layers\">\n <div class=\"cs-page-shape-layers__title\">Layers</div>\n <div class=\"cs-page-shape-layers__list\" data-layers-list></div>\n <div class=\"cs-page-shape-layers__actions\">\n <button type=\"button\" data-layers-act=\"merge\" title=\"Merge selected layers\">Merge</button>\n <button type=\"button\" data-layers-act=\"lock\" title=\"Lock / unlock selected\">Lock</button>\n </div>\n <div class=\"cs-page-shape-layers__hint\">Ctrl/Cmd-click to multi-select · drag to reorder (top = front)</div>\n </aside>\n <div class=\"cs-page-shape-stagewrap\">\n <div class=\"cs-page-shape-stage\"></div>\n <div class=\"cs-page-shape-zoom\">\n <button type=\"button\" data-zoom=\"out\" title=\"Zoom out\">−</button>\n <button type=\"button\" data-zoom=\"fit\" class=\"cs-page-shape-zoom__val\" title=\"Reset to fit\">100%</button>\n <button type=\"button\" data-zoom=\"in\" title=\"Zoom in\">+</button>\n </div>\n </div>\n <aside class=\"cs-page-shape-shapes\" data-shapes-panel>\n <div class=\"cs-page-shape-shapes__title\">Trace reference</div>\n <div class=\"cs-page-shape-ref\">\n <label class=\"cs-page-shape-ref__btn\">\n <input type=\"file\" accept=\"image/*\" data-ref-file>\n <span>⬆&nbsp; Upload image</span>\n </label>\n <label class=\"cs-page-shape-ref__op\">\n <span>Dim</span>\n <input type=\"range\" min=\"5\" max=\"100\" value=\"45\" data-ref-op>\n </label>\n <label class=\"cs-page-shape-ref__chk\">\n <input type=\"checkbox\" data-trace-outline>\n <span>Outline only — mark without fill (so the image stays visible)</span>\n </label>\n <button type=\"button\" data-ref-clear class=\"cs-page-shape-ref__clear\">Remove reference</button>\n <p class=\"cs-page-shape-ref__hint\">Drop an image, dim it, then trace it with the pen tool. It's only a guide — it is NOT saved with the shape.</p>\n </div>\n <div class=\"cs-page-shape-shapes__title\">Properties</div>\n <div class=\"cs-page-shape-props\" data-props-host></div>\n <div class=\"cs-page-shape-shapes__title\">Shapes</div>\n <div class=\"cs-page-shape-size\">\n <label>W <input type=\"number\" data-shape-w min=\"10\" step=\"10\" value=\"220\"></label>\n <label>H <input type=\"number\" data-shape-h min=\"10\" step=\"10\" value=\"160\"></label>\n </div>\n <div class=\"cs-page-shape-shapes__grid\">\n <button type=\"button\" data-preset=\"rectangle\" title=\"Rectangle\">▭</button>\n <button type=\"button\" data-preset=\"square\" title=\"Square\">◻</button>\n <button type=\"button\" data-preset=\"rounded-rect\" title=\"Rounded rectangle\">▢</button>\n <button type=\"button\" data-preset=\"pill\" title=\"Pill / capsule\">⬭</button>\n <button type=\"button\" data-preset=\"ellipse\" title=\"Ellipse / circle\">◯</button>\n <button type=\"button\" data-preset=\"triangle\" title=\"Triangle\">△</button>\n <button type=\"button\" data-preset=\"triangle-down\" title=\"Triangle down\">▽</button>\n <button type=\"button\" data-preset=\"right-triangle\" title=\"Right triangle\">◣</button>\n <button type=\"button\" data-preset=\"diamond\" title=\"Diamond\">◇</button>\n <button type=\"button\" data-preset=\"pentagon\" title=\"Pentagon\">⬠</button>\n <button type=\"button\" data-preset=\"hexagon\" title=\"Hexagon\">⬡</button>\n <button type=\"button\" data-preset=\"heptagon\" title=\"Heptagon\">⬣</button>\n <button type=\"button\" data-preset=\"octagon\" title=\"Octagon\">⯃</button>\n <button type=\"button\" data-preset=\"parallelogram\" title=\"Parallelogram\">▰</button>\n <button type=\"button\" data-preset=\"trapezoid\" title=\"Trapezoid\">⏢</button>\n <button type=\"button\" data-preset=\"star\" title=\"Star (5)\">★</button>\n <button type=\"button\" data-preset=\"star-4\" title=\"Star (4)\">✦</button>\n <button type=\"button\" data-preset=\"star-6\" title=\"Star (6)\">✶</button>\n <button type=\"button\" data-preset=\"star-12\" title=\"Star (12)\">✺</button>\n <button type=\"button\" data-preset=\"burst\" title=\"Burst / seal\">❉</button>\n <button type=\"button\" data-preset=\"arrow-right\" title=\"Arrow right\">➜</button>\n <button type=\"button\" data-preset=\"arrow-left\" title=\"Arrow left\">⬅</button>\n <button type=\"button\" data-preset=\"arrow-up\" title=\"Arrow up\">⬆</button>\n <button type=\"button\" data-preset=\"arrow-down\" title=\"Arrow down\">⬇</button>\n <button type=\"button\" data-preset=\"arrow-h\" title=\"Double arrow (horizontal)\">↔</button>\n <button type=\"button\" data-preset=\"arrow-v\" title=\"Double arrow (vertical)\">↕</button>\n <button type=\"button\" data-preset=\"chevron\" title=\"Chevron\">❯</button>\n <button type=\"button\" data-preset=\"plus\" title=\"Plus / cross\">✚</button>\n <button type=\"button\" data-preset=\"heart\" title=\"Heart\">♥</button>\n <button type=\"button\" data-preset=\"speech\" title=\"Speech bubble\">💬</button>\n <button type=\"button\" data-preset=\"banner\" title=\"Banner / ribbon\">⚑</button>\n <button type=\"button\" data-preset=\"cloud\" title=\"Cloud\">☁</button>\n </div>\n <div class=\"cs-page-shape-shapes__title\">Page backgrounds</div>\n <div class=\"cs-page-shape-shapes__grid\">\n <button type=\"button\" data-preset=\"corner\" title=\"Corner wedge (full bleed)\">◣</button>\n <button type=\"button\" data-preset=\"diagonal\" title=\"Diagonal band (full bleed)\">◹</button>\n <button type=\"button\" data-preset=\"header\" title=\"Header bar (full bleed)\">▀</button>\n <button type=\"button\" data-preset=\"footer\" title=\"Footer bar (full bleed)\">▄</button>\n </div>\n </aside>\n </div>\n </div>`;\n return el;\n };\n\n // Fill the page selector with one option per page, marking the target page.\n const populatePageSelect = () => {\n const sel = modal?.querySelector('[data-page-select]');\n if (!sel) return;\n const labels = labelPages(pageList);\n sel.innerHTML = pageList\n .map((p, i) => `<option value=\"${i}\"${p === targetPage ? ' selected' : ''}>${labels[i]}</option>`)\n .join('');\n };\n\n // Fit the page (dims) inside the available modal body area, preserving aspect.\n // Sized against the HOST window (full app), since the modal lives there.\n const fitStageSize = (dims) => {\n const maxW = Math.max(200, hostWin.innerWidth - 460); // leave room for both side panels\n const maxH = Math.max(200, hostWin.innerHeight - 180);\n const scale = Math.min(maxW / dims.w, maxH / dims.h, 1);\n return { w: Math.round(dims.w * scale), h: Math.round(dims.h * scale) };\n };\n\n // Zoom multiplier on top of the fit size (1 = fit-to-window). Lets the user\n // zoom into the trace reference for precise anchor/handle placement; the\n // stagewrap scrolls when the stage grows past the viewport.\n let zoom = 1;\n\n const updateZoomLabel = () => {\n const el = modal && modal.querySelector('.cs-page-shape-zoom__val');\n if (el) el.textContent = `${Math.round(zoom * 100)}%`;\n };\n\n const setZoom = (z) => {\n zoom = Math.max(0.25, Math.min(6, z));\n layoutStage();\n };\n\n // Size the stage + drawing block to fit the host window, preserving the page\n // aspect ratio, then apply the zoom factor. Re-run on host window resize.\n const layoutStage = () => {\n if (!modal || !block) return;\n const dims = getPageDims();\n const fit = fitStageSize(dims);\n const w = Math.round(fit.w * zoom);\n const h = Math.round(fit.h * zoom);\n const stage = modal.querySelector('.cs-page-shape-stage');\n if (stage) { stage.style.width = `${w}px`; stage.style.height = `${h}px`; }\n block.style.width = `${w}px`;\n block.style.height = `${h}px`;\n updateZoomLabel();\n };\n let onResize = null;\n\n /* --------------------------- trace reference image ------------------------ */\n // A faint image behind the pen block that the user traces over (Photoshop\n // \"template layer\" style). It lives inside the stage, behind the pen overlay,\n // with pointer-events:none so every click still reaches the pen tool. It is\n // purely a guide — Save reads only the pen SVG, so the image never ends up in\n // the saved shape or the exported PDF.\n\n const refEl = () => modal && modal.querySelector('[data-ref-img]');\n\n const setReference = (url) => {\n const el = refEl();\n if (!el) return;\n if (url) { el.style.backgroundImage = `url(\"${url}\")`; el.classList.add('is-on'); }\n else { el.style.backgroundImage = 'none'; el.classList.remove('is-on'); }\n };\n\n const setReferenceOpacity = (pct) => {\n const el = refEl();\n if (el) el.style.opacity = String(Math.max(0, Math.min(1, (Number(pct) || 0) / 100)));\n };\n\n const loadReferenceFile = (file) => {\n if (!file || !/^image\\//.test(file.type || '')) return;\n const reader = new FileReader();\n reader.onload = () => setReference(reader.result);\n reader.readAsDataURL(file);\n };\n\n const close = () => {\n if (!modal) return;\n try { window.PenShape?.deactivate?.(); } catch (e) { /* */ }\n if (onResize) { hostWin.removeEventListener('resize', onResize); onResize = null; }\n modal.remove();\n modal = null;\n block = null;\n targetPage = null;\n pageList = [];\n sessionDesigns = null;\n };\n\n // Move to a different page: stash the current page's edits, then load the\n // selected page's design into the editor.\n const switchToPage = (pageEl) => {\n if (!pageEl || pageEl === targetPage) return;\n sessionDesigns.set(targetPage, captureBlock());\n targetPage = pageEl;\n const design = sessionDesigns.has(pageEl) ? sessionDesigns.get(pageEl) : readDesignFromPage(pageEl);\n loadBlock(design);\n activateBlock();\n };\n\n const save = () => {\n if (!block || !sessionDesigns) { close(); return; }\n // Capture the page currently open, then flush every page edited this\n // session. Pages never visited keep their existing layer untouched.\n sessionDesigns.set(targetPage, captureBlock());\n sessionDesigns.forEach((design, pageEl) => {\n if (document.contains(pageEl)) injectLayer(pageEl, design);\n });\n close();\n };\n\n const open = () => {\n if (modal) return;\n if (!window.PenShape || typeof window.PenShape.createBlock !== 'function') {\n console.warn('[PageShapeDesigner] PenShape engine not available');\n return;\n }\n\n pageList = getAllPages();\n targetPage = resolveActivePage();\n if (!targetPage) {\n console.warn('[PageShapeDesigner] no page to design');\n return;\n }\n if (!pageList.includes(targetPage)) pageList = getAllPages();\n sessionDesigns = new Map();\n\n // Render the modal in the HOST document (root) so it covers the whole app\n // like the save-as modal — no iframe resizing needed.\n ensureHostStyles();\n\n const dims = getPageDims();\n modal = buildModal(dims);\n hostDoc.body.appendChild(modal);\n populatePageSelect();\n\n const stage = modal.querySelector('.cs-page-shape-stage');\n zoom = 1;\n\n // Build a clean pen-shape block; layoutStage() sizes it to the stage.\n block = window.PenShape.createBlock();\n block.classList.add('cs-page-shape-block');\n block.style.margin = '0';\n\n // Trace-reference layer sits BEHIND the pen block (inserted first).\n const refImg = document.createElement('div');\n refImg.className = 'cs-page-shape-ref-img';\n refImg.setAttribute('data-ref-img', '');\n refImg.setAttribute('aria-hidden', 'true');\n refImg.style.opacity = '0.45';\n stage.appendChild(refImg);\n\n // Restore the target page's existing design (if any) into the editor.\n loadBlock(readDesignFromPage(targetPage));\n\n stage.appendChild(block);\n layoutStage();\n\n modal.addEventListener('change', (e) => {\n if (e.target.matches('[data-page-select]')) {\n const next = pageList[Number(e.target.value)];\n switchToPage(next);\n return;\n }\n if (e.target.matches('[data-ref-file]')) {\n loadReferenceFile(e.target.files && e.target.files[0]);\n return;\n }\n if (e.target.matches('[data-trace-outline]')) {\n const st = modal.querySelector('.cs-page-shape-stage');\n if (st) st.classList.toggle('cs-trace-outline', e.target.checked);\n }\n });\n\n modal.addEventListener('input', (e) => {\n if (e.target.matches('[data-ref-op]')) setReferenceOpacity(e.target.value);\n });\n\n modal.addEventListener('click', (e) => {\n const preset = e.target.closest('[data-preset]')?.dataset.preset;\n if (preset) {\n try {\n // Convert the W/H (page px) into viewBox units so the shape drops in\n // at the chosen size instead of filling the page.\n const dims = getPageDims();\n const VBU = window.PenShape?.VIEWBOX || 1000;\n const wpx = Number(modal.querySelector('[data-shape-w]')?.value) || 0;\n const hpx = Number(modal.querySelector('[data-shape-h]')?.value) || 0;\n const opts = (wpx > 0 && hpx > 0)\n ? { w: (wpx / dims.w) * VBU, h: (hpx / dims.h) * VBU }\n : null;\n window.PenShape?.loadPreset?.(preset, opts);\n } catch (err) { /* */ }\n return;\n }\n const zc = e.target.closest('[data-zoom]')?.dataset.zoom;\n if (zc) {\n if (zc === 'in') setZoom(zoom * 1.25);\n else if (zc === 'out') setZoom(zoom / 1.25);\n else setZoom(1);\n return;\n }\n if (e.target.closest('[data-ref-clear]')) {\n setReference(null);\n const f = modal.querySelector('[data-ref-file]');\n if (f) f.value = '';\n return;\n }\n const lact = e.target.closest('[data-layers-act]')?.dataset.layersAct;\n if (lact === 'merge') { try { window.PenShape?.mergeSelected?.(); } catch (err) { /* */ } return; }\n if (lact === 'lock') { try { window.PenShape?.toggleLockSelected?.(); } catch (err) { /* */ } return; }\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act === 'cancel') return close();\n if (act === 'save') return save();\n if (act === 'clear') { try { window.PenShape?.clearAllPaths?.(); } catch (err) { /* */ } return; }\n if (e.target.classList.contains('cs-page-shape-modal__backdrop')) return close();\n });\n\n // Ctrl/Cmd + wheel zooms the stage (the pen engine's ResizeObserver redraws\n // the overlay to the new size; the stagewrap scrolls when it overflows).\n const stagewrap = modal.querySelector('.cs-page-shape-stagewrap');\n if (stagewrap) {\n stagewrap.addEventListener('wheel', (e) => {\n if (!e.ctrlKey && !e.metaKey) return;\n e.preventDefault();\n setZoom(zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12));\n }, { passive: false });\n }\n\n // Re-fit when the host window resizes. The pen engine's ResizeObserver\n // redraws the overlay to the new size.\n onResize = () => layoutStage();\n hostWin.addEventListener('resize', onResize);\n\n // Activate the pen session once the stage has real dimensions.\n activateBlock();\n };\n\n // Remove the shape from the page the user is currently working on.\n const removeFromActive = () => {\n const page = resolveActivePage();\n if (!page) return;\n page.querySelectorAll(`:scope > .${LAYER_CLASS}`).forEach((el) => el.remove());\n };\n\n // Remove the shape from every page (used by tooling, not the per-page UI).\n const clearAll = () => {\n getAllPages().forEach((page) => {\n page.querySelectorAll(`:scope > .${LAYER_CLASS}`).forEach((el) => el.remove());\n });\n };\n\n /* -------------------- keep cloned pages' def ids unique ------------------- */\n\n // Page shapes are per-page, so newly-added pages do NOT inherit any design.\n // But duplicating a page that already has a shape clones its <svg> verbatim —\n // duplicate gradient/pattern ids in one document make them all resolve to the\n // first. Re-uniquify any cloned layer's ids so each page renders its own.\n const watchNewPages = () => {\n const root = getPagesRoot();\n if (!root) return;\n const obs = new MutationObserver((muts) => {\n for (const m of muts) {\n for (const node of m.addedNodes) {\n if (node.nodeType !== 1) continue;\n const pages = node.matches?.(PAGE_SEL)\n ? [node]\n : Array.from(node.querySelectorAll?.(PAGE_SEL) || []);\n pages.forEach((pageEl) => {\n const layer = pageEl.querySelector(`:scope > .${LAYER_CLASS}`);\n const svg = layer && layer.querySelector('svg');\n // _csShapeUniq is a JS property (not an attribute) so it is NOT\n // copied by cloneNode — a freshly-cloned layer lacks it and gets\n // re-uniquified exactly once.\n if (svg && !layer._csShapeUniq) {\n uniquifyIds(svg, `pg${uidSeq += 1}`);\n layer._csShapeUniq = true;\n }\n });\n }\n }\n });\n obs.observe(root, { childList: true, subtree: true });\n };\n\n Object.assign(window.PageShapeDesigner, { open, removeFromActive, clearAll });\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', watchNewPages);\n } else {\n watchNewPages();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/collab.js\">\n/**\n * @fileoverview Real-time collaboration: presence + comments & mentions.\n *\n * Runs INSIDE the canvas iframe and renders its own floating UI over the canvas\n * (a toolbar, remote cursors, an avatar stack, comment pins + threads). It needs\n * no Angular changes.\n *\n * Transport: connects to the server relay at ws(s)://<host>/collab?doc=<id>.\n * If the WebSocket can't open (e.g. running under `ng serve` without the SSR\n * server), it falls back to a same-origin BroadcastChannel so presence +\n * comments still work live across browser TABS — handy for testing. The two\n * transports speak the exact same JSON messages, so the WS backend is a drop-in.\n *\n * Identity: a lightweight local user { id, name, color } in localStorage\n * (name is editable). This is identity-only — no passwords yet.\n *\n * Message types (all fan-out via the relay):\n * presence:hello | presence:cursor | presence:select | presence:leave\n * comment:add | comment:reply | comment:resolve | comment:delete\n */\n(function () {\n 'use strict';\n const DOC_ID = (new URLSearchParams(location.search).get('doc')) || 'default';\n const USER_KEY = 'cs-collab-user';\n const COMMENTS_KEY = 'cs-collab-comments-' + DOC_ID;\n const COLORS = ['#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];\n const uid = (p) => p + Math.random().toString(16).slice(2, 10);\n\n // The persistent toolbar must render in the HOST window — this canvas iframe\n // is a tall element scrolled by the host, so a `position:fixed` toolbar inside\n // it lands at the bottom of the tall iframe (off-screen). Pins/cursors/popovers\n // stay in the iframe (they're anchored to content, which scrolls with it).\n const hostWin = (() => { try { return (window.parent && window.parent !== window) ? window.parent : window; } catch (e) { return window; } })();\n const hostDoc = hostWin.document;\n\n // Feature flags, driven by the host settings toggles (collab:config message).\n // Default ON; the host pushes the real values on load + whenever toggled.\n let cfg = { comments: true, presence: true };\n\n /* ------------------------------- identity -------------------------------- */\n const loadUser = () => {\n try { const u = JSON.parse(localStorage.getItem(USER_KEY)); if (u && u.id) return u; } catch (e) { /* */ }\n const u = { id: uid('u_'), name: 'Guest ' + Math.floor(100 + Math.random() * 900), color: COLORS[Math.floor(Math.random() * COLORS.length)] };\n localStorage.setItem(USER_KEY, JSON.stringify(u));\n return u;\n };\n let me = loadUser();\n const saveUser = () => localStorage.setItem(USER_KEY, JSON.stringify(me));\n\n /* ------------------------------- transport ------------------------------- */\n let send = () => { };\n const listeners = [];\n const onMsg = (fn) => listeners.push(fn);\n const dispatch = (msg) => { if (msg) listeners.forEach((fn) => { try { fn(msg); } catch (e) { /* */ } }); };\n\n const initTransport = () => {\n let ws = null, bc = null, wsOk = false;\n const startBC = () => {\n if (bc) return;\n try {\n bc = new BroadcastChannel('cs-collab-' + DOC_ID);\n send = (m) => { try { bc.postMessage(m); } catch (e) { /* */ } };\n bc.onmessage = (e) => dispatch(e.data);\n hello();\n } catch (e) { /* */ }\n };\n try {\n const proto = location.protocol === 'https:' ? 'wss' : 'ws';\n ws = new WebSocket(`${proto}://${location.host}/collab?doc=${encodeURIComponent(DOC_ID)}`);\n ws.onopen = () => { wsOk = true; send = (m) => { try { ws.send(JSON.stringify(m)); } catch (e) { /* */ } }; hello(); };\n ws.onmessage = (e) => { try { dispatch(JSON.parse(e.data)); } catch (err) { /* */ } };\n ws.onclose = () => { if (!wsOk) startBC(); };\n ws.onerror = () => { if (!wsOk) startBC(); };\n } catch (e) { startBC(); }\n setTimeout(() => { if (!wsOk && !bc) startBC(); }, 1500);\n };\n const hello = () => send({ type: 'presence:hello', user: me });\n\n /* --------------------------- coordinate mapping -------------------------- */\n // Cursors are shared in page-relative fractions so they line up regardless of\n // each peer's scroll position / window size.\n const docs = () => Array.from(document.querySelectorAll('.cs_margin'));\n const docAt = (cx, cy) => docs().find((d) => { const r = d.getBoundingClientRect(); return cy >= r.top && cy <= r.bottom && cx >= r.left && cx <= r.right; });\n const toPageFrac = (cx, cy) => {\n const all = docs(); const d = docAt(cx, cy) || all[0]; if (!d) return null;\n const r = d.getBoundingClientRect();\n return { page: all.indexOf(d), nx: (cx - r.left) / r.width, ny: (cy - r.top) / r.height };\n };\n const fromPageFrac = (p) => {\n const all = docs(); const d = all[p.page] || all[0]; if (!d) return null;\n const r = d.getBoundingClientRect();\n return { x: r.left + p.nx * r.width, y: r.top + p.ny * r.height };\n };\n // Comments anchor to a block id + fractional offset, so they follow the block.\n const blockAnchor = (cx, cy) => {\n const el = document.elementFromPoint(cx, cy);\n const block = el && el.closest && el.closest('.cs_block_s, .canvas-block');\n if (block) {\n if (!block.id) block.id = uid('block_');\n const r = block.getBoundingClientRect();\n return { blockId: block.id, relX: (cx - r.left) / r.width, relY: (cy - r.top) / r.height };\n }\n const pf = toPageFrac(cx, cy);\n return pf ? { page: pf.page, nx: pf.nx, ny: pf.ny } : null;\n };\n const anchorToViewport = (a) => {\n if (!a) return null;\n if (a.blockId) {\n const b = document.getElementById(a.blockId);\n if (!b) return null;\n const r = b.getBoundingClientRect();\n return { x: r.left + (a.relX || 0) * r.width, y: r.top + (a.relY || 0) * r.height };\n }\n return fromPageFrac(a);\n };\n\n /* --------------------------------- styles -------------------------------- */\n const STYLE = `\n .cs-collab-cursor{position:fixed;z-index:99000;pointer-events:none;transform:translate(-2px,-2px);transition:left .08s linear,top .08s linear}\n .cs-collab-cursor svg{display:block;filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}\n .cs-collab-cursor span{position:absolute;left:14px;top:10px;white-space:nowrap;font:600 11px/1 Inter,sans-serif;color:#fff;padding:2px 6px;border-radius:4px}\n .cs-collab-selout{position:fixed;z-index:98000;pointer-events:none;border:2px solid;border-radius:4px}\n .cs-collab-selout span{position:absolute;top:-18px;left:-2px;font:600 10px/1 Inter,sans-serif;color:#fff;padding:2px 5px;border-radius:3px}\n .cs-collab-bar{position:fixed;left:12px;bottom:12px;z-index:99500;display:flex;align-items:center;gap:8px;background:#111827;color:#fff;border-radius:10px;padding:6px 8px;box-shadow:0 6px 20px rgba(0,0,0,.3);font:500 12px/1 Inter,sans-serif}\n .cs-collab-bar button{border:none;background:#374151;color:#fff;border-radius:6px;padding:6px 9px;font-size:12px;cursor:pointer}\n .cs-collab-bar button.on{background:#248567}\n .cs-collab-avatars{display:flex}\n .cs-collab-av{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:#fff;font:700 10px/1 Inter,sans-serif;border:2px solid #111827;margin-left:-6px;cursor:default}\n .cs-collab-av:first-child{margin-left:0}\n .cs-collab-pin{position:fixed;z-index:98500;width:26px;height:26px;border-radius:50% 50% 50% 2px;display:grid;place-items:center;color:#fff;font-size:13px;cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,.35);border:2px solid #fff}\n .cs-collab-pin.resolved{opacity:.45}\n .cs-collab-pop{position:fixed;z-index:99600;width:300px;max-height:60vh;overflow:auto;background:#fff;border:1px solid #e5e7eb;border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.25);font:13px/1.4 Inter,sans-serif;color:#1f2937}\n .cs-collab-pop__hd{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid #eee;font-weight:600}\n .cs-collab-pop__msgs{padding:8px 12px;display:flex;flex-direction:column;gap:10px}\n .cs-collab-msg__a{font-weight:600;font-size:12px}\n .cs-collab-msg__t{font-size:11px;color:#9ca3af;margin-left:6px}\n .cs-collab-msg__b{font-size:13px;margin-top:2px;white-space:pre-wrap}\n .cs-collab-msg__b .men{color:#2563eb;font-weight:600}\n .cs-collab-comp{position:relative;padding:8px 12px;border-top:1px solid #eee}\n .cs-collab-comp textarea{width:100%;box-sizing:border-box;border:1px solid #e5e7eb;border-radius:6px;padding:6px 8px;font:13px Inter,sans-serif;resize:vertical;min-height:42px}\n .cs-collab-comp__row{display:flex;justify-content:flex-end;gap:6px;margin-top:6px}\n .cs-collab-comp__row button{border:none;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer}\n .cs-collab-btn-primary{background:#248567;color:#fff}\n .cs-collab-btn-ghost{background:#f3f4f6;color:#374151}\n .cs-collab-ment{position:absolute;left:12px;right:12px;bottom:60px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.18);max-height:160px;overflow:auto}\n .cs-collab-ment div{padding:7px 10px;cursor:pointer;display:flex;align-items:center;gap:8px}\n .cs-collab-ment div:hover,.cs-collab-ment div.sel{background:#eef2ff}\n .cs-collab-dot{width:16px;height:16px;border-radius:50%;color:#fff;display:grid;place-items:center;font:700 8px/1 Inter}\n body.cs-comment-mode .cs_paper{cursor:crosshair}`;\n const injectStyle = () => {\n const targets = hostDoc === document ? [document] : [document, hostDoc];\n targets.forEach((d) => {\n if (!d || d.getElementById('cs-collab-style')) return;\n const s = d.createElement('style'); s.id = 'cs-collab-style'; s.textContent = STYLE; d.head.appendChild(s);\n });\n };\n\n /* ------------------------------- presence -------------------------------- */\n const peers = new Map(); // userId → { user, lastSeen }\n const cursorEls = new Map(); // userId → element\n const selEls = new Map(); // userId → element\n const knownUsers = () => { const m = new Map(); m.set(me.id, me); peers.forEach((p) => m.set(p.user.id, p.user)); return Array.from(m.values()); };\n\n const initial = (name) => (name || '?').trim().charAt(0).toUpperCase();\n let avatarsEl;\n const renderAvatars = () => {\n if (!avatarsEl) return;\n const users = knownUsers();\n avatarsEl.innerHTML = '';\n users.forEach((u) => {\n const a = document.createElement('div');\n a.className = 'cs-collab-av'; a.style.background = u.color; a.textContent = initial(u.name);\n a.title = u.id === me.id ? `${u.name} (you)` : u.name;\n avatarsEl.appendChild(a);\n });\n };\n\n const showCursor = (user, pos) => {\n if (!pos) return;\n let el = cursorEls.get(user.id);\n if (!el) {\n el = document.createElement('div'); el.className = 'cs-collab-cursor';\n el.innerHTML = `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M1 1l5 13 2-5 5-2z\" fill=\"${user.color}\"/></svg><span style=\"background:${user.color}\">${user.name}</span>`;\n document.body.appendChild(el); cursorEls.set(user.id, el);\n }\n el.style.left = pos.x + 'px'; el.style.top = pos.y + 'px';\n };\n const showRemoteSelection = (user, blockId) => {\n let el = selEls.get(user.id);\n const b = blockId && document.getElementById(blockId);\n if (!b) { if (el) { el.remove(); selEls.delete(user.id); } return; }\n if (!el) {\n el = document.createElement('div'); el.className = 'cs-collab-selout';\n el.style.borderColor = user.color;\n el.innerHTML = `<span style=\"background:${user.color}\">${user.name}</span>`;\n document.body.appendChild(el); selEls.set(user.id, el);\n }\n el._blockId = blockId;\n const r = b.getBoundingClientRect();\n el.style.left = r.left + 'px'; el.style.top = r.top + 'px';\n el.style.width = r.width + 'px'; el.style.height = r.height + 'px';\n };\n const dropPeer = (userId) => {\n peers.delete(userId);\n [cursorEls, selEls].forEach((m) => { const e = m.get(userId); if (e) e.remove(); m.delete(userId); });\n renderAvatars();\n };\n const touchPeer = (user) => {\n const isNew = !peers.has(user.id);\n peers.set(user.id, { user, lastSeen: performance.now() });\n if (isNew) { renderAvatars(); hello(); /* let the new peer learn us */ }\n };\n // Reap peers we haven't heard from in a while (covers BroadcastChannel, which\n // has no disconnect event).\n setInterval(() => {\n const now = performance.now();\n peers.forEach((p, id) => { if (now - p.lastSeen > 15000) dropPeer(id); });\n }, 5000);\n\n let lastCursorSent = 0;\n const onPointerMove = (e) => {\n if (!cfg.presence) return;\n const now = performance.now();\n if (now - lastCursorSent < 60) return;\n lastCursorSent = now;\n const pf = toPageFrac(e.clientX, e.clientY);\n if (pf) send({ type: 'presence:cursor', user: me, pos: pf });\n };\n\n /* -------------------------------- comments ------------------------------- */\n let comments = [];\n const loadComments = () => { try { comments = JSON.parse(localStorage.getItem(COMMENTS_KEY) || '[]'); } catch (e) { comments = []; } };\n const persistComments = () => {\n try { localStorage.setItem(COMMENTS_KEY, JSON.stringify(comments)); } catch (e) { /* */ }\n // Best-effort server persistence (no-op under ng serve).\n try { fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doc: DOC_ID, comments }) }).catch(() => { }); } catch (e) { /* */ }\n };\n const fetchServerComments = () => {\n try {\n fetch('/api/comments?doc=' + encodeURIComponent(DOC_ID)).then((r) => r.json()).then((d) => {\n if (Array.isArray(d.comments) && d.comments.length) { comments = mergeComments(comments, d.comments); renderPins(); }\n }).catch(() => { });\n } catch (e) { /* */ }\n };\n const mergeComments = (a, b) => { const m = new Map();[...a, ...b].forEach((c) => m.set(c.id, c)); return Array.from(m.values()); };\n\n const fmtTime = (ts) => { const d = (new Date(ts)).getTime?.() ? new Date(ts) : new Date(); const diff = Date.now() - ts; const mn = Math.floor(diff / 60000); if (mn < 1) return 'just now'; if (mn < 60) return mn + 'm'; const h = Math.floor(mn / 60); if (h < 24) return h + 'h'; return Math.floor(h / 24) + 'd'; };\n const escapeHtml = (s) => (s || '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));\n const renderBody = (text) => escapeHtml(text).replace(/@([A-Za-z0-9 _-]{1,24})/g, '<span class=\"men\">@$1</span>');\n\n let commentMode = false, panelOpen = false, openThreadId = null;\n const pinEls = new Map();\n\n const renderPins = () => {\n if (!cfg.comments) { pinEls.forEach((el) => { el.style.display = 'none'; }); return; }\n const live = new Set();\n comments.forEach((c) => {\n if (c.parentId) return; // only roots get pins\n live.add(c.id);\n const pos = anchorToViewport(c.anchor);\n let pin = pinEls.get(c.id);\n if (!pos) { if (pin) { pin.style.display = 'none'; } return; }\n if (!pin) {\n pin = document.createElement('div'); pin.className = 'cs-collab-pin';\n pin.addEventListener('click', (e) => { e.stopPropagation(); openThread(c.id); });\n document.body.appendChild(pin); pinEls.set(c.id, pin);\n }\n pin.style.display = '';\n pin.style.background = c.color || '#248567';\n pin.classList.toggle('resolved', !!c.resolved);\n pin.textContent = c.resolved ? '✓' : '💬';\n pin.style.left = pos.x + 'px'; pin.style.top = (pos.y - 26) + 'px';\n });\n pinEls.forEach((el, id) => { if (!live.has(id)) { el.remove(); pinEls.delete(id); } });\n };\n\n const threadOf = (rootId) => comments.filter((c) => c.id === rootId || c.parentId === rootId)\n .sort((a, b) => a.createdAt - b.createdAt);\n\n let popEl = null;\n const closePopover = () => { if (popEl) { popEl.remove(); popEl = null; } openThreadId = null; };\n const openThread = (rootId) => {\n closePopover();\n const root = comments.find((c) => c.id === rootId); if (!root) return;\n openThreadId = rootId;\n const pos = anchorToViewport(root.anchor) || { x: 100, y: 100 };\n popEl = document.createElement('div'); popEl.className = 'cs-collab-pop';\n popEl.style.left = Math.min(window.innerWidth - 312, pos.x + 16) + 'px';\n popEl.style.top = Math.min(window.innerHeight - 280, Math.max(8, pos.y - 20)) + 'px';\n const msgs = threadOf(rootId).map((c) => `\n <div data-mid=\"${c.id}\">\n <div><span class=\"cs-collab-msg__a\" style=\"color:${c.color || '#1f2937'}\">${escapeHtml(c.author)}</span><span class=\"cs-collab-msg__t\">${fmtTime(c.createdAt)}</span></div>\n <div class=\"cs-collab-msg__b\">${renderBody(c.body)}</div>\n </div>`).join('');\n popEl.innerHTML = `\n <div class=\"cs-collab-pop__hd\">\n <span>Comment ${root.resolved ? '· resolved' : ''}</span>\n <span>\n <button class=\"cs-collab-btn-ghost\" data-act=\"resolve\" style=\"padding:4px 8px;border:none;border-radius:5px;cursor:pointer;font-size:11px\">${root.resolved ? 'Reopen' : 'Resolve'}</button>\n <button class=\"cs-collab-btn-ghost\" data-act=\"del\" style=\"padding:4px 8px;border:none;border-radius:5px;cursor:pointer;font-size:11px;color:#ef4444\">Delete</button>\n <button class=\"cs-collab-btn-ghost\" data-act=\"close\" style=\"padding:4px 8px;border:none;border-radius:5px;cursor:pointer;font-size:11px\">✕</button>\n </span>\n </div>\n <div class=\"cs-collab-pop__msgs\">${msgs}</div>\n <div class=\"cs-collab-comp\">\n <textarea placeholder=\"Reply… use @ to mention\"></textarea>\n <div class=\"cs-collab-comp__row\">\n <button class=\"cs-collab-btn-primary\" data-act=\"reply\">Reply</button>\n </div>\n </div>`;\n document.body.appendChild(popEl);\n const ta = popEl.querySelector('textarea');\n wireMentions(ta, popEl.querySelector('.cs-collab-comp'));\n popEl.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act === 'close') return closePopover();\n if (act === 'resolve') { toggleResolve(rootId); return; }\n if (act === 'del') { deleteThread(rootId); return; }\n if (act === 'reply') { const body = ta.value.trim(); if (body) addComment(root.anchor, body, rootId); ta.value = ''; }\n });\n };\n\n // @mention autocomplete\n const wireMentions = (ta, container) => {\n let menu = null, items = [], sel = 0, atPos = -1;\n const close = () => { if (menu) { menu.remove(); menu = null; } atPos = -1; };\n const apply = (u) => {\n const before = ta.value.slice(0, atPos);\n const after = ta.value.slice(ta.selectionStart);\n ta.value = before + '@' + u.name + ' ' + after;\n close(); ta.focus();\n };\n ta.addEventListener('input', () => {\n const caret = ta.selectionStart;\n const upto = ta.value.slice(0, caret);\n const m = /@([A-Za-z0-9 _-]*)$/.exec(upto);\n if (!m) return close();\n atPos = caret - m[0].length;\n const q = m[1].toLowerCase();\n items = knownUsers().filter((u) => u.name.toLowerCase().includes(q)).slice(0, 6);\n if (!items.length) return close();\n sel = 0;\n if (!menu) { menu = document.createElement('div'); menu.className = 'cs-collab-ment'; container.appendChild(menu); }\n menu.innerHTML = items.map((u, i) => `<div data-i=\"${i}\" class=\"${i === sel ? 'sel' : ''}\"><span class=\"cs-collab-dot\" style=\"background:${u.color}\">${initial(u.name)}</span>${escapeHtml(u.name)}</div>`).join('');\n menu.querySelectorAll('[data-i]').forEach((d) => d.addEventListener('mousedown', (e) => { e.preventDefault(); apply(items[+d.dataset.i]); }));\n });\n ta.addEventListener('keydown', (e) => {\n if (!menu) return;\n if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; }\n else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; }\n else if (e.key === 'Enter') { e.preventDefault(); apply(items[sel]); return; }\n else if (e.key === 'Escape') { close(); return; }\n else return;\n menu.querySelectorAll('[data-i]').forEach((d, i) => d.classList.toggle('sel', i === sel));\n });\n };\n\n const extractMentions = (body) => {\n const ids = []; const names = knownUsers();\n (body.match(/@([A-Za-z0-9 _-]{1,24})/g) || []).forEach((tok) => {\n const nm = tok.slice(1).trim();\n const u = names.find((x) => nm.startsWith(x.name) || x.name === nm);\n if (u) ids.push(u.id);\n });\n return Array.from(new Set(ids));\n };\n\n const addComment = (anchor, body, parentId) => {\n const c = {\n id: uid('c_'), docId: DOC_ID, anchor, body,\n author: me.name, authorId: me.id, color: me.color,\n mentions: extractMentions(body), parentId: parentId || null,\n resolved: false, createdAt: Date.now(),\n };\n comments.push(c); persistComments(); renderPins();\n send({ type: parentId ? 'comment:reply' : 'comment:add', comment: c });\n openThread(parentId || c.id);\n notifyMentions(c);\n };\n const toggleResolve = (rootId) => {\n const c = comments.find((x) => x.id === rootId); if (!c) return;\n c.resolved = !c.resolved; persistComments(); renderPins();\n send({ type: 'comment:resolve', id: rootId, resolved: c.resolved });\n openThread(rootId);\n };\n const deleteThread = (rootId) => {\n comments = comments.filter((c) => c.id !== rootId && c.parentId !== rootId);\n persistComments(); renderPins(); closePopover();\n send({ type: 'comment:delete', id: rootId });\n };\n const notifyMentions = (c) => {\n if (c.mentions && c.mentions.includes(me.id) && c.authorId !== me.id) toast(`${c.author} mentioned you`);\n };\n\n let toastEl;\n const toast = (text) => {\n if (!toastEl) { toastEl = hostDoc.createElement('div'); toastEl.className = 'cs-collab-toast'; toastEl.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:99999;background:#111827;color:#fff;padding:10px 14px;border-radius:8px;font:600 13px Inter,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.3)'; hostDoc.body.appendChild(toastEl); }\n toastEl.textContent = '🔔 ' + text; toastEl.style.opacity = '1';\n clearTimeout(toastEl._t); toastEl._t = setTimeout(() => { toastEl.style.opacity = '0'; }, 4000);\n };\n\n /* --------------------------- incoming messages --------------------------- */\n onMsg((m) => {\n if (m.type === 'presence:hello') { if (cfg.presence) touchPeer(m.user); }\n else if (m.type === 'presence:cursor') { if (cfg.presence) { touchPeer(m.user); showCursor(m.user, fromPageFrac(m.pos)); } }\n else if (m.type === 'presence:select') { if (cfg.presence) { touchPeer(m.user); showRemoteSelection(m.user, m.blockId); } }\n else if (m.type === 'presence:leave') { dropPeer(m.userId); }\n else if (m.type === 'comment:add' || m.type === 'comment:reply') {\n if (!comments.find((c) => c.id === m.comment.id)) { comments.push(m.comment); persistComments(); renderPins(); notifyMentions(m.comment); if (openThreadId && (m.comment.parentId === openThreadId)) openThread(openThreadId); }\n }\n else if (m.type === 'comment:resolve') { const c = comments.find((x) => x.id === m.id); if (c) { c.resolved = m.resolved; persistComments(); renderPins(); } }\n else if (m.type === 'comment:delete') { comments = comments.filter((c) => c.id !== m.id && c.parentId !== m.id); persistComments(); renderPins(); if (openThreadId === m.id) closePopover(); }\n });\n\n /* --------------------------------- toolbar ------------------------------- */\n let commentBtn = null;\n let bar = null;\n const setCommentMode = (on) => {\n commentMode = cfg.comments && !!on;\n if (commentBtn) commentBtn.classList.toggle('on', commentMode);\n document.body.classList.toggle('cs-comment-mode', commentMode); // iframe body → crosshair\n };\n\n const clearRemoteVisuals = () => {\n cursorEls.forEach((e) => e.remove()); cursorEls.clear();\n selEls.forEach((e) => e.remove()); selEls.clear();\n };\n\n // Apply host settings: show/hide comments + presence UI.\n const applyConfig = (c) => {\n cfg = Object.assign({}, cfg, c || {});\n if (commentBtn) commentBtn.style.display = cfg.comments ? '' : 'none';\n if (!cfg.comments) { setCommentMode(false); closePopover(); }\n if (avatarsEl) avatarsEl.style.display = cfg.presence ? '' : 'none';\n if (!cfg.presence) clearRemoteVisuals();\n if (bar) bar.style.display = (cfg.comments || cfg.presence) ? '' : 'none';\n renderPins();\n };\n\n // Exposed so the host topbar button + settings toggles can drive collab.\n window.Collab = window.Collab || {};\n window.Collab.toggleCommentMode = () => { if (cfg.comments) setCommentMode(!commentMode); };\n window.Collab.applyConfig = applyConfig;\n\n const buildBar = () => {\n // Remove any stale bars/toasts left in the HOST by a previous iframe load\n // (the iframe reloads, but host-appended elements persist → duplicates).\n hostDoc.querySelectorAll('.cs-collab-bar, .cs-collab-toast').forEach((e) => e.remove());\n bar = hostDoc.createElement('div'); bar.className = 'cs-collab-bar';\n bar.innerHTML = `\n <span class=\"cs-collab-avatars\"></span>\n <button data-c=\"comment\" title=\"Comment mode — click the canvas to drop a comment\">💬 Comment</button>\n <button data-c=\"me\" title=\"Rename yourself\">You: <b>${escapeHtml(me.name)}</b></button>`;\n hostDoc.body.appendChild(bar);\n avatarsEl = bar.querySelector('.cs-collab-avatars');\n const meBtn = bar.querySelector('[data-c=\"me\"]');\n commentBtn = bar.querySelector('[data-c=\"comment\"]');\n commentBtn.addEventListener('click', () => setCommentMode(!commentMode));\n meBtn.addEventListener('click', () => {\n const nm = prompt('Your display name', me.name);\n if (nm && nm.trim()) { me.name = nm.trim(); saveUser(); meBtn.innerHTML = `You: <b>${escapeHtml(me.name)}</b>`; renderAvatars(); hello(); }\n });\n renderAvatars();\n };\n\n // Click on the canvas in comment mode → start a new comment thread.\n const onCanvasClick = (e) => {\n if (!commentMode) return;\n if (e.target.closest('.cs-collab-pop, .cs-collab-pin, .cs-collab-bar')) return;\n const anchor = blockAnchor(e.clientX, e.clientY);\n if (!anchor) return;\n e.preventDefault(); e.stopPropagation();\n // Create an empty draft thread by opening a composer popover at the point.\n openDraft(anchor, e.clientX, e.clientY);\n setCommentMode(false);\n };\n const openDraft = (anchor, x, y) => {\n closePopover();\n popEl = document.createElement('div'); popEl.className = 'cs-collab-pop';\n popEl.style.left = Math.min(window.innerWidth - 312, x + 12) + 'px';\n popEl.style.top = Math.min(window.innerHeight - 200, y) + 'px';\n popEl.innerHTML = `\n <div class=\"cs-collab-pop__hd\"><span>New comment</span><button class=\"cs-collab-btn-ghost\" data-act=\"close\" style=\"border:none;border-radius:5px;cursor:pointer;padding:4px 8px\">✕</button></div>\n <div class=\"cs-collab-comp\">\n <textarea placeholder=\"Comment… use @ to mention\"></textarea>\n <div class=\"cs-collab-comp__row\">\n <button class=\"cs-collab-btn-ghost\" data-act=\"close\">Cancel</button>\n <button class=\"cs-collab-btn-primary\" data-act=\"add\">Comment</button>\n </div>\n </div>`;\n document.body.appendChild(popEl);\n const ta = popEl.querySelector('textarea'); ta.focus();\n wireMentions(ta, popEl.querySelector('.cs-collab-comp'));\n popEl.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act === 'close') return closePopover();\n if (act === 'add') { const body = ta.value.trim(); if (body) addComment(anchor, body, null); }\n });\n };\n\n // Reposition pins + remote selection outlines on scroll & resize (their\n // fixed/viewport coords otherwise drift as the canvas moves).\n const reposition = () => {\n renderPins();\n selEls.forEach((el, id) => { const p = peers.get(id); if (p && el._blockId) showRemoteSelection(p.user, el._blockId); });\n };\n\n /* --------------------------------- wiring -------------------------------- */\n const init = () => {\n injectStyle();\n loadComments();\n buildBar();\n initTransport();\n fetchServerComments();\n renderPins();\n\n document.addEventListener('pointermove', onPointerMove, true);\n document.addEventListener('click', onCanvasClick, true);\n document.addEventListener('scroll', () => requestAnimationFrame(reposition), true);\n window.addEventListener('resize', () => requestAnimationFrame(reposition));\n\n // Broadcast which block I have selected (presence).\n if (window.EditorManager) {\n let last = null;\n setInterval(() => {\n if (!cfg.presence) return;\n const b = window.EditorManager.getSelected?.();\n const id = b ? (b.id || (b.id = uid('block_'))) : null;\n if (id !== last) { last = id; send({ type: 'presence:select', user: me, blockId: id }); }\n }, 400);\n }\n hello();\n // When THIS iframe instance goes away, remove its host-appended UI so the\n // next load doesn't stack a duplicate bar/toast.\n window.addEventListener('pagehide', () => { try { bar && bar.remove(); toastEl && toastEl.remove(); } catch (e) { /* */ } });\n // Tell the host we're ready so it can push the current settings (collab:config).\n try { window.parent && window.parent.postMessage({ source: 'custom-form-twig', type: 'collab:ready' }, '*'); } catch (e) { /* */ }\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow-canvas.js\">\n/**\n * @fileoverview Flow Canvas — entry point.\n *\n * Wires together the feature modules under /flow/:\n * - block-factory.js — creates block elements\n * - row-col-builder.js — DOM scaffolding + placeBlock\n * - drop-zones.js — find target + visual indicator\n * - col-resize.js — draggable column divider\n * - section-canvas.js — in-section absolute placement\n * - cleanup-observer.js — auto-remove empty cols/rows\n *\n * This file:\n * 1. Bootstraps the .cs_margin root inside the canvas.\n * 2. Attaches drag/drop listeners that route through findDropTarget → placeBlock.\n * 3. Initializes column resize + cleanup observer.\n *\n * All shared helpers live on `window.FlowCanvas`.\n */\n(function () {\n // Feature flag: set to true to enable header/footer rendering & sync.\n // When false, pages render without header/footer regions.\n let ENABLE_HEADER_FOOTER = false;\n\n const CANVAS_SELECTOR = '.custom-form-design';\n\n const canvas = document.querySelector(CANVAS_SELECTOR);\n if (!canvas) {\n console.warn('flow-canvas: canvas not found');\n return;\n }\n\n // Guard against double-initialization (HMR / accidental double-load).\n if (canvas.dataset.flowCanvasInit === '1') {\n console.warn('flow-canvas: already initialized, skipping');\n return;\n }\n canvas.dataset.flowCanvasInit = '1';\n\n canvas.classList.add('cs-flow-canvas');\n\n const FC = window.FlowCanvas || {};\n\n // -------------------------------------------------------------------------\n // Document model:\n // canvas (.custom-form-design)\n // └─ .cs_paper — multi-page container\n // ├─ .cs_margin[data-page=\"1\"] — first page (always has header/footer)\n // ├─ .cs_margin[data-page=\"2\"] — additional pages (with or without)\n // └─ ...\n // -------------------------------------------------------------------------\n // The host page owns the outer .cs_paper wrapper (see custom-form.html);\n // we must NOT inject a second .cs_paper inside the canvas. Pages\n // (.cs_margin) attach directly under the canvas so the existing drag /\n // drop listeners (mounted on the canvas) keep working. From the rest\n // of the file's point of view, `paper` is \"the element pages live in\" —\n // here, that's the canvas itself.\n //\n // If a legacy DOM still has a nested .cs_paper inside the canvas, lift\n // its docs up to canvas level and drop the empty wrapper.\n const legacyPaper = canvas.querySelector(':scope > .cs_paper');\n if (legacyPaper) {\n legacyPaper.querySelectorAll(':scope > .cs_margin').forEach((d) => canvas.appendChild(d));\n legacyPaper.remove();\n }\n const paper = canvas.closest('.cs_paper') || canvas;\n\n const makeRegion = (region) => {\n const el = (FC.makeRow && FC.makeRow()) || document.createElement('div');\n el.className = `row-item cs-page-${region}`;\n FC.assignNodeId?.(el, 'row');\n el.setAttribute('data-cs-page-region', region);\n el.setAttribute('data-cs-region-label', region.toUpperCase());\n const placeholder = `Double-click to edit ${region}`;\n el.setAttribute('data-cs-placeholder', placeholder);\n\n if (region === 'header') {\n const col1 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col1.classList.contains('col-item')) col1.classList.add('col-item');\n col1.style.flex = '6';\n col1.style.maxWidth = '100%';\n const imgBlock = FC.createBlock && FC.createBlock('image');\n if (imgBlock) {\n imgBlock.style.position = '';\n imgBlock.style.left = '';\n imgBlock.style.top = '';\n imgBlock.style.maxWidth = '100%';\n const wrapper = imgBlock.querySelector('.image-container');\n if (wrapper) wrapper.style.setProperty('height', '60px', 'important');\n col1.appendChild(imgBlock);\n }\n el.appendChild(col1);\n\n const colGap = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!colGap.classList.contains('col-item')) colGap.classList.add('col-item');\n colGap.style.flex = '1';\n colGap.style.maxWidth = '100%';\n el.appendChild(colGap);\n\n const col2 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col2.classList.contains('col-item')) col2.classList.add('col-item');\n col2.style.flex = '3';\n col2.style.maxWidth = '100%';\n const textBlock = FC.createBlock && FC.createBlock('body-text');\n if (textBlock) {\n textBlock.style.position = '';\n textBlock.style.left = '';\n textBlock.style.top = '';\n textBlock.style.maxWidth = '100%';\n col2.appendChild(textBlock);\n }\n el.appendChild(col2);\n\n setTimeout(() => { if (FC.rebuildDividers) FC.rebuildDividers(el); }, 0);\n } else if (region === 'footer') {\n const col1 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col1.classList.contains('col-item')) col1.classList.add('col-item');\n col1.style.flex = '6';\n col1.style.maxWidth = '100%';\n const textBlock = FC.createBlock && FC.createBlock('body-text');\n if (textBlock) {\n textBlock.style.position = '';\n textBlock.style.left = '';\n textBlock.style.top = '';\n textBlock.style.maxWidth = '100%';\n col1.appendChild(textBlock);\n }\n el.appendChild(col1);\n\n const colGap = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!colGap.classList.contains('col-item')) colGap.classList.add('col-item');\n colGap.style.flex = '1';\n colGap.style.maxWidth = '100%';\n el.appendChild(colGap);\n\n const col2 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col2.classList.contains('col-item')) col2.classList.add('col-item');\n col2.style.flex = '3';\n col2.style.maxWidth = '100%';\n const imgBlock = FC.createBlock && FC.createBlock('image');\n if (imgBlock) {\n imgBlock.style.position = '';\n imgBlock.style.left = '';\n imgBlock.style.top = '';\n imgBlock.style.maxWidth = '100%';\n const wrapper = imgBlock.querySelector('.image-container');\n if (wrapper) wrapper.style.setProperty('height', '60px', 'important');\n col2.appendChild(imgBlock);\n }\n el.appendChild(col2);\n\n setTimeout(() => { if (FC.rebuildDividers) FC.rebuildDividers(el); }, 0);\n } else {\n const col = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col.classList.contains('col-item')) col.classList.add('col-item');\n col.setAttribute('data-cs-placeholder', placeholder);\n el.appendChild(col);\n }\n\n return el;\n };\n\n const ensurePageRegions = (docEl) => {\n if (docEl.dataset.csNoHeaderFooter === '1') return; // blank page\n let header = docEl.querySelector(':scope > .cs-page-header');\n let footer = docEl.querySelector(':scope > .cs-page-footer');\n let main = docEl.querySelector(':scope > .body-main-content');\n\n if (!main) {\n main = document.createElement('div');\n main.className = 'body-main-content';\n main.style.flex = '1';\n main.style.display = 'flex';\n main.style.flexDirection = 'column';\n }\n\n if (!header) {\n header = makeRegion('header');\n docEl.prepend(header);\n }\n\n if (!main.parentNode) {\n Array.from(docEl.querySelectorAll(':scope > .row-item:not(.cs-page-header):not(.cs-page-footer)')).forEach(r => main.appendChild(r));\n }\n\n if (!footer) {\n footer = makeRegion('footer');\n docEl.appendChild(footer);\n }\n\n if (header.nextElementSibling !== main) docEl.insertBefore(main, header.nextSibling);\n if (main.nextElementSibling !== footer) docEl.insertBefore(footer, main.nextSibling);\n\n return { header, footer, main };\n };\n\n const setRegionActive = (docEl, region) => {\n // Clear active state across ALL pages.\n paper.querySelectorAll('.cs_margin').forEach((d) => {\n d.classList.remove('editing-header', 'editing-footer');\n d.querySelectorAll('.cs-page-header, .cs-page-footer')\n .forEach((el) => el.classList.remove('is-active'));\n });\n if (!docEl || !region) return;\n const header = docEl.querySelector(':scope > .cs-page-header');\n const footer = docEl.querySelector(':scope > .cs-page-footer');\n if (region === 'header' && header) { header.classList.add('is-active'); docEl.classList.add('editing-header'); }\n else if (region === 'footer' && footer) { footer.classList.add('is-active'); docEl.classList.add('editing-footer'); }\n };\n\n const wireRegionEvents = (docEl) => {\n const header = docEl.querySelector(':scope > .cs-page-header');\n const footer = docEl.querySelector(':scope > .cs-page-footer');\n header?.addEventListener('dblclick', (e) => { e.stopPropagation(); setRegionActive(docEl, 'header'); });\n footer?.addEventListener('dblclick', (e) => { e.stopPropagation(); setRegionActive(docEl, 'footer'); });\n };\n\n const wireRegionOrderObserver = (docEl) => {\n let reordering = false;\n const obs = new MutationObserver(() => {\n if (reordering) return;\n const header = docEl.querySelector(':scope > .cs-page-header');\n const footer = docEl.querySelector(':scope > .cs-page-footer');\n if (!header && !footer) return;\n if (docEl.firstElementChild === header && docEl.lastElementChild === footer) return;\n reordering = true;\n if (header && docEl.firstElementChild !== header) docEl.prepend(header);\n if (footer && docEl.lastElementChild !== footer) docEl.appendChild(footer);\n requestAnimationFrame(() => { reordering = false; });\n });\n obs.observe(docEl, { childList: true });\n };\n\n // -------------------------------------------------------------------------\n // Header/footer sync across pages\n //\n // Any page's header/footer is editable. After the user finishes editing\n // (focus leaves the region, or typing stops for 400ms), the content is\n // copied to every other page's matching region.\n //\n // The non-destructive part: we don't overwrite innerHTML on every\n // keystroke. We only sync once the user pauses or moves focus away.\n // While the user is actively editing a region, mirror updates are\n // suspended for the page being edited so the cursor and selection\n // stay intact.\n // -------------------------------------------------------------------------\n let regionSyncing = false;\n const editingState = { region: null, docEl: null };\n\n // After cloning header/footer content to a mirror page we must rewrite\n // every `id` attribute so each page's blocks are still unique. The\n // editor and block IDs are used as keys by block-creator, inline-editor\n // and Froala — duplicate IDs break selection and editing.\n const rewriteIds = (root, suffix) => {\n root.querySelectorAll('[id]').forEach((el) => {\n const oldId = el.id;\n el.id = `${oldId}__p${suffix}`;\n });\n };\n\n const syncRegion = (region, sourceDocEl) => {\n if (regionSyncing) return;\n const source = sourceDocEl.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (!source) return;\n const html = source.innerHTML;\n regionSyncing = true;\n paper.querySelectorAll('.cs_margin').forEach((d) => {\n if (d === sourceDocEl) return;\n // Don't clobber the page the user is actively typing into.\n if (editingState.docEl === d && editingState.region === region) return;\n const target = d.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (target && target.innerHTML !== html) {\n target.innerHTML = html;\n rewriteIds(target, d.dataset.page || 'x');\n }\n });\n requestAnimationFrame(() => { regionSyncing = false; });\n };\n\n const wireRegionSync = (docEl) => {\n ['header', 'footer'].forEach((region) => {\n const regionEl = docEl.querySelector(`:scope > .cs-page-${region}`);\n const col = docEl.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (!regionEl || !col) return;\n\n // Track when this region is the one being actively edited so the\n // sync routine knows to leave it alone.\n regionEl.addEventListener('focusin', () => {\n editingState.region = region;\n editingState.docEl = docEl;\n });\n regionEl.addEventListener('focusout', (e) => {\n // If focus moved to another element in the SAME region, stay editing.\n if (regionEl.contains(e.relatedTarget)) return;\n if (editingState.docEl === docEl && editingState.region === region) {\n editingState.region = null;\n editingState.docEl = null;\n // On blur, push final content to all other pages.\n syncRegion(region, docEl);\n }\n });\n\n // Debounced sync while typing — runs 400ms after the last mutation\n // so we don't fight the user's cursor.\n let debounceTimer = null;\n const obs = new MutationObserver(() => {\n if (regionSyncing) return;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => syncRegion(region, docEl), 400);\n });\n obs.observe(col, { childList: true, subtree: true, characterData: true, attributes: true });\n });\n };\n\n // Pull current header/footer content from the canonical page into a\n // freshly-created doc. No-ops when there's no canonical page (it was deleted\n // and not yet re-established) or when seeding the canonical into itself.\n const seedRegionsFromCanonical = (docEl) => {\n if (!firstDoc || !document.contains(firstDoc) || docEl === firstDoc) return;\n ['header', 'footer'].forEach((region) => {\n const src = firstDoc.querySelector(`:scope > .cs-page-${region} > .col-item`);\n const dst = docEl.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (src && dst) {\n regionSyncing = true;\n dst.innerHTML = src.innerHTML;\n rewriteIds(dst, docEl.dataset.page || 'x');\n requestAnimationFrame(() => { regionSyncing = false; });\n }\n });\n };\n\n // Bootstrap page 1.\n let firstDoc = paper.querySelector('.cs_margin[data-page=\"1\"]') || paper.querySelector('.cs_margin');\n if (!firstDoc) {\n firstDoc = document.createElement('div');\n firstDoc.className = 'cs_margin';\n firstDoc.dataset.page = '1';\n const firstPageWrapper = paper.querySelector('.cs_page') || paper;\n firstPageWrapper.appendChild(firstDoc);\n } else if (!firstDoc.dataset.page) {\n firstDoc.dataset.page = '1';\n }\n if (ENABLE_HEADER_FOOTER) {\n ensurePageRegions(firstDoc);\n wireRegionEvents(firstDoc);\n wireRegionOrderObserver(firstDoc);\n wireRegionSync(firstDoc);\n } else {\n firstDoc.dataset.csNoHeaderFooter = '1';\n }\n\n // Backwards-compat alias for the rest of the file (drag handlers\n // expect a single `doc`). It now always refers to page 1.\n const doc = firstDoc;\n\n // -------------------------------------------------------------------------\n // Add Page API — callable from the host shell or a future \"+\" button.\n // window.FlowCanvas.addPage({ headerFooter: true | false }) → docEl\n // -------------------------------------------------------------------------\n const renumberPages = () => {\n paper.querySelectorAll('.cs_margin').forEach((d, i) => { d.dataset.page = String(i + 1); });\n };\n\n // Total pages = every `.cs_page` directly under the paper (content wrappers +\n // cover pages). Reported to the host shell so the footer / Delete-page button\n // stay in sync with add/remove.\n const countPages = () => paper.querySelectorAll(':scope > .cs_page').length;\n const postPageCount = () => {\n try {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:count', count: countPages() }, '*');\n } catch (e) { /* parent on different origin — ignore */ }\n };\n const postRemoveResult = (ok, reason) => {\n try {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:removed', ok: !!ok, reason: reason || null, count: countPages() }, '*');\n } catch (e) { /* ignore */ }\n return !!ok;\n };\n\n FC.addPage = function (opts) {\n const withHF = ENABLE_HEADER_FOOTER && (!opts || opts.headerFooter !== false);\n const newDoc = document.createElement('div');\n newDoc.className = 'cs_margin';\n if (!withHF) newDoc.dataset.csNoHeaderFooter = '1';\n\n if (paper.classList.contains('cs_paper')) {\n const pageWrapper = document.createElement('div');\n pageWrapper.className = 'cs_page custom-form-design centercontent cs-flow-canvas';\n pageWrapper.style.visibility = 'visible';\n pageWrapper.appendChild(newDoc);\n paper.appendChild(pageWrapper);\n } else {\n paper.appendChild(newDoc);\n }\n if (withHF) {\n ensurePageRegions(newDoc);\n // If the canonical page was deleted (no content page left to copy from),\n // this new page becomes the canonical source. Otherwise seed its\n // header/footer from the canonical, then start two-way sync.\n if (!firstDoc || !document.contains(firstDoc)) firstDoc = newDoc;\n seedRegionsFromCanonical(newDoc); // no-ops when newDoc IS the canonical\n wireRegionSync(newDoc);\n wireRegionEvents(newDoc);\n wireRegionOrderObserver(newDoc);\n }\n renumberPages();\n postPageCount();\n // Scroll to the new page and mark it active. focusPage handles the host\n // scroll container + waits for the iframe to resize; plain scrollIntoView\n // can't cross the iframe→host boundary reliably.\n if (FC.focusPage) FC.focusPage(newDoc);\n else newDoc.scrollIntoView({ behavior: 'smooth', block: 'start' });\n return newDoc;\n };\n\n FC.removePage = function (docEl) {\n if (!docEl || docEl === firstDoc) return false; // can't remove page 1\n docEl.remove();\n renumberPages();\n postPageCount();\n return true;\n };\n\n // Remove the page the user is currently viewing (scroll-tracked active page),\n // covering both content pages (`.cs_page` wrapping a `.cs_margin`) and cover\n // pages (`.cs_page[data-cs-cover]`). Falls back to the last page when nothing\n // is selected. Page 1 (the header/footer canonical source) and the very last\n // remaining page are protected. Posts a `page:removed` result so the host can\n // surface why a delete was refused.\n FC.removeActivePage = function () {\n const pages = Array.from(paper.querySelectorAll(':scope > .cs_page'));\n if (pages.length <= 1) return postRemoveResult(false, 'last');\n\n const page = (FC.getSelectedPage && FC.getSelectedPage()) || pages[pages.length - 1];\n if (!page || !pages.includes(page)) return postRemoveResult(false, 'none');\n\n const isCover = page.matches('[data-cs-cover=\"1\"]');\n\n // Any page can be deleted as long as one page (of any type) remains. If this\n // page held the header/footer canonical source (firstDoc), hand that role to\n // another content page when one exists; otherwise leave it empty — the next\n // content page added re-establishes it (see addPage). Every content page\n // already carries identical, synced header/footer content, so any other\n // `.cs_margin` is a valid replacement.\n if (!isCover && firstDoc && page.contains(firstDoc)) {\n firstDoc = Array.from(paper.querySelectorAll('.cs_margin')).find((m) => m !== firstDoc) || null;\n }\n\n // Pick a neighbour to scroll to once this page is gone.\n const i = pages.indexOf(page);\n const neighbor = pages[i - 1] || pages[i + 1] || null;\n\n page.remove();\n renumberPages();\n if (neighbor && FC.focusPage) FC.focusPage(neighbor);\n postPageCount();\n return postRemoveResult(true);\n };\n\n // Clear the content of the page the user is viewing WITHOUT deleting the page:\n // every dropped block / row is removed, but the header & footer regions, the\n // page-number / overflow chrome and any designed background-shape layer are\n // kept. Works on content pages (clears the `.body-main-content`) and cover\n // pages (clears the absolutely-positioned blocks). Falls back to the last\n // page when nothing is scrolled into view.\n FC.clearActivePage = function () {\n const pages = Array.from(paper.querySelectorAll(':scope > .cs_page'));\n const page = (FC.getSelectedPage && FC.getSelectedPage()) || pages[pages.length - 1];\n if (!page) return false;\n const doc = page.matches('[data-cs-cover=\"1\"]')\n ? page\n : (page.querySelector(':scope > .cs_margin') || page);\n\n // Drop any selection / inline-edit chrome first so no overlay is orphaned\n // when its block is detached.\n try { window.EditorManager?.clearAll?.(); } catch (e) { /* */ }\n\n // Content page: empty the main body region (keeps header/footer in place).\n const main = doc.querySelector(':scope > .body-main-content');\n if (main) main.innerHTML = '';\n\n // Sweep any remaining top-level user content — covers / blank pages keep\n // their blocks directly on the doc, and legacy pages may have stray rows.\n Array.from(doc.children).forEach((c) => {\n if (c === main) return;\n if (c.matches('.cs-page-header, .cs-page-footer')) return; // shared regions\n if (c.matches('[data-cs-chrome]')) return; // page number / marks\n if (c.classList && c.classList.contains('cs-page-shape-bg')) return; // designed bg\n c.remove();\n });\n\n try {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:cleared', ok: true }, '*');\n } catch (e) { /* ignore */ }\n return true;\n };\n\n // -------------------------------------------------------------------------\n // Cover page — a free-move canvas.\n //\n // Unlike a normal page (rigid row/col flow), a cover page's body is one\n // full-page `.cs-flexible-content`. Every block dropped onto it is placed\n // with `position:absolute` (free move + resize), reusing the existing\n // flexible-container machinery in row-col-builder.js / inline-editor.js /\n // drop-zones.js. The `data-cs-cover=\"1\"` flag lets placeBlock relax the\n // `restrictInFlexible` rule so ALL block types are allowed here. No\n // header/footer regions — it always renders blank like an added page.\n //\n // window.FlowCanvas.addCoverPage() → docEl\n // -------------------------------------------------------------------------\n FC.addCoverPage = function () {\n // A cover page is a free-move canvas: the `.cs_page` IS the page and the\n // positioning context — dropped blocks become absolutely-positioned DIRECT\n // children of it. No `.cs_margin` and no inner `.cs-flexible-content`\n // wrapper (that's the structure the export/template layer expects).\n //\n // It carries `.custom-form-design` so the twig generator (which iterates\n // `.custom-form-design`) serialises it as its own sheet, and so the editor\n // surface (now `.cs_paper`-wide) covers it for selection/move/resize.\n // `data-cs-cover=\"1\"` is the single flag drop-zones / placeBlock key off to\n // treat it as a free canvas instead of a row/col flow root.\n const newDoc = document.createElement('div');\n newDoc.className = 'cs_page custom-form-design centercontent cs-flow-canvas cs-cover-canvas';\n newDoc.id = `cover_${FC.generateHash ? FC.generateHash() : Math.random().toString(16).slice(2)}`;\n newDoc.dataset.csCover = '1';\n newDoc.dataset.csNoHeaderFooter = '1';\n newDoc.style.visibility = 'visible';\n\n paper.appendChild(newDoc);\n renumberPages();\n postPageCount();\n if (FC.focusPage) FC.focusPage(newDoc);\n else newDoc.scrollIntoView({ behavior: 'smooth', block: 'start' });\n return newDoc;\n };\n\n // -------------------------------------------------------------------------\n // Page break splitter\n //\n // When the user drops a Page Break block, we split the source doc at\n // that location. Everything AFTER the break (including the block that\n // contains the break itself) is moved onto a freshly-created page so\n // the user's content naturally flows onto two pages. The break block\n // itself is discarded — its presence in the DOM was just a marker for\n // where to cut. Header/footer regions on the source page are\n // preserved on the source; the destination page is created without\n // them (matches the manual \"Add Page\" default).\n // -------------------------------------------------------------------------\n FC.splitPageAt = function (docEl, breakBlock) {\n if (!docEl || !breakBlock) return null;\n const breakRow = breakBlock.closest('.row-item');\n if (!breakRow || breakRow.parentElement !== docEl) return null;\n\n // Collect every row that comes AFTER the break row at the doc root,\n // skipping the footer (which always stays at the bottom of the\n // source page). Order is preserved because we walk forward.\n const rowsToMove = [];\n let cursor = breakRow.nextElementSibling;\n while (cursor) {\n const next = cursor.nextElementSibling;\n if (cursor.classList && cursor.classList.contains('cs-page-footer')) {\n cursor = next;\n continue;\n }\n rowsToMove.push(cursor);\n cursor = next;\n }\n\n // Remove the break marker row itself — its job is done. If the row\n // contained other siblings inside the same column, keep those by\n // detaching just the break block.\n const breakCol = breakBlock.closest('.col-item');\n if (breakCol && breakCol.children.length > 1) {\n breakBlock.remove();\n } else {\n breakRow.remove();\n }\n\n // Create the destination page and move rows over (in original\n // order, before the destination footer if it has one).\n const newDoc = FC.addPage({ headerFooter: false });\n if (!newDoc) return null;\n const newFooter = newDoc.querySelector(':scope > .cs-page-footer');\n rowsToMove.forEach((row) => {\n if (newFooter) newDoc.insertBefore(row, newFooter);\n else newDoc.appendChild(row);\n });\n return newDoc;\n };\n\n // -------------------------------------------------------------------------\n // A4 overflow indicator\n //\n // Adds .cs-overflowing to any .cs_margin whose content exceeds the\n // configured A4 height. CSS renders a dashed boundary at the A4 mark\n // with a hint suggesting the user drop a Page Break. We only TOGGLE\n // the class — the visible split is the user's call.\n // -------------------------------------------------------------------------\n const PAGE_TARGET_HEIGHT = (window.CanvasConfig?.page?.minHeight) ?? 1123;\n // Measure how tall the doc's actual children stretch. We can't rely on\n // d.scrollHeight because the `.cs-overflowing::after` pseudo-element is\n // positioned at top: 1123px and contributes to scrollHeight — meaning\n // once we add the class, scrollHeight is locked at >=1123 forever and\n // the class can never come back off when content shrinks.\n const measureContentBottom = (docEl) => {\n let bottom = 0;\n docEl.querySelectorAll(':scope > .row-item, :scope > .body-main-content').forEach((row) => {\n const rect = row.getBoundingClientRect();\n const docRect = docEl.getBoundingClientRect();\n const offset = (rect.bottom - docRect.top);\n if (offset > bottom) bottom = offset;\n });\n return bottom;\n };\n const ensureOverflowMark = (docEl) => {\n let mark = docEl.querySelector(':scope > .cs-overflow-mark');\n if (!mark) {\n mark = document.createElement('div');\n mark.className = 'cs-overflow-mark';\n mark.setAttribute('data-cs-chrome', '1');\n const label = document.createElement('span');\n label.className = 'cs-overflow-mark__label';\n label.textContent = 'Suggested page break — drag a Page Break here';\n mark.appendChild(label);\n docEl.appendChild(mark);\n }\n };\n const removeOverflowMark = (docEl) => {\n docEl.querySelectorAll(':scope > .cs-overflow-mark').forEach((m) => m.remove());\n };\n const updatePageNumbers = () => {\n const docs = Array.from(paper.querySelectorAll('.cs_margin'));\n const total = docs.length;\n docs.forEach((d, index) => {\n let pageNumEl = d.querySelector(':scope > .cs-page-number');\n if (!pageNumEl) {\n pageNumEl = document.createElement('div');\n pageNumEl.className = 'cs-page-number';\n pageNumEl.setAttribute('data-cs-chrome', '1');\n pageNumEl.style.fontSize = '12px';\n pageNumEl.style.color = '#505b65';\n pageNumEl.style.paddingLeft = '4px';\n d.appendChild(pageNumEl);\n }\n pageNumEl.textContent = `Page ${index + 1} of ${total}`;\n\n if (d.lastElementChild !== pageNumEl) {\n d.appendChild(pageNumEl);\n }\n });\n };\n\n const updateOverflowMarks = () => {\n paper.querySelectorAll('.cs_margin').forEach((d) => {\n const contentBottom = measureContentBottom(d);\n const overflowing = contentBottom > PAGE_TARGET_HEIGHT + 1;\n if (overflowing) ensureOverflowMark(d);\n else removeOverflowMark(d);\n });\n // updatePageNumbers();\n };\n // MutationObserver catches additions/removals (childList) and inline\n // style edits (attributes). We exclude our own .cs-overflowing class\n // flips from triggering re-runs by listing the attribute filter\n // explicitly — `class` is included so legitimate class changes still\n // re-check, but the guard above prevents loops.\n const overflowObs = new MutationObserver(() => requestAnimationFrame(updateOverflowMarks));\n overflowObs.observe(paper, {\n childList: true,\n subtree: true,\n characterData: true,\n attributes: true,\n attributeFilter: ['style', 'class'],\n });\n // ResizeObserver covers the case where content stays the same DOM but\n // its rendered height changes (image loads, text re-flows, etc.) so the\n // indicator hides as soon as the doc shrinks back under A4.\n if (typeof ResizeObserver !== 'undefined') {\n const ro = new ResizeObserver(() => requestAnimationFrame(updateOverflowMarks));\n const observeDocs = () => {\n paper.querySelectorAll('.cs_margin').forEach((d) => ro.observe(d));\n };\n observeDocs();\n // Re-observe whenever a new doc is added (FC.addPage).\n new MutationObserver(observeDocs).observe(paper, { childList: true });\n }\n requestAnimationFrame(updateOverflowMarks);\n\n // -------------------------------------------------------------------------\n // Auto-resize: tell the parent shell how tall our content is so the\n // iframe can grow to fit all stacked pages.\n // -------------------------------------------------------------------------\n const reportHeight = () => {\n // Measure the ACTUAL content (the paper), never document.body /\n // documentElement scrollHeight: those are pinned to the iframe's\n // host-forced height, so once the host grows the iframe they floor at\n // that value and never let it shrink again (e.g. after a page is\n // deleted the empty space stays). Walking the offset chain gives the\n // paper's absolute bottom independent of the iframe's current height.\n let top = 0;\n for (let el = paper; el; el = el.offsetParent) top += el.offsetTop;\n const contentH = Math.max(paper.offsetHeight, paper.scrollHeight);\n const h = Math.ceil(top + contentH + 64);\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'iframe:height',\n height: h,\n }, '*');\n } catch (e) { /* parent on different origin — ignore */ }\n };\n const heightObs = new MutationObserver(() => requestAnimationFrame(reportHeight));\n heightObs.observe(paper, { childList: true, subtree: true, attributes: true });\n window.addEventListener('load', reportHeight);\n requestAnimationFrame(reportHeight);\n // Tell the host how many pages exist on boot (e.g. a multi-page template\n // loaded) so the footer / Delete-page button start in sync.\n window.addEventListener('load', postPageCount);\n requestAnimationFrame(postPageCount);\n\n // -------------------------------------------------------------------------\n // postMessage listener — host shell can ask us to add/remove pages.\n // -------------------------------------------------------------------------\n window.addEventListener('message', (e) => {\n const msg = e.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n if (msg.type === 'page:add') {\n FC.addPage({ headerFooter: msg.headerFooter !== false });\n }\n if (msg.type === 'page:add-cover') {\n FC.addCoverPage();\n }\n if (msg.type === 'page:remove' && msg.pageNumber > 1) {\n const docEl = paper.querySelector(`.cs_margin[data-page=\"${msg.pageNumber}\"]`);\n FC.removePage(docEl);\n }\n if (msg.type === 'page:remove-active') {\n FC.removeActivePage();\n }\n if (msg.type === 'page:clear-active') {\n FC.clearActivePage();\n }\n if (msg.type === 'page-size:change' && msg.sizeKey) {\n if (typeof window.setCanvasPageSize === 'function') {\n window.setCanvasPageSize(msg.sizeKey);\n }\n }\n if (msg.type === 'page-bg:change') {\n if (typeof window.setCanvasPageBackground === 'function') {\n window.setCanvasPageBackground(msg.imageUrl || '');\n }\n }\n // Per-page background image: apply to the page the user is currently viewing\n // (content `.cs_margin` OR cover `.cs_page[data-cs-cover]`). Inline style wins\n // over the global `--cs-page-bg-image` var; `data-cs-bg-image` lets us read it\n // back (panel preview on page switch) and survives template save/export.\n if (msg.type === 'page-bg:set-active') {\n const page = FC.getSelectedDrawablePage ? FC.getSelectedDrawablePage() : null;\n if (page) {\n const url = msg.imageUrl || '';\n if (url) {\n page.style.backgroundImage = `url(\"${url}\")`;\n page.style.backgroundSize = 'cover';\n page.style.backgroundPosition = 'center';\n page.style.backgroundRepeat = 'no-repeat';\n page.dataset.csBgImage = url;\n } else {\n page.style.backgroundImage = '';\n delete page.dataset.csBgImage;\n }\n // Echo back so the panel preview matches this page immediately.\n try { window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:active', bgImage: url }, '*'); } catch (e) { /* */ }\n }\n }\n if (msg.type === 'component:capture') {\n const data = window.FlowCanvas?.captureComponent?.() || null;\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'component:captured', data }, '*');\n }\n if (msg.type === 'component:insert') {\n window.FlowCanvas?.insertComponentHtml?.(msg.html);\n }\n if (msg.type === 'block:select' && msg.blockId) {\n // Panel asked us to select an ancestor block (the \"Choose parent\" button).\n const el = document.getElementById(msg.blockId);\n if (el) {\n if (window.EditorManager?.select) window.EditorManager.select(el);\n else el.click();\n }\n }\n if (msg.type === 'comment:toggle') {\n window.Collab?.toggleCommentMode?.();\n }\n if (msg.type === 'collab:config') {\n window.Collab?.applyConfig?.(msg.config);\n }\n if (msg.type === 'page-shape:open') {\n window.PageShapeDesigner?.open();\n }\n if (msg.type === 'page-shape:clear') {\n // Per-page: remove the shape only from the page the user is working on.\n window.PageShapeDesigner?.removeFromActive();\n }\n if (msg.type === 'page-margins:change') {\n const margins = msg.margins || {};\n const { top, right, bottom, left } = margins;\n paper.querySelectorAll('.cs_margin').forEach(docEl => {\n docEl.style.padding = `${top || 0}mm ${right || 0}mm ${bottom || 0}mm ${left || 0}mm`;\n });\n setTimeout(updateOverflowMarks, 50);\n }\n if (msg.type === 'header-footer:toggle') {\n ENABLE_HEADER_FOOTER = msg.enabled;\n paper.querySelectorAll('.cs_margin').forEach(p => {\n // Cover pages are always blank free-move canvases — never give them\n // header/footer regions, regardless of the global toggle.\n if (p.dataset.csCover === '1') return;\n if (msg.enabled) {\n delete p.dataset.csNoHeaderFooter;\n ensurePageRegions(p);\n seedRegionsFromCanonical(p);\n wireRegionSync(p);\n wireRegionEvents(p);\n wireRegionOrderObserver(p);\n } else {\n p.dataset.csNoHeaderFooter = '1';\n const h = p.querySelector(':scope > .cs-page-header');\n const f = p.querySelector(':scope > .cs-page-footer');\n if (h) h.remove();\n if (f) f.remove();\n }\n });\n // Optionally re-measure after structural changes\n setTimeout(updateOverflowMarks, 50);\n }\n if (msg.type === 'inline-insert:toggle') {\n const enabled = window.FlowCanvas.setInlineInsertEnabled?.(msg.enabled) !== false;\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'inline-insert:state',\n enabled\n }, '*');\n }\n if (msg.type === 'rich-toolbar:dock') {\n // Place the CustomRichEditor toolbar: docked (top sticky) vs inline float.\n if (typeof window.setRichToolbarDocked === 'function') {\n window.setRichToolbarDocked(!!msg.docked);\n } else if (window.CanvasConfig && window.CanvasConfig.editor) {\n window.CanvasConfig.editor.dockRichToolbar = !!msg.docked;\n }\n }\n if (msg.type === 'set-block-style') {\n const block = document.getElementById(msg.blockId);\n if (!block) return;\n\n // ===== HANDLE LAYOUT PROPERTIES (layoutStyle, layoutColumns, sectionColor) =====\n if (msg.prop === 'layoutColumns') {\n const contentArea = block.querySelector('.cs-flexible-content');\n if (contentArea) {\n contentArea.dataset.layoutColumns = msg.value;\n // Remove all layout classes\n contentArea.classList.remove('cs-layout--one-col', 'cs-layout--two-col-wave', 'cs-layout--two-col-diagonal', 'cs-layout--two-col-organic', 'cs-layout--three-col');\n // Add appropriate class\n const layoutStyle = contentArea.dataset.layoutStyle || 'wave';\n if (msg.value === '1') {\n contentArea.classList.add('cs-layout--one-col');\n } else if (msg.value === '2') {\n contentArea.classList.add(`cs-layout--two-col-${layoutStyle}`);\n } else if (msg.value === '3') {\n contentArea.classList.add('cs-layout--three-col');\n }\n }\n return;\n }\n\n if (msg.prop === 'sectionColor') {\n const contentArea = block.querySelector('.cs-flexible-content, .section-container-content');\n if (contentArea) {\n contentArea.style.backgroundColor = msg.value;\n }\n return;\n }\n\n // ===== HANDLE REGULAR STYLE PROPERTIES =====\n // Check if this block is currently in editing mode with Froala active\n const isEditing = block.classList.contains('cs-editing');\n const hasFroala = window.FroalaStyleHandler && window.FroalaStyleHandler.hasActiveEditor();\n\n // If block is editing and Froala is active, use Froala commands for typography\n if (isEditing && hasFroala) {\n const typographyCommands = {\n 'color': () => window.FroalaStyleHandler.applyColor(msg.value),\n 'fontSize': () => window.FroalaStyleHandler.applyFontSize(msg.value),\n 'fontWeight': () => window.FroalaStyleHandler.applyFontWeight(msg.value)\n };\n\n if (msg.prop in typographyCommands) {\n typographyCommands[msg.prop]();\n // Also set inline style as fallback\n const inner = block.querySelector('.edit_me, .canvas-block__content');\n if (inner) inner.style[msg.prop] = msg.value;\n return;\n }\n }\n\n // Fallback: Apply as inline styles (for non-editing blocks or non-Froala props)\n const typographyProps = ['color', 'fontSize', 'fontWeight'];\n const containerProps = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius'];\n\n // For typography (color, fontSize, fontWeight), apply to inner editable element\n const typographyTarget = block.querySelector('.edit_me, .canvas-block__content');\n if (typographyProps.includes(msg.prop) && typographyTarget) {\n typographyTarget.style[msg.prop] = msg.value;\n return;\n }\n\n // For background/border on flexible/section containers, apply to the content area\n const isFlexible = block.classList.contains('cs-flexible-block');\n const isSection = block.dataset.blockType === 'section-container' || block.getAttribute('data') === 'Section Container';\n\n if ((isFlexible || isSection) && containerProps.includes(msg.prop)) {\n const contentArea = block.querySelector('.cs-flexible-content, .section-container-content');\n if (contentArea) {\n contentArea.style[msg.prop] = msg.value;\n return;\n }\n }\n\n // Default: Apply to outer block\n block.style[msg.prop] = msg.value;\n }\n });\n\n // -------------------------------------------------------------------------\n // Global click / keyboard: deactivate header/footer focus\n // -------------------------------------------------------------------------\n if (ENABLE_HEADER_FOOTER) {\n canvas.addEventListener('click', (e) => {\n if (!e.target.closest('.cs-page-header') && !e.target.closest('.cs-page-footer')) {\n setRegionActive(null);\n }\n });\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') setRegionActive(null);\n });\n }\n\n // -------------------------------------------------------------------------\n // Drag payload helpers\n // -------------------------------------------------------------------------\n const DRAG_STORE_KEY = '__BROCHURE_FLOW_DRAG__';\n\n const parsePayload = (value) => {\n if (!value) return null;\n try { return JSON.parse(value); } catch (e) { return null; }\n };\n\n const getDragPayload = (event) => {\n const direct =\n parsePayload(event.dataTransfer?.getData('application/x-brochure-block')) ||\n parsePayload(event.dataTransfer?.getData('text/plain'));\n if (direct?.blockType) {\n console.log('getDragPayload: got direct payload from dataTransfer');\n return direct;\n }\n try {\n const fallback = window.parent?.[DRAG_STORE_KEY] ?? null;\n if (fallback?.blockType) {\n console.log('getDragPayload: got fallback payload from parent window:', fallback);\n return fallback;\n }\n console.log('getDragPayload: no payload found');\n return null;\n } catch (e) {\n console.warn('getDragPayload: error accessing parent window:', e);\n return null;\n }\n };\n\n const createBlockFromPayload = (payload) => {\n // Reusable component: build from stored HTML instead of the block factory.\n if (payload?.blockType === 'component' && payload.componentHtml) {\n return FC.buildComponentBlock?.(payload.componentHtml) || null;\n }\n if (!payload?.blockType) return null;\n\n const block = FC.createBlock?.(payload.blockType);\n if (!block) {\n console.warn('BLOCK CREATE: failed for blockType:', payload.blockType);\n return null;\n }\n\n if (payload.blockType === 'fa-icon' && payload.class) {\n const iconEl = block.querySelector('i');\n if (iconEl) {\n iconEl.className = payload.class;\n block.dataset.iconName = payload.icon || 'star';\n block.dataset.iconClass = payload.class;\n }\n }\n\n return block;\n };\n\n const maybeOpenBindingModal = (payload, block) => {\n if (!payload?.blockType || !block) return;\n const REPEATER_TYPES = window.FormBlockRegistry?.repeaterTypes() ||\n ['section-container', 'table-repeater', 'list-repeater'];\n if (REPEATER_TYPES.includes(payload.blockType) &&\n typeof window.showSectionBindingModal === 'function') {\n window.showSectionBindingModal(block);\n }\n };\n\n const insertPayloadAtTarget = ({ payload, activeDoc, target, clientX, clientY }) => {\n if (!payload?.blockType || !activeDoc || !target) return null;\n\n const block = createBlockFromPayload(payload);\n if (!block) return null;\n\n // A reusable component carries blockType 'component', but for placement\n // rules (row/col wrap + the flexible-container restriction in placeBlock) it\n // must behave like its underlying block — e.g. a saved Section must bounce\n // out of a flexible container just like a real section-container would,\n // instead of being placed as a stray absolute child. Derive the real type\n // from the built block.\n const effectiveType = payload.blockType === 'component'\n ? (block.dataset.blockType || 'component')\n : payload.blockType;\n\n if (payload.blockType === 'page-break') {\n let beforeRow = null;\n if (target.kind === 'between-rows') {\n beforeRow = target.beforeRow || null;\n } else if (target.kind === 'col-edge' || target.kind === 'in-col') {\n const refRow = (target.row || target.col || target.beforeBlock)?.closest?.('.row-item');\n beforeRow = refRow?.nextElementSibling || null;\n }\n FC.placeBlock?.(activeDoc, block, { kind: 'between-rows', beforeRow }, clientX, clientY, payload.blockType);\n if (typeof FC.splitPageAt === 'function') FC.splitPageAt(activeDoc, block);\n return block;\n }\n\n FC.placeBlock?.(activeDoc, block, target, clientX, clientY, effectiveType);\n maybeOpenBindingModal(payload, block);\n return block;\n };\n\n // -------------------------------------------------------------------------\n // Drag-and-drop event wiring\n // -------------------------------------------------------------------------\n console.log('FLOW-CANVAS: drop listeners attached to element:', paper?.className || paper?.id || 'unknown');\n\n paper.addEventListener('dragenter', (event) => {\n console.log('FLOW-CANVAS: dragenter fired');\n if (getDragPayload(event)) {\n console.log('FLOW-CANVAS: dragenter has valid payload');\n event.preventDefault();\n const activeDoc = findActiveDoc(event.clientX, event.clientY);\n const page = activeDoc?.closest('.custom-form-design');\n if (page) page.classList.add('drop-surface--active');\n }\n });\n\n paper.addEventListener('dragover', (event) => {\n console.log('FLOW-CANVAS: dragover fired');\n if (getDragPayload(event)) {\n console.log('FLOW-CANVAS: dragover has valid payload, calling preventDefault');\n event.preventDefault();\n event.dataTransfer.dropEffect = 'copy';\n }\n });\n\n // Pick the page that's currently under the pointer (multi-page aware).\n // Includes cover pages, which are free-canvas `.cs_page[data-cs-cover]`\n // elements rather than `.cs_margin` flow pages.\n const findActiveDoc = (clientX, clientY) => {\n const docs = Array.from(paper.querySelectorAll('.cs_margin, .cs_page[data-cs-cover=\"1\"]'));\n for (const d of docs) {\n const r = d.getBoundingClientRect();\n if (clientY >= r.top && clientY <= r.bottom) return d;\n }\n return docs[0] || firstDoc;\n };\n\n paper.addEventListener('dragover', (event) => {\n const payload = getDragPayload(event);\n if (!payload) return;\n event.preventDefault();\n event.dataTransfer.dropEffect = 'copy';\n const activeDoc = findActiveDoc(event.clientX, event.clientY);\n\n paper.querySelectorAll('.drop-surface--active').forEach(el => el.classList.remove('drop-surface--active'));\n const page = activeDoc?.closest('.custom-form-design');\n if (page) page.classList.add('drop-surface--active');\n\n const result = FC.findDropTarget?.(activeDoc, paper, event.clientX, event.clientY, payload.blockType);\n if (result) {\n FC.showIndicator?.(result.indicator);\n paper._pendingDropTarget = result.target;\n paper._pendingDropDoc = activeDoc;\n }\n });\n\n paper.addEventListener('dragleave', (event) => {\n if (!paper.contains(event.relatedTarget)) {\n paper.querySelectorAll('.drop-surface--active').forEach(el => el.classList.remove('drop-surface--active'));\n FC.hideIndicator?.();\n paper._pendingDropTarget = null;\n }\n });\n\n let lastDropAt = 0;\n paper.addEventListener('drop', (event) => {\n console.log('DROP EVENT FIRED');\n const payload = getDragPayload(event);\n console.log('DROP: getDragPayload result:', payload);\n if (!payload) {\n console.warn('DROP: no payload found');\n return;\n }\n event.preventDefault();\n event.stopPropagation();\n paper.querySelectorAll('.drop-surface--active').forEach(el => el.classList.remove('drop-surface--active'));\n FC.hideIndicator?.();\n\n // De-dupe: some browsers / wrapper frames emit duplicate drop events.\n const now = performance.now();\n if (now - lastDropAt < 200) {\n paper._pendingDropTarget = null;\n return;\n }\n lastDropAt = now;\n\n const activeDoc = paper._pendingDropDoc || findActiveDoc(event.clientX, event.clientY);\n const result = paper._pendingDropTarget ||\n FC.findDropTarget?.(activeDoc, paper, event.clientX, event.clientY, payload.blockType)?.target;\n paper._pendingDropTarget = null;\n paper._pendingDropDoc = null;\n\n // Sidebar drop: build a fresh block.\n console.log('DROP: payload =', payload);\n const block = insertPayloadAtTarget({\n payload,\n activeDoc,\n target: result,\n clientX: event.clientX,\n clientY: event.clientY,\n });\n console.log('DROP: block inserted?', !!block);\n });\n\n // -------------------------------------------------------------------------\n // Feature modules\n // -------------------------------------------------------------------------\n FC.initColResize?.(canvas);\n FC.initFieldPanel?.(canvas);\n FC.initHistory?.(canvas);\n FC.initDimensionIndicator?.(canvas);\n FC.initInlineInsert?.(canvas);\n FC.initCopyPaste?.(canvas);\n FC.initImageZoom?.(canvas);\n // Per-doc feature wiring (cleanup observer, block reorder) — also run\n // these for any future docs added via FC.addPage().\n const wireDocFeatures = (docEl) => {\n FC.initCleanupObserver?.(docEl);\n FC.initBlockReorder?.(canvas, docEl);\n };\n wireDocFeatures(firstDoc);\n const _origAddPage = FC.addPage;\n FC.addPage = function (opts) {\n const newDoc = _origAddPage.call(FC, opts);\n if (newDoc) wireDocFeatures(newDoc);\n return newDoc;\n };\n const _origAddCoverPage = FC.addCoverPage;\n FC.addCoverPage = function () {\n const newDoc = _origAddCoverPage.call(FC);\n if (newDoc) wireDocFeatures(newDoc);\n return newDoc;\n };\n\n // -------------------------------------------------------------------------\n // Image upload handler\n // -------------------------------------------------------------------------\n const initImageUpload = () => {\n // Attach to the whole board (.cs_paper) — not just page 1's canvas — so the\n // image upload also fires for image blocks on added pages and cover pages,\n // which live in their own sibling `.custom-form-design` wrappers.\n paper.addEventListener('click', (e) => {\n const imgBtn = e.target.closest('.img-btn');\n if (!imgBtn) return;\n\n const imageBlock = imgBtn.closest('.cs_block_s');\n const isImageBlockArmed = !!imageBlock &&\n (imageBlock.classList.contains('cs-selected') || imageBlock.classList.contains('cs-editing'));\n\n // First click should only select the image block. We let the normal\n // inline-editor click state machine handle that. Only when the block\n // is already selected/editing do we intercept and open the upload modal.\n if (!isImageBlockArmed) return;\n\n e.preventDefault();\n e.stopPropagation();\n\n // Create a hidden file input\n const fileInput = document.createElement('input');\n fileInput.type = 'file';\n fileInput.accept = 'image/*';\n\n fileInput.addEventListener('change', (event) => {\n const files = event.target.files;\n if (!files || files.length === 0) return;\n const file = files[0];\n\n const reader = new FileReader();\n reader.onload = (e) => {\n const imageDataUrl = e.target.result;\n const imageContainer = imgBtn.closest('.image-container');\n\n if (imageContainer) {\n // Remove the existing button and img if present\n imgBtn.remove();\n const existingImg = imageContainer.querySelector('img');\n if (existingImg) existingImg.remove();\n\n // Create and add the image element\n const img = document.createElement('img');\n img.src = imageDataUrl;\n img.alt = 'Uploaded image';\n imageContainer.appendChild(img);\n }\n };\n reader.readAsDataURL(file);\n });\n\n fileInput.click();\n }, true); // Use capture phase to catch clicks before other handlers\n };\n\n initImageUpload();\n\n // -------------------------------------------------------------------------\n // Send initial header/footer state to parent\n // -------------------------------------------------------------------------\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'header-footer:state',\n enabled: ENABLE_HEADER_FOOTER\n }, '*');\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'inline-insert:state',\n enabled: window.FlowCanvas.isInlineInsertEnabled?.() !== false\n }, '*');\n\n // -------------------------------------------------------------------------\n // Debug surface\n // -------------------------------------------------------------------------\n window.__FLOW_CANVAS__ = { canvas, doc, FC };\n Object.assign(FC, {\n createBlockFromPayload,\n insertPayloadAtTarget,\n });\n console.log('flow-canvas: initialized');\n})();\n\n<\/script>\n\n <script data-src=\"./js/common-twig-generator.js\">\n/**\n * @fileoverview Common Twig Code Generator\n * Captures drag, drop, move, resize, etc. on the canvas and generates Twig code natively,\n * passing state and selections back to the Angular parent context.\n */\n(function () {\n const CANVAS_SELECTOR = '.custom-form-design';\n const BLOCK_SELECTOR = '.canvas-block, .cs_block_s';\n\n const state = {\n twig: '',\n };\n\n const notify = () => {\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'twig:updated',\n data: { twig: state.twig }\n }, '*');\n } catch (e) { }\n };\n\n const getCanvas = () => document.querySelector(CANVAS_SELECTOR);\n\n const stripChrome = (root) => {\n const clone = root.cloneNode(true);\n clone.querySelectorAll('[data-cs-chrome], .section-binding-info').forEach((el) => el.remove());\n // Strip editor-only selection markers. `cs_selected` / `cs_selected_border`\n // are the scroll-driven \"active page\" highlight on .cs_page wrappers (see\n // active-page.js) and must never appear in the exported markup. The root\n // itself can carry them (a cover page IS the .custom-form-design root).\n [clone, ...clone.querySelectorAll('.cs-selected, .cs-editing, .canvas-block--selected, .cs_selected, .cs_selected_border, .cs-aiden--active, .cs-aiden--loading')]\n .forEach((el) => {\n el.classList?.remove('cs-selected', 'cs-editing', 'canvas-block--selected', 'cs_selected', 'cs_selected_border', 'cs-aiden--active', 'cs-aiden--loading');\n });\n // Aiden's empty-state hint is a `:empty:before` placeholder — drop it on\n // empty AI-writer blocks so the hint text never shows in the export.\n clone.querySelectorAll('.cs-aiden-block .edit_me[placeholder]').forEach((el) => {\n if (!(el.textContent || '').trim()) el.removeAttribute('placeholder');\n });\n\n // Section wrappers used to record their last manual resize as an\n // inline `height: NNNpx`. With flow layout that height clips growing\n // content (and worse — clips the rendered PDF), so we drop it from\n // the emitted markup whenever the block contains a flow section.\n const matchesSection = (el) => !!(el.querySelector && el.querySelector(':scope > .section-container-content'));\n const allBlocks = [clone, ...clone.querySelectorAll('.cs_block_s')];\n allBlocks.forEach((el) => {\n if (!el.style) return;\n if (matchesSection(el)) {\n el.style.height = '';\n el.style.minHeight = '';\n }\n });\n\n return clone;\n };\n\n const generateForCanvas = (canvas) => {\n if (!canvas) return '';\n\n const allBlocks = Array.from(canvas.querySelectorAll(BLOCK_SELECTOR));\n\n // Assign temp IDs\n allBlocks.forEach((b, i) => {\n if (!b.dataset.twigId) {\n b.dataset.twigId = 'tw_' + Math.random().toString(36).substr(2, 9);\n }\n });\n\n // Process from deepest to shallowest to gracefully replace inner HTML\n allBlocks.sort((a, b) => {\n let depthA = 0, currA = a; while (currA) { depthA++; currA = currA.parentElement; }\n let depthB = 0, currB = b; while (currB) { depthB++; currB = currB.parentElement; }\n return depthB - depthA;\n });\n\n const blockTwigMap = new Map();\n\n for (const block of allBlocks) {\n const clone = stripChrome(block);\n\n const subBlocks = clone.querySelectorAll(BLOCK_SELECTOR);\n subBlocks.forEach(sb => {\n // If a subblock was identified, we replace it in the clone\n // Note: the clone's subBlocks still have their dataset if they were on the live block\n const tid = sb.dataset.twigId;\n if (tid && blockTwigMap.has(tid)) {\n const marker = document.createComment(`__TWIG_ID_${tid}__`);\n sb.replaceWith(marker);\n }\n });\n\n // Row/cell-level conditions: <tr>/<td>/<th> carrying data-twig-if get\n // wrapped — whole element — in {% if %}...{% endif %}, so when the\n // condition is false the entire <tr>/<td> disappears from the output\n // (not just its content). NOTE: removing a single <td> shifts the\n // remaining columns in that row, so use cell conditions only when a\n // missing column is acceptable. The expressions are stashed and\n // replaced by numbered comment markers, then swapped for twig after\n // serialisation — that keeps comparison operators (<, >) in the\n // expression from being HTML-escaped by outerHTML.\n const elementConditions = [];\n clone.querySelectorAll('tr[data-twig-if], td[data-twig-if], th[data-twig-if]').forEach((el) => {\n const expr = (el.getAttribute('data-twig-if') || '').trim();\n el.removeAttribute('data-twig-if');\n if (!expr) return;\n const idx = elementConditions.length;\n elementConditions.push(expr);\n el.before(document.createComment(`__IFEL_START_${idx}__`));\n el.after(document.createComment(`__IFEL_END_${idx}__`));\n });\n\n let rawHTML = clone.outerHTML;\n rawHTML = rawHTML.replace(/<!--__TWIG_ID_([^>]+)__-->/g, (match, tid) => {\n return blockTwigMap.get(tid) || '';\n });\n if (elementConditions.length) {\n rawHTML = rawHTML\n .replace(/<!--__IFEL_START_(\\d+)__-->/g, (_, i) => `{% if ${elementConditions[i]} %}`)\n .replace(/<!--__IFEL_END_(\\d+)__-->/g, () => `{% endif %}`);\n }\n\n const repeatPath = block.dataset.repeatPath || '';\n const repeatAlias = block.dataset.repeatAlias || '';\n const ifExpr = block.dataset.twigIf || '';\n\n // Clean up custom twig attributes from the generated HTML\n rawHTML = rawHTML.replace(/\\s+data-twig-if=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-path=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-alias=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-chain=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-label=\"[^\"]*\"/g, '')\n .replace(/\\s+data-twig-id=\"[^\"]*\"/g, '')\n // Also handle single quotes just in case\n .replace(/\\s+data-twig-if='[^']*'/g, '')\n .replace(/\\s+data-repeat-path='[^']*'/g, '')\n .replace(/\\s+data-repeat-alias='[^']*'/g, '')\n .replace(/\\s+data-repeat-chain='[^']*'/g, '')\n .replace(/\\s+data-repeat-label='[^']*'/g, '')\n .replace(/\\s+data-twig-id='[^']*'/g, '');\n\n let twig = rawHTML;\n\n // Multi-level binding chain (set when the user picks a deeply nested\n // array in the modal). Each entry is one {% for %} loop, outermost\n // first. Single-level bindings keep using data-repeat-path/-alias.\n let chain = null;\n try {\n if (block.dataset.repeatChain) {\n chain = JSON.parse(block.dataset.repeatChain);\n }\n } catch (e) { chain = null; }\n\n // Strip leading chain steps that an ancestor block is ALREADY\n // looping over. Modal-saved chains include every outer loop needed\n // to reach the selected array, but when this block sits inside a\n // section that's already iterating the same outer scope, emitting\n // those steps again would produce nested duplicate {% for %} loops\n // (one from the ancestor block, one from this block) and the data\n // would multiply across both axes.\n if (Array.isArray(chain) && chain.length > 0) {\n const ancestorPaths = new Set();\n let anc = block.parentElement;\n while (anc) {\n if (anc.dataset?.repeatChain) {\n try {\n const ancChain = JSON.parse(anc.dataset.repeatChain);\n if (Array.isArray(ancChain)) ancChain.forEach((s) => s?.path && ancestorPaths.add(s.path));\n } catch (e) { /* ignore */ }\n } else if (anc.dataset?.repeatPath) {\n ancestorPaths.add(anc.dataset.repeatPath);\n }\n if (anc.matches?.('.cs_margin, .cs-flow-canvas') || anc.tagName === 'BODY') break;\n anc = anc.parentElement;\n }\n if (ancestorPaths.size) {\n chain = chain.filter((step) => !ancestorPaths.has(step.path));\n if (chain.length === 0) chain = null;\n }\n }\n\n // Build the {% for %} stack from a chain (multi-level) or the\n // single repeatPath/-alias pair. Returns the wrapped body, or the\n // body unchanged if there's no loop.\n const wrapInLoops = (body) => {\n if (Array.isArray(chain) && chain.length > 0) {\n let out = body;\n for (let i = chain.length - 1; i >= 0; i--) {\n const step = chain[i];\n if (step.kind === 'map' && step.keyAlias) {\n out = `{% for ${step.keyAlias}, ${step.alias} in ${step.path} %}\\n${out}\\n{% endfor %}`;\n } else {\n out = `{% for ${step.alias} in ${step.path} %}\\n${out}\\n{% endfor %}`;\n }\n }\n return out;\n }\n if (repeatPath) {\n const alias = repeatAlias || 'item';\n return `{% for ${alias} in ${repeatPath} %}\\n${body}\\n{% endfor %}`;\n }\n return body;\n };\n\n const hasLoop = (Array.isArray(chain) && chain.length > 0) || !!repeatPath;\n // Tables: by default the whole <table> would repeat, which means\n // <thead> (the column header row) repeats once per iteration too.\n // Almost always the header should appear ONCE and only the data\n // rows under <tbody> should repeat. We rewrite the block to wrap\n // just the <tbody> contents in the {% for %} stack.\n //\n // BUT: when the header itself uses loop-specific content (eg.\n // `Visit {{ loop.index }}` or `{{ item.engineer }}`), the header\n // is supposed to repeat alongside the body — that's effectively a\n // \"card per item\" layout rendered as a table. Detect that by\n // looking for any chain alias or `loop.` reference inside the\n // <thead>; if found, fall back to wrapping the entire block.\n //\n // For non-table loops we DEFER the {% for %} wrap to the canvas\n // finalisation pass: if the block sits alone in its row/col, the\n // wrap is hoisted up to wrap the row instead (so the rendered\n // markup contains one row per iteration, not one block-with-no-row\n // per iteration which would put multiple blocks under the same\n // col and trip duplicate IDs).\n const tbodyMatch = hasLoop ? rawHTML.match(/<tbody[^>]*>([\\s\\S]*?)<\\/tbody>/i) : null;\n const theadMatch = hasLoop ? rawHTML.match(/<thead[^>]*>([\\s\\S]*?)<\\/thead>/i) : null;\n const aliasList = Array.isArray(chain) && chain.length\n ? chain.map((s) => s.alias).concat(chain.filter((s) => s.keyAlias).map((s) => s.keyAlias))\n : (repeatAlias ? [repeatAlias] : []);\n const theadInner = theadMatch ? theadMatch[1] : '';\n const theadIsDynamic = theadInner.includes('loop.') ||\n aliasList.some((a) => a && new RegExp(`\\\\b${a}\\\\b`).test(theadInner));\n\n let wrappedAtBlockLevel = false;\n if (tbodyMatch && !theadIsDynamic) {\n const tbodyInner = tbodyMatch[1];\n // Within <tbody> the rows may mix static label rows (\"Part |\n // Quantity\") with data rows that reference loop aliases. Only\n // the dynamic rows should repeat — leave static rows outside\n // the {% for %} so they render once. We split on </tr> and\n // group consecutive dynamic rows together, then wrap each\n // dynamic group with the loop while leaving static rows as-is.\n const trMatches = tbodyInner.match(/<tr[\\s\\S]*?<\\/tr>/gi) || [];\n if (trMatches.length > 1 && aliasList.length) {\n const aliasRe = new RegExp(`\\\\b(?:${aliasList.map((a) => a.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|')})\\\\b|loop\\\\.`);\n let assembled = '';\n let buffer = '';\n const flushBuffer = () => {\n if (!buffer) return;\n assembled += wrapInLoops(buffer);\n buffer = '';\n };\n for (const tr of trMatches) {\n if (aliasRe.test(tr)) {\n buffer += tr;\n } else {\n flushBuffer();\n assembled += tr;\n }\n }\n flushBuffer();\n twig = rawHTML.replace(tbodyMatch[0], `<tbody>${assembled}</tbody>`);\n } else {\n const wrappedTbody = wrapInLoops(tbodyInner);\n twig = rawHTML.replace(tbodyMatch[0], `<tbody>${wrappedTbody}</tbody>`);\n }\n wrappedAtBlockLevel = true;\n }\n\n if (ifExpr && wrappedAtBlockLevel) {\n twig = `{% if ${ifExpr} %}\\n${twig}\\n{% endif %}`;\n }\n\n blockTwigMap.set(block.dataset.twigId, twig);\n // Carry the unwrapped loop info forward to the finalisation pass\n // so it can decide whether to wrap at the block, col, or row level.\n if (!wrappedAtBlockLevel && (hasLoop || ifExpr)) {\n blockTwigMap.set(block.dataset.twigId + '__wrap', {\n chain: Array.isArray(chain) && chain.length ? chain : null,\n repeatPath: !chain ? repeatPath : '',\n repeatAlias: !chain ? repeatAlias : '',\n ifExpr,\n wrapInLoops,\n });\n }\n }\n\n const canvasClone = stripChrome(canvas);\n const canvasSubBlocks = canvasClone.querySelectorAll(BLOCK_SELECTOR);\n\n // For each block that DEFERRED its {% for %} wrap, decide where the\n // wrap should land: ideally on the outermost ancestor that contains\n // ONLY this block (typically the row-item when the block is alone in\n // a single-col row). That way, each loop iteration produces a fresh\n // row/col stack instead of stuffing multiple blocks under the same\n // <col-item> (which leaves duplicate IDs and broken flex layout).\n //\n // We mark the hoist target with BEGIN/END comment sentinels — these\n // survive .innerHTML serialization, and we substitute them with the\n // actual {% for %} / {% endif %} text in the final string pass.\n // Top-level blocks of `el` = blocks inside el whose CLOSEST ancestor\n // block (excluding themselves) is NOT also inside el. Without this\n // filter, a section block's own nested children blocks would inflate\n // the count and prevent legitimate row-hoisting.\n const topLevelBlocksUnder = (el) => {\n const all = Array.from(el.querySelectorAll(BLOCK_SELECTOR));\n return all.filter((b) => {\n const outer = b.parentElement?.closest(BLOCK_SELECTOR);\n return !outer || !el.contains(outer);\n });\n };\n\n const hoistMap = new Map();\n canvasSubBlocks.forEach((sb) => {\n const tid = sb.dataset.twigId;\n if (!tid || !blockTwigMap.has(tid + '__wrap')) return;\n // Walk up while the ancestor's ONLY top-level descendant block is\n // this one. Stop at the cs_margin / section-container-content\n // boundary, and only hoist through structural row/col wrappers.\n let hoist = sb;\n let cursor = sb.parentElement;\n while (cursor) {\n if (cursor.matches?.('.cs_margin, .section-container-content, .cs-flow-canvas')) break;\n if (!cursor.matches?.('.row-item, .col-item')) break;\n const topBlocks = topLevelBlocksUnder(cursor);\n if (topBlocks.length !== 1 || topBlocks[0] !== sb) break;\n hoist = cursor;\n cursor = cursor.parentElement;\n }\n hoistMap.set(tid, hoist);\n });\n\n canvasSubBlocks.forEach((sb) => {\n const tid = sb.dataset.twigId;\n if (tid && blockTwigMap.has(tid)) {\n const marker = document.createComment(`__TWIG_ID_${tid}__`);\n sb.replaceWith(marker);\n }\n });\n\n // Insert hoist BEGIN/END markers around the chosen ancestor for each\n // deferred wrap. Done AFTER block replacement so the markers don't\n // accidentally get nuked by the replaceWith.\n hoistMap.forEach((hoistEl, tid) => {\n if (!hoistEl || !hoistEl.parentElement) return;\n const begin = document.createComment(`__TWIG_WRAP_BEGIN_${tid}__`);\n const end = document.createComment(`__TWIG_WRAP_END_${tid}__`);\n hoistEl.parentElement.insertBefore(begin, hoistEl);\n hoistEl.parentElement.insertBefore(end, hoistEl.nextSibling);\n });\n\n let finalHTML = canvasClone.outerHTML;\n finalHTML = finalHTML.replace(/<!--__TWIG_ID_([^>]+)__-->/g, (match, tid) => {\n return blockTwigMap.get(tid) || '';\n });\n // Substitute hoisted wraps. Each pair becomes {% for ... %} ... {% endfor %}.\n finalHTML = finalHTML.replace(\n /<!--__TWIG_WRAP_BEGIN_([^>]+)__-->([\\s\\S]*?)<!--__TWIG_WRAP_END_\\1__-->/g,\n (match, tid, body) => {\n const info = blockTwigMap.get(tid + '__wrap');\n if (!info) return body;\n let wrapped = info.wrapInLoops(body);\n if (info.ifExpr) wrapped = `{% if ${info.ifExpr} %}\\n${wrapped}\\n{% endif %}`;\n return wrapped;\n }\n );\n\n // Clean any remaining root-level custom attributes that leaked through\n finalHTML = finalHTML.replace(/\\s+data-twig-if=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-path=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-alias=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-chain=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-label=\"[^\"]*\"/g, '')\n .replace(/\\s+data-twig-id=\"[^\"]*\"/g, '');\n\n // Cleanup live DOM dataset twigIds\n allBlocks.forEach(b => delete b.dataset.twigId);\n\n return finalHTML;\n };\n\n // Serialise EVERY page canvas (.custom-form-design) on the board and\n // concatenate them, each on its own line. Each canvas is one A4 .cs_margin\n // page; emitting all of them (instead of only the first via getCanvas())\n // is what lets a multi-page design render past page 1 in the PDF.\n const generate = () => {\n const canvases = Array.from(document.querySelectorAll(CANVAS_SELECTOR));\n if (!canvases.length) return '';\n const html = canvases.map((c) => generateForCanvas(c)).join('\\n');\n state.twig = html;\n notify();\n return html;\n };\n\n const startObserver = () => {\n const canvas = getCanvas();\n if (!canvas) return;\n // Observe the whole multi-page board (.cs_paper) when present so edits\n // on ANY page — and newly added pages — regenerate the twig, not just\n // changes to page 1.\n const target = canvas.closest('.cs_paper') || canvas.parentElement || canvas;\n\n let scheduled = false;\n const scheduleRegen = () => {\n if (scheduled) return;\n scheduled = true;\n requestAnimationFrame(() => {\n scheduled = false;\n generate();\n });\n };\n\n const obs = new MutationObserver(scheduleRegen);\n obs.observe(target, {\n childList: true,\n subtree: true,\n characterData: true,\n attributes: true,\n attributeFilter: ['style', 'data-twig-if', 'data-repeat-path', 'data-repeat-alias', 'data-repeat-chain', 'class']\n });\n };\n\n // Convert RGB to Hex for consistent color display\n const rgbToHex = (rgb) => {\n if (!rgb) return '';\n if (rgb.startsWith('#')) return rgb;\n\n const match = rgb.match(/^rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)$/);\n if (!match) return rgb;\n\n const r = parseInt(match[1]);\n const g = parseInt(match[2]);\n const b = parseInt(match[3]);\n\n return \"#\" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);\n };\n\n const readBlockStyles = (block) => {\n // Use StyleManager if available\n if (typeof window.StyleManager !== 'undefined' && typeof window.StyleManager.readBlockStyles === 'function') {\n return window.StyleManager.readBlockStyles(block);\n }\n\n // Fallback implementation with RGB to Hex conversion\n const s = block.style;\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n const iS = inner ? inner.style : {};\n\n return {\n backgroundColor: rgbToHex(s.backgroundColor) || '',\n textColor: rgbToHex(iS.color || s.color) || '',\n fontSize: iS.fontSize || '',\n fontWeight: iS.fontWeight || '',\n borderStyle: s.borderStyle || '',\n borderColor: rgbToHex(s.borderColor) || '',\n borderWidth: s.borderWidth || '',\n borderRadius: s.borderRadius || '',\n paddingTop: s.paddingTop || '',\n paddingRight: s.paddingRight || '',\n paddingBottom: s.paddingBottom || '',\n paddingLeft: s.paddingLeft || '',\n marginTop: s.marginTop || '',\n marginRight: s.marginRight || '',\n marginBottom: s.marginBottom || '',\n marginLeft: s.marginLeft || '',\n opacity: s.opacity || '',\n boxShadow: s.boxShadow || '',\n width: s.width || '',\n height: s.height || '',\n };\n };\n\n // Walk UP from a selected block collecting every ancestor that is itself a\n // content block (.cs_block_s) — e.g. the Flexible / Section that wraps it.\n // Returns innermost-first ({id, name}) so the panel can offer a \"Choose\n // parent <name>\" button for each level. Ids are minted lazily so the panel\n // can target them with `block:select`.\n const getBlockParents = (block) => {\n const out = [];\n let cur = block && block.parentElement;\n while (cur && cur !== document.body) {\n if (cur.matches && cur.matches('.cs_margin, .cs-flow-canvas, .cs_paper, .cs_page')) break;\n if (cur.classList && cur.classList.contains('cs_block_s')) {\n if (!cur.id) cur.id = 'block_' + Math.random().toString(36).substr(2, 9);\n out.push({\n id: cur.id,\n name: cur.getAttribute('custom-name') || cur.dataset.blockType || cur.getAttribute('data') || 'Block'\n });\n }\n cur = cur.parentElement;\n }\n return out;\n };\n\n // The set of mutually-exclusive frame-shape classes an image container can\n // carry. Shared by the read (getImageFrame) and write (set-image-frame) paths\n // so they never drift. Mirrors the .image-container.<shape> rules in editor.css.\n const IMAGE_FRAME_SHAPES = [\n 'square-image',\n 'rounded-square-image',\n 'circle-image',\n 'diagonal-corners-image',\n 'polygon',\n 'star',\n 'rectangle-image',\n ];\n\n const getImageFrame = (container) => {\n for (const shape of IMAGE_FRAME_SHAPES) {\n if (container.classList.contains(shape)) return shape;\n }\n return 'square-image'; // default framing when no shape class is present\n };\n\n // Geometric frames need a 1:1 box — otherwise a percentage clip-path (star /\n // polygon / hexagon) stretches across the image's wide-and-short frame and\n // stops looking like the shape. They also need the image to COVER that box;\n // the global `.cs_block_s img { object-fit: contain }` rule otherwise\n // letterboxes the picture inside the shape. Rectangular frames keep the\n // default contain / full-width framing.\n //\n // These have to be driven via inline `!important` from JS: the container's\n // creation-time inline `aspect-ratio` carries `!important`, which no\n // stylesheet rule (even `!important`) can override — only another inline\n // declaration can.\n const SHAPED_FRAMES = ['circle-image', 'diagonal-corners-image', 'polygon', 'star'];\n\n const setImageFrame = (container, shape) => {\n if (!IMAGE_FRAME_SHAPES.includes(shape)) return;\n IMAGE_FRAME_SHAPES.forEach((s) => container.classList.remove(s));\n container.classList.add(shape);\n\n const img = container.querySelector('img');\n if (SHAPED_FRAMES.includes(shape)) {\n // Square, centred box (margin:auto on the container already centres it)\n // so the shape renders true-to-form, with the image filling it.\n container.style.setProperty('aspect-ratio', '1', 'important');\n container.style.setProperty('width', 'auto', 'important');\n img?.style.setProperty('object-fit', 'cover', 'important');\n } else {\n // Restore the default wide framing for square / rounded / rectangle.\n container.style.setProperty('aspect-ratio', 'auto', 'important');\n container.style.setProperty('width', '100%', 'important');\n img?.style.removeProperty('object-fit');\n }\n\n // The box size/aspect just changed, so re-clamp any active zoom/pan.\n window.FlowCanvas?.refreshImageZoom?.(container);\n };\n\n const broadcastSelection = () => {\n // Find the currently selected block, whether it is selected via inline-editor class or custom-form class\n let block = document.querySelector('.cs_block_s.cs-selected, .cs_block_s.cs-editing') ||\n document.querySelector('.canvas-block--selected');\n\n if (!block) {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'selection:cleared'\n }, '*');\n return;\n }\n\n // Ensure it has an ID so we can apply properties back to it\n if (!block.id) {\n block.id = 'block_' + Math.random().toString(36).substr(2, 9);\n }\n\n // Image-block frame: surface whether this is an image and which frame\n // shape it currently uses, so the parent panel can show the shape picker\n // only for images and highlight the active shape.\n const imageContainer = block.querySelector('.image-container');\n\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'selection:changed',\n data: {\n blockId: block.id,\n blockType: block.dataset.blockType || block.getAttribute('data') || null,\n label: block.getAttribute('custom-name') || block.dataset.blockType || 'Block',\n twigIf: block.dataset.twigIf || '',\n tableBorderWidth: block.querySelector('table') ? (block.querySelector('table').dataset.borderWidth || '0') : '0',\n tableBorderColor: block.querySelector('table') ? (block.querySelector('table').dataset.borderColor || '#000000') : '#000000',\n isImage: !!imageContainer,\n imageFrame: imageContainer ? getImageFrame(imageContainer) : '',\n styles: readBlockStyles(block),\n parents: getBlockParents(block)\n }\n }, '*');\n };\n\n // When the user clicks inside a Table block we surface the clicked cell and\n // its row to the panel so each can carry its own show-condition. Cell/row\n // ids are minted lazily so the panel can target them with set-condition.\n const broadcastTableTarget = (target) => {\n const cell = target && target.closest ? target.closest('td, th') : null;\n const blockEl = target && target.closest ? target.closest('.cs_block_s, .canvas-block') : null;\n const isTable = !!(cell && blockEl && blockEl.querySelector('table'));\n if (!isTable) {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'table-target:cleared' }, '*');\n return;\n }\n const row = cell.closest('tr');\n if (!cell.id) cell.id = 'cell_' + Math.random().toString(36).substr(2, 9);\n if (row && !row.id) row.id = 'row_' + Math.random().toString(36).substr(2, 9);\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'table-target:changed',\n data: {\n cellId: cell.id,\n cellTag: cell.tagName.toLowerCase(),\n cellCondition: cell.dataset.twigIf || '',\n rowId: row ? row.id : '',\n rowCondition: row ? (row.dataset.twigIf || '') : ''\n }\n }, '*');\n };\n\n // Remove a block with a small shrink/fade animation, then prune empty\n // columns/rows and regenerate. Shared by the Delete key and the block badge\n // \"delete\" action.\n const deleteBlockWithAnimation = (block) => {\n if (!block) return;\n block.style.transition = 'transform 0.2s cubic-bezier(0.6, -0.28, 0.735, 0.045), opacity 0.2s ease-in';\n block.style.transform = 'scale(0.85)';\n block.style.opacity = '0';\n setTimeout(() => {\n block.remove();\n broadcastSelection();\n if (typeof window.FlowCanvas !== 'undefined' && typeof window.FlowCanvas.cleanupEmpty === 'function') {\n const c = getCanvas();\n if (c) window.FlowCanvas.cleanupEmpty(c);\n }\n if (typeof window.generate === 'function') window.generate();\n }, 200);\n };\n window.FlowCanvas = window.FlowCanvas || {};\n window.FlowCanvas.deleteBlock = deleteBlockWithAnimation;\n\n const startSelectionObserver = () => {\n document.addEventListener('click', (e) => {\n setTimeout(broadcastSelection, 50);\n broadcastTableTarget(e.target);\n });\n document.addEventListener('drop', () => {\n setTimeout(broadcastSelection, 50);\n setTimeout(generate, 100);\n });\n // The main observer watches for 'class' changes which covers selections too\n const canvas = getCanvas();\n if (canvas) {\n const classObs = new MutationObserver(() => {\n broadcastSelection();\n });\n classObs.observe(canvas, { subtree: true, attributes: true, attributeFilter: ['class'] });\n }\n\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Delete' || e.key === 'Backspace') {\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.isContentEditable || activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {\n return; // Let user natively edit text within block\n }\n\n const activeBlock = document.querySelector('.cs_block_s.cs-selected, .cs_block_s.cs-editing') || document.querySelector('.canvas-block--selected');\n if (activeBlock) {\n // Route through FlowCanvas.deleteBlock so wrappers (e.g. the List's\n // group-delete) run; it falls back to deleteBlockWithAnimation.\n (window.FlowCanvas?.deleteBlock || deleteBlockWithAnimation)(activeBlock);\n }\n }\n });\n };\n\n window.addEventListener('message', (event) => {\n const msg = event.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n\n if (msg.type === 'delete-block') {\n const block = document.getElementById(msg.blockId);\n if (block) {\n block.remove();\n // Manually trigger cleanup to remove empty cols/rows in sections\n if (window.FlowCanvas?.cleanupEmpty) {\n const doc = document.querySelector('.custom-form-design');\n if (doc) window.FlowCanvas.cleanupEmpty(doc);\n }\n broadcastSelection();\n generate();\n }\n }\n\n if (msg.type === 'set-condition') {\n const block = document.getElementById(msg.blockId);\n if (block) {\n if (msg.expr && msg.expr.trim()) {\n block.dataset.twigIf = msg.expr.trim();\n } else {\n delete block.dataset.twigIf;\n }\n generate();\n }\n }\n\n if (msg.type === 'set-table-border-params') {\n const block = document.getElementById(msg.blockId);\n if (block) {\n const table = block.querySelector('table');\n const cells = block.querySelectorAll('th, td');\n\n let bw = parseInt(msg.borderWidth) || 0;\n let color = msg.borderColor || '#000000';\n let borderStr = bw > 0 ? `${bw}px solid ${color}` : 'none';\n\n if (table) {\n table.dataset.borderWidth = bw.toString();\n table.dataset.borderColor = color;\n table.style.border = borderStr;\n table.style.borderCollapse = 'collapse';\n }\n cells.forEach(c => c.style.border = borderStr);\n generate();\n }\n }\n\n // Result from the parent-side binding modal: apply repeat-path/alias to\n // the block, or do nothing on skip.\n if (msg.type === 'binding-modal:apply') {\n const block = document.getElementById(msg.blockId);\n if (block && msg.path) {\n block.dataset.repeatPath = msg.path;\n block.dataset.repeatAlias = msg.alias || 'item';\n block.dataset.repeatLabel = msg.path;\n\n // Multi-level binding (deeply nested array picked in the modal):\n // persist the full chain so the twig generator can emit nested\n // {% for %} loops. Single-level bindings clear the chain so the\n // simpler code path is used.\n if (Array.isArray(msg.chain) && msg.chain.length > 1) {\n block.dataset.repeatChain = JSON.stringify(msg.chain);\n } else {\n delete block.dataset.repeatChain;\n }\n\n // Visual hint (matches old in-iframe modal behaviour)\n let info = block.querySelector('.section-binding-info');\n if (!info) {\n info = document.createElement('div');\n info.className = 'section-binding-info';\n block.appendChild(info);\n }\n const chainLen = Array.isArray(msg.chain) ? msg.chain.length : 1;\n info.textContent = chainLen > 1\n ? `Repeats ${msg.path} (${chainLen} nested loops)`\n : `Repeats ${msg.path}`;\n generate();\n }\n }\n\n // Handle style updates from the parent\n if (msg.type === 'set-block-style') {\n const block = document.getElementById(msg.blockId);\n if (block && msg.prop && msg.value !== undefined) {\n const { prop, value } = msg;\n\n // Handle different style properties\n if (prop === 'textColor') {\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n if (inner) {\n inner.style.color = value || '';\n }\n block.style.color = value || '';\n } else if (prop === 'fontSize') {\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n if (inner) {\n inner.style.fontSize = value || '';\n }\n } else if (prop === 'fontWeight') {\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n if (inner) {\n inner.style.fontWeight = value || '';\n }\n } else {\n // Apply style directly to the block\n if (value === '' || value === null) {\n const cssProp = prop === 'backgroundColor' ? 'background-color' : camelCaseToCssProp(prop);\n block.style.removeProperty(cssProp);\n } else {\n const cssProp = prop === 'backgroundColor' ? 'background-color' : camelCaseToCssProp(prop);\n block.style.setProperty(cssProp, value, 'important');\n }\n }\n\n // After applying styles, broadcast the selection to update the panel\n setTimeout(() => broadcastSelection(), 50);\n generate();\n }\n }\n\n // Change an image block's frame shape (square / rounded / circle / polygon\n // / star …). Only the .image-container's shape class is swapped — the <img>,\n // its src and any zoom/pan transform are untouched, so all other image\n // functionality keeps working; only the visible frame changes.\n if (msg.type === 'set-image-frame') {\n const block = document.getElementById(msg.blockId);\n const container = block?.querySelector('.image-container');\n if (container && msg.shape) {\n setImageFrame(container, msg.shape);\n broadcastSelection();\n generate();\n }\n }\n });\n\n // Helper function for style handler\n const camelCaseToCssProp = (camelCase) => {\n return camelCase.replace(/([A-Z])/g, (g) => `-${g.toLowerCase()}`);\n };\n\n document.addEventListener('DOMContentLoaded', () => {\n setTimeout(() => {\n startObserver();\n startSelectionObserver();\n generate();\n }, 100);\n });\n})();\n\n<\/script>\n</body>\n\n</html>";
182
+ window.__PS_CANVAS_SRCDOC__ = "<!doctype html>\n<html lang=\"en\">\n\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Custom Form</title>\n <style data-src=\"./css/custom-form.css\">\n:root {\n color-scheme: light;\n --page-bg: #f4f5fb;\n --ink: #20233d;\n --muted: #72769a;\n --line: #e2e5f4;\n --line-strong: #cfd4f6;\n --accent: #5c5cff;\n --accent-soft: #eef0ff;\n --accent-ghost: rgba(92, 92, 255, 0.08);\n --shadow: 0 18px 40px rgba(34, 36, 61, 0.08);\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml,\nbody {\n margin-top: 15px;\n padding: 0;\n background: var(--page-bg);\n font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n color: var(--ink);\n font-size: 14px;\n}\n\nbody {\n min-height: 100%;\n /* The iframe is sized to the page in canvas.scss. The horizontal\n axis must NEVER scroll inside the iframe (it produces an ugly\n extra scrollbar under the canvas). Vertical scroll IS allowed so\n long forms can be edited; the outer .canvas-stage handles the\n bigger picture. */\n overflow-x: hidden;\n}\n\n#place_everything {\n min-height: 100%;\n height: auto;\n}\n\n.page_container {\n min-height: 100%;\n height: auto;\n}\n\n.cs_paper {\n min-height: 100%;\n height: auto;\n /* background: #f4f5fb; */\n padding: 24px 0;\n}\n\n.custom-form-design {\n position: relative;\n height: 100%;\n overflow: hidden;\n /* border-radius: 8px; */\n background: #ffffff;\n}\n\n/* .custom-form-design::before {\n content: 'Drag a block here';\n position: absolute;\n inset: 16px;\n display: grid;\n place-items: center;\n text-align: center;\n color: var(--muted);\n font-size: 14px;\n line-height: 1.4;\n pointer-events: none;\n opacity: 0.7;\n transition: opacity 160ms ease, transform 160ms ease;\n} */\n\n.drop-surface--active {\n background:\n linear-gradient(180deg, rgba(92, 92, 255, 0.04), rgba(92, 92, 255, 0.02)),\n #ffffff;\n}\n\n.custom-form-design.drop-surface--active::before {\n color: var(--accent);\n opacity: 1;\n transform: scale(1.03);\n}\n\n.page-grid {\n position: absolute;\n inset: 0;\n background-image:\n linear-gradient(rgba(92, 92, 255, 0.05) 1px, transparent 1px),\n linear-gradient(90deg, rgba(92, 92, 255, 0.05) 1px, transparent 1px);\n background-size: 24px 24px;\n opacity: 0.28;\n pointer-events: none;\n}\n\n.page-empty-state {\n position: absolute;\n inset: 50% auto auto 50%;\n width: min(100% - 96px, 420px);\n transform: translate(-50%, -50%);\n display: grid;\n gap: 12px;\n justify-items: center;\n text-align: center;\n padding: 28px;\n border: 1px dashed var(--line-strong);\n border-radius: 24px;\n background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 246, 255, 0.94));\n box-shadow: var(--shadow);\n}\n\n.page-empty-state[hidden] {\n display: none;\n}\n\n.page-empty-state__badge {\n display: inline-flex;\n align-items: center;\n min-height: 28px;\n padding: 0 12px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.page-empty-state h1 {\n margin: 0;\n font-size: 14px;\n line-height: 1.15;\n letter-spacing: -0.04em;\n}\n\n.page-empty-state p {\n margin: 0;\n color: var(--muted);\n font-size: 14px;\n line-height: 1.65;\n}\n\n.section-binding-modal {\n position: fixed;\n inset: 0;\n display: grid;\n place-items: center;\n z-index: 1000;\n}\n\n.section-binding-modal[hidden] {\n display: none !important;\n}\n\n.section-binding-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(20, 24, 48, 0.62);\n backdrop-filter: blur(8px);\n}\n\n.section-binding-card {\n position: relative;\n width: min(820px, calc(100% - 32px));\n max-height: min(90vh, 680px);\n overflow: hidden;\n display: grid;\n gap: 24px;\n padding: 28px;\n border-radius: 28px;\n background: #192238;\n color: #f0f2f9;\n box-shadow: 0 28px 72px rgba(14, 20, 48, 0.38), 0 0 1px rgba(255, 255, 255, 0.12);\n z-index: 1;\n}\n\n.section-binding-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n}\n\n.section-binding-title {\n margin: 0 0 8px;\n font-size: 14px;\n font-weight: 700;\n color: #fff;\n letter-spacing: -0.02em;\n}\n\n.section-binding-subtitle {\n margin: 0;\n color: rgba(255, 255, 255, 0.68);\n font-size: 14px;\n line-height: 1.6;\n}\n\n.section-binding-close {\n border: 1px solid rgba(255, 255, 255, 0.12);\n background: rgba(255, 255, 255, 0.06);\n color: #fff;\n width: 40px;\n height: 40px;\n border-radius: 999px;\n font-size: 14px;\n cursor: pointer;\n flex-shrink: 0;\n transition: background-color 160ms, border-color 160ms;\n}\n\n.section-binding-close:hover {\n background: rgba(255, 255, 255, 0.11);\n border-color: rgba(255, 255, 255, 0.18);\n}\n\n.section-binding-grid {\n display: grid;\n grid-template-columns: 1fr 1.2fr;\n gap: 20px;\n min-height: 0;\n}\n\n.section-binding-list-card,\n.section-binding-config-card {\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid rgba(255, 255, 255, 0.09);\n border-radius: 20px;\n padding: 18px;\n overflow: hidden;\n display: grid;\n gap: 14px;\n}\n\n.section-binding-list-title {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 2px;\n font-size: 14px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: #8aa4e6;\n font-weight: 700;\n}\n\n.section-binding-badge {\n display: inline-flex;\n min-width: 48px;\n justify-content: center;\n background: rgba(138, 164, 230, 0.18);\n color: #a0b2ff;\n font-size: 14px;\n padding: 5px 12px;\n border-radius: 999px;\n font-weight: 600;\n}\n\n.section-binding-list {\n display: grid;\n gap: 10px;\n max-height: 360px;\n overflow-y: auto;\n overflow-x: hidden;\n padding-right: 6px;\n}\n\n.section-binding-list::-webkit-scrollbar {\n width: 6px;\n}\n\n.section-binding-list::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.section-binding-list::-webkit-scrollbar-thumb {\n background: rgba(255, 255, 255, 0.12);\n border-radius: 3px;\n}\n\n.section-binding-list::-webkit-scrollbar-thumb:hover {\n background: rgba(255, 255, 255, 0.18);\n}\n\n.section-binding-array-item {\n width: 100%;\n text-align: left;\n border: 1px solid rgba(255, 255, 255, 0.09);\n border-radius: 14px;\n padding: 14px;\n background: rgba(255, 255, 255, 0.02);\n color: #f0f2f9;\n cursor: pointer;\n transition: all 180ms ease;\n}\n\n.section-binding-array-item:hover {\n border-color: rgba(92, 92, 255, 0.35);\n background: rgba(92, 92, 255, 0.08);\n}\n\n.section-binding-array-item--selected {\n border-color: #5c5cff;\n background: rgba(92, 92, 255, 0.15);\n box-shadow: inset 0 0 0 1px rgba(92, 92, 255, 0.25);\n}\n\n.section-binding-array-item__row {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n margin-bottom: 8px;\n align-items: center;\n}\n\n.section-binding-array-item__path {\n font-size: 14px;\n font-weight: 600;\n color: #fff;\n word-break: break-word;\n}\n\n.section-binding-array-item__count {\n font-size: 14px;\n color: rgba(255, 255, 255, 0.58);\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.section-binding-array-item__preview {\n color: rgba(255, 255, 255, 0.54);\n font-size: 14px;\n line-height: 1.45;\n word-break: break-word;\n}\n\n.section-binding-empty {\n grid-column: 1 / -1;\n padding: 32px 16px;\n text-align: center;\n color: rgba(255, 255, 255, 0.52);\n font-size: 14px;\n line-height: 1.6;\n}\n\n.section-binding-field-label {\n display: block;\n margin-bottom: 8px;\n color: rgba(255, 255, 255, 0.62);\n font-size: 14px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n font-weight: 700;\n}\n\n.section-binding-field {\n min-height: 44px;\n display: flex;\n align-items: center;\n padding: 12px 14px;\n border-radius: 14px;\n background: rgba(255, 255, 255, 0.04);\n color: #f0f2f9;\n font-size: 14px;\n border: 1px solid rgba(255, 255, 255, 0.08);\n}\n\n.section-binding-field--readonly {\n pointer-events: none;\n}\n\n.section-binding-input {\n width: 100%;\n padding: 12px 14px;\n border-radius: 14px;\n border: 1px solid rgba(255, 255, 255, 0.12);\n background: rgba(255, 255, 255, 0.05);\n color: #f0f2f9;\n font-size: 14px;\n font-family: inherit;\n transition: border-color 160ms, background-color 160ms;\n}\n\n.section-binding-input:focus {\n outline: none;\n border-color: rgba(92, 92, 255, 0.40);\n background: rgba(92, 92, 255, 0.08);\n}\n\n.section-binding-generated-code {\n margin-top: 12px;\n}\n\n.section-binding-code-title {\n margin-bottom: 10px;\n font-size: 14px;\n color: rgba(255, 255, 255, 0.62);\n text-transform: uppercase;\n letter-spacing: 0.12em;\n font-weight: 700;\n}\n\n.section-binding-code {\n margin: 0;\n padding: 14px;\n border-radius: 14px;\n background: rgba(0, 0, 0, 0.28);\n border: 1px solid rgba(92, 92, 255, 0.15);\n color: #a0b2ff;\n font-size: 14px;\n line-height: 1.6;\n white-space: pre-wrap;\n word-break: break-word;\n font-family: 'Monaco', 'Courier New', monospace;\n}\n\n.section-binding-footer {\n display: flex;\n justify-content: flex-end;\n gap: 12px;\n margin-top: 4px;\n}\n\n.section-binding-skip,\n.section-binding-apply {\n border: none;\n min-height: 44px;\n border-radius: 999px;\n padding: 0 26px;\n font-size: 14px;\n font-weight: 600;\n cursor: pointer;\n transition: all 160ms ease;\n}\n\n.section-binding-skip {\n background: transparent;\n color: rgba(255, 255, 255, 0.78);\n border: 1px solid rgba(255, 255, 255, 0.12);\n}\n\n.section-binding-skip:hover {\n background: rgba(255, 255, 255, 0.06);\n border-color: rgba(255, 255, 255, 0.2);\n}\n\n.section-binding-apply {\n background: #5c5cff;\n color: #fff;\n box-shadow: 0 8px 20px rgba(92, 92, 255, 0.24);\n}\n\n.section-binding-apply:hover:not(:disabled) {\n background: #6b6bff;\n box-shadow: 0 12px 28px rgba(92, 92, 255, 0.32);\n}\n\n.section-binding-apply:disabled {\n opacity: 0.42;\n cursor: not-allowed;\n}\n\n.canvas-block {\n position: absolute;\n /* min-width: 120px; */\n max-width: calc(100% - 32px);\n user-select: none;\n cursor: grab;\n}\n\n.canvas-block:active {\n cursor: grabbing;\n}\n\n.canvas-block--selected .canvas-block__inner {\n box-shadow:\n 0 0 0 2px var(--accent),\n 0 18px 40px rgba(34, 36, 61, 0.12);\n}\n\n.canvas-block__inner {\n position: relative;\n padding: 14px;\n border: 1px solid var(--line);\n border-radius: 22px;\n background: #ffffff;\n box-shadow: var(--shadow);\n}\n\n.canvas-block__tag {\n display: inline-flex;\n align-items: center;\n min-height: 24px;\n padding: 0 10px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n}\n\n.canvas-block__remove {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 28px;\n height: 28px;\n border: 0;\n border-radius: 999px;\n background: #f5f6ff;\n color: #5e6288;\n font: inherit;\n font-size: 14px;\n cursor: pointer;\n pointer-events: auto;\n}\n\n.canvas-block__remove:hover {\n background: #eceeff;\n}\n\n.canvas-block__content,\n.canvas-block__content * {\n pointer-events: none;\n}\n\n.block-card {\n display: grid;\n gap: 14px;\n}\n\n.block-card h2,\n.block-card h3,\n.block-heading {\n margin: 0;\n letter-spacing: -0.03em;\n}\n\n.block-card p,\n.block-paragraph,\n.block-caption,\n.block-list li,\n.block-table__row span,\n.block-input {\n color: var(--muted);\n}\n\n.block-card--hero {\n padding-top: 10px;\n}\n\n.block-card--hero h2 {\n font-size: 14px;\n line-height: 1.08;\n}\n\n.block-card--hero p {\n margin: 0;\n max-width: 520px;\n font-size: 14px;\n line-height: 1.7;\n}\n\n.block-actions {\n display: flex;\n gap: 10px;\n}\n\n.block-pill {\n display: inline-flex;\n align-items: center;\n min-height: 38px;\n padding: 0 16px;\n border-radius: 12px;\n background: #ffffff;\n border: 1px solid var(--line);\n color: var(--ink);\n font-size: 14px;\n font-weight: 600;\n}\n\n.block-pill--primary,\n.block-button {\n background: var(--accent);\n border-color: var(--accent);\n color: #ffffff;\n}\n\n.block-columns {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 16px;\n}\n\n.block-column {\n padding: 18px;\n border: 1px solid var(--line);\n border-radius: 18px;\n background: linear-gradient(180deg, #ffffff, #fafbff);\n}\n\n.block-column strong {\n display: block;\n margin-bottom: 8px;\n font-size: 14px;\n}\n\n.block-media {\n display: grid;\n grid-template-columns: 220px minmax(0, 1fr);\n gap: 18px;\n align-items: center;\n}\n\n.block-image {\n height: 180px;\n border: 1px dashed var(--line-strong);\n border-radius: 20px;\n background:\n linear-gradient(135deg, rgba(92, 92, 255, 0.1), rgba(92, 92, 255, 0.02)),\n #f7f8ff;\n}\n\n.block-pricing {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 14px;\n}\n\n.block-price-card {\n padding: 18px;\n border: 1px solid var(--line);\n border-radius: 18px;\n background: #ffffff;\n}\n\n.block-price-card strong {\n display: block;\n margin: 6px 0;\n font-size: 14px;\n line-height: 1;\n}\n\n.block-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 18px;\n}\n\n.block-footer__links {\n display: flex;\n gap: 16px;\n color: var(--muted);\n font-size: 14px;\n}\n\n.block-heading {\n font-size: 14px;\n line-height: 1.15;\n}\n\n.block-paragraph {\n margin: 0;\n font-size: 14px;\n line-height: 1.7;\n}\n\n.block-label {\n display: inline-flex;\n align-items: center;\n min-height: 30px;\n padding: 0 12px;\n border-radius: 999px;\n background: var(--accent-ghost);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n}\n\n.block-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 140px;\n min-height: 44px;\n padding: 0 18px;\n border-radius: 14px;\n font-size: 14px;\n font-weight: 600;\n}\n\n.block-divider {\n height: 1px;\n background: linear-gradient(90deg, transparent, var(--line-strong), transparent);\n}\n\n.block-spacer {\n height: 72px;\n border: 1px dashed var(--line-strong);\n border-radius: 16px;\n background: linear-gradient(180deg, rgba(92, 92, 255, 0.05), rgba(92, 92, 255, 0.01));\n}\n\n.block-container {\n min-height: 180px;\n padding: 20px;\n border: 1px dashed var(--line-strong);\n border-radius: 22px;\n background: #fbfbff;\n}\n\n.block-container strong {\n display: block;\n margin-bottom: 8px;\n}\n\n.block-table {\n display: grid;\n gap: 10px;\n overflow: hidden;\n}\n\n.cs_block_s[data=\"Table\"] {\n overflow: hidden;\n}\n\n.block-table__row {\n display: grid;\n grid-template-columns: 1.2fr 0.8fr 0.8fr;\n gap: 10px;\n}\n\n.block-table__row span,\n.block-list li,\n.block-input {\n min-height: 42px;\n display: flex;\n align-items: center;\n padding: 0 14px;\n border: 1px solid var(--line);\n border-radius: 14px;\n background: #ffffff;\n font-size: 14px;\n}\n\n.block-list {\n margin: 0;\n padding: 0;\n list-style: none;\n display: grid;\n gap: 10px;\n}\n\n.block-shape {\n border-radius: 20px;\n background: linear-gradient(135deg, rgba(92, 92, 255, 0.16), rgba(92, 92, 255, 0.05));\n border: 1px solid rgba(92, 92, 255, 0.26);\n}\n\n.block-shape--rectangle {\n width: 100%;\n height: 160px;\n}\n\n.block-shape--circle {\n width: 180px;\n height: 180px;\n border-radius: 999px;\n}\n\n.block-icon-badge {\n width: 88px;\n height: 88px;\n display: grid;\n place-items: center;\n border-radius: 24px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 14px;\n font-weight: 700;\n}\n\n\n.cs_block_s {\n overflow-wrap: break-word;\n /* padding: 5px; */\n margin: 0;\n height: max-content;\n width: 250px;\n max-width: 100%;\n position: relative;\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n border: 1px solid #e1e1e1;\n}\n\n\n\n.edit_me:empty:before {\n content: attr(placeholder);\n /*if affected some place please unhide this below command*/\n color: #AAA;\n font-family: sans-serif;\n line-height: 1.1;\n}\n\n.section-binding-info {\n display: none;\n}\n\n/* ============================================================\n FLOW CANVAS — row / column layout (Word-style)\n Variables are populated by canvas-config.js — tune that file\n to change page dimensions, paddings, colors, etc.\n ============================================================ */\n\n.custom-form-design.cs-flow-canvas {\n padding: 0;\n overflow: visible;\n background: transparent;\n height: auto;\n flex: 0 0 auto;\n}\n\n.cs_paper {\n /* Multi-page container — holds one or more .cs_margin pages stacked\n vertically. Each page is an A4 sheet (794 × 1123 px). */\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 28px;\n width: 100%;\n}\n\n.cs_margin {\n width: var(--cs-page-width, 794px);\n max-width: 100%;\n min-height: var(--cs-page-min-height, 1123px);\n margin: 0 auto;\n /* padding: var(--cs-page-padding, 16px); */\n background: var(--cs-page-bg, #ffffff);\n background-image: var(--cs-page-bg-image, none);\n background-size: cover;\n background-position: center;\n background-repeat: no-repeat;\n position: relative;\n box-sizing: border-box;\n border: 1px solid #e1e1e1;\n display: flex;\n flex-direction: column;\n flex: 0 0 auto;\n}\n\n/* Page background shape layer (.cs-page-shape-bg, injected by the Page Shape\n designer): z-index:0 keeps it above the page background; page content is\n lifted to z-index:1 so it always paints on top. This pure-z-index approach\n (no negative z / isolation) renders consistently in wkhtmltopdf + puppeteer. */\n.cs_margin>.cs-page-shape-bg,\n.cs-cover-canvas>.cs-page-shape-bg {\n z-index: 0;\n}\n\n/* Smart alignment guides shown while dragging / resizing a free-move block\n (cover or section). Editor-only chrome (data-cs-chrome → stripped on export). */\n.cs-align-guides {\n position: absolute;\n inset: 0;\n pointer-events: none;\n z-index: 60;\n}\n\n.cs-align-guide {\n position: absolute;\n background: #f43f7e;\n}\n\n.cs-align-guide--v {\n top: 0;\n bottom: 0;\n width: 1px;\n}\n\n.cs-align-guide--h {\n left: 0;\n right: 0;\n height: 1px;\n}\n\n/* ============================================================\n Distance measurement overlay (measure-distance.js)\n Figma-style gap measurement: select a free block, hold Ctrl/⌘,\n then hover another block. Editor-only chrome — transient, never\n exported. All accents use the brand teal #248567.\n ============================================================ */\n.cs-measure {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n animation: cs-measure-fade .14s ease-out both;\n}\n\n.cs-measure__svg {\n position: absolute;\n top: 0;\n left: 0;\n overflow: visible;\n pointer-events: none;\n}\n\n/* the two block outlines: source solid + glow/pulse, target dashed marching-ants */\n.cs-measure__box {\n fill: none;\n stroke: #248567;\n stroke-width: 1.5;\n animation: cs-measure-box .18s ease-out both;\n}\n\n.cs-measure__box--src {\n filter: drop-shadow(0 0 3px rgba(36, 133, 103, .55));\n animation: cs-measure-box .18s ease-out both, cs-measure-pulse 1.8s ease-in-out infinite .18s;\n}\n\n.cs-measure__box--tgt {\n stroke-dasharray: 5 4;\n animation: cs-measure-box .18s ease-out both, cs-measure-march 700ms linear infinite;\n}\n\n/* measurement lines + end caps + dotted extensions */\n.cs-measure__line {\n stroke: #248567;\n stroke-width: 1.5;\n stroke-linecap: round;\n stroke-dasharray: 1;\n stroke-dashoffset: 1;\n animation: cs-measure-draw .22s ease-out forwards;\n}\n\n.cs-measure__cap {\n stroke: #248567;\n stroke-width: 1.5;\n stroke-linecap: round;\n opacity: 0;\n animation: cs-measure-in .22s ease-out .06s forwards;\n}\n\n.cs-measure__ext {\n stroke: #248567;\n stroke-width: 1;\n stroke-dasharray: 2 3;\n opacity: 0;\n animation: cs-measure-in .22s ease-out .04s forwards;\n}\n\n/* value badges */\n.cs-measure__label {\n position: absolute;\n transform: translate(-50%, -50%);\n background: #248567;\n color: #fff;\n font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n letter-spacing: .2px;\n padding: 1px 6px;\n border-radius: 5px;\n white-space: nowrap;\n box-shadow: 0 2px 6px rgba(36, 133, 103, .45), 0 0 0 1px rgba(255, 255, 255, .18) inset;\n animation: cs-measure-pop .24s cubic-bezier(.34, 1.56, .64, 1) both;\n}\n\n/* hint chip while armed but not yet over a target */\n.cs-measure__hint {\n position: absolute;\n transform: translate(14px, 16px);\n background: #248567;\n color: #fff;\n font: 500 11px/1.4 system-ui, sans-serif;\n padding: 4px 9px;\n border-radius: 7px;\n white-space: nowrap;\n box-shadow: 0 4px 14px rgba(36, 133, 103, .4);\n animation: cs-measure-fade .14s ease-out both;\n}\n\n.cs-measure__hint b {\n font-weight: 700;\n}\n\n@keyframes cs-measure-fade {\n from {\n opacity: 0\n }\n\n to {\n opacity: 1\n }\n}\n\n@keyframes cs-measure-in {\n to {\n opacity: 1\n }\n}\n\n@keyframes cs-measure-draw {\n to {\n stroke-dashoffset: 0\n }\n}\n\n@keyframes cs-measure-box {\n from {\n opacity: 0\n }\n\n to {\n opacity: 1\n }\n}\n\n@keyframes cs-measure-pop {\n from {\n opacity: 0;\n transform: translate(-50%, -50%) scale(.55)\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, -50%) scale(1)\n }\n}\n\n@keyframes cs-measure-march {\n to {\n stroke-dashoffset: -18\n }\n}\n\n@keyframes cs-measure-pulse {\n\n 0%,\n 100% {\n filter: drop-shadow(0 0 2px rgba(36, 133, 103, .35))\n }\n\n 50% {\n filter: drop-shadow(0 0 6px rgba(36, 133, 103, .7))\n }\n}\n\n/* Active-page highlight (set by active-page.js on the in-view .cs_page).\n Uses box-shadow so it never shifts layout. cs_selected = cover page,\n cs_selected_border = content page. Stripped from exported markup. */\n.cs_page.cs_selected,\n.cs_page.cs_selected_border {\n box-shadow: 0 0 0 0px #248567, 0 6px 18px rgba(36, 133, 103, 0.18);\n}\n\n.cs_margin>.row-item,\n.cs_margin>.body-main-content,\n.cs_margin>.cs-page-header,\n.cs_margin>.cs-page-footer {\n position: relative;\n z-index: 1;\n}\n\n/* A4 boundary indicator — a real DOM element injected by JS into any\n .cs_margin whose content overflows past the configured A4 height. The\n line spans the full doc width, with a centered pill label that has\n a solid background so it's readable on top of the dashed line. */\n.cs-overflow-mark {\n position: absolute;\n left: 0;\n right: 0;\n top: var(--cs-page-min-height, 1123px);\n height: 0;\n border-top: 1px dashed #f97316;\n pointer-events: none;\n z-index: 5;\n}\n\n.cs-overflow-mark__label {\n position: absolute;\n left: 50%;\n top: 0;\n transform: translate(-50%, -50%);\n padding: 3px 12px;\n font-size: 14px;\n font-weight: 600;\n color: #c2410c;\n background: #ffffff;\n border: 1px solid #f97316;\n border-radius: 999px;\n white-space: nowrap;\n}\n\n/* Page number badge (top-right corner of each page) */\n.cs_margin::before {\n /* content: \"Page \" attr(data-page); */\n position: absolute;\n top: 8px;\n right: 12px;\n font-size: 10px;\n font-weight: 600;\n color: rgba(99, 102, 241, 0.55);\n letter-spacing: 0.05em;\n text-transform: uppercase;\n pointer-events: none;\n}\n\n.row-item {\n display: flex;\n gap: 0;\n /* margin-bottom: var(--row-item-margin-bottom, 8px); */\n position: relative;\n /* min-height: var(--row-item-min-height, 40px); */\n flex: 0 0 auto;\n /* padding: 5px; */\n}\n\n/* ============================================================\n WORD-STYLE PAGE HEADER / FOOTER\n Header is the first row (top of page), footer is the last row\n (bottom of page). `margin-top: auto` on the footer pushes it to\n the bottom of the flex column — empty space sits between the\n body and the footer.\n ============================================================ */\n.cs_margin>.cs-page-header,\n.cs_margin>.cs-page-footer {\n flex: 0 0 auto;\n position: relative;\n min-height: 70px;\n /* padding: 10px; */\n border: 1px dashed transparent;\n /* border-radius: 4px; */\n background: transparent;\n color: rgba(31, 31, 31, 0.42);\n transition: color 120ms ease, background 120ms ease, border-color 120ms ease;\n cursor: pointer;\n}\n\n.cs_margin>.cs-page-header {\n margin-top: 0;\n margin-bottom: 0px;\n border-bottom: 1px solid #e1e1e1;\n align-items: center;\n}\n\n.cs_margin>.cs-page-footer {\n margin-top: auto;\n align-items: center;\n /* push to bottom of the flex column */\n margin-bottom: 0;\n border-top: 1px solid #e1e1e1;\n}\n\n/* Placeholder hint shown when the region is empty (no nested blocks) */\n.cs-page-header>.col-item:empty::before,\n.cs-page-footer>.col-item:empty::before {\n content: attr(data-cs-placeholder);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n min-height: 50px;\n font-size: 14px;\n font-weight: 600;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: rgba(99, 102, 241, 0.55);\n pointer-events: none;\n}\n\n.cs-page-header>.col-item:empty,\n.cs-page-footer>.col-item:empty {\n position: relative;\n}\n\n/* Mirror the placeholder text from the row to its inner col so the\n :empty selector still has access to the attribute. */\n.cs-page-header>.col-item,\n.cs-page-footer>.col-item {\n background: transparent;\n}\n\n/* Hover hint: faint blue suggests \"click here\" */\n.cs_margin>.cs-page-header:not(.is-active):hover,\n.cs_margin>.cs-page-footer:not(.is-active):hover {\n background: rgba(99, 102, 241, 0.04);\n border-color: rgba(99, 102, 241, 0.3);\n}\n\n/* Active state — bright outline + corner label, drop-zone ready */\n.cs_margin>.cs-page-header.is-active,\n.cs_margin>.cs-page-footer.is-active {\n color: inherit;\n background: rgba(99, 102, 241, 0.06);\n border-color: rgba(99, 102, 241, 0.6);\n cursor: auto;\n}\n\n.cs_margin>.cs-page-header.is-active::before,\n.cs_margin>.cs-page-footer.is-active::before {\n content: attr(data-cs-region-label);\n position: absolute;\n top: -8px;\n left: 12px;\n font-size: 14px;\n font-weight: 700;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: #4338ca;\n background: #ffffff;\n padding: 0 6px;\n pointer-events: none;\n line-height: 1;\n z-index: 1;\n}\n\n/* When a region is active, dim the inactive areas */\n.cs_margin.editing-header>.row-item:not(.cs-page-header):not(.cs-page-footer),\n.cs_margin.editing-footer>.row-item:not(.cs-page-header):not(.cs-page-footer) {\n opacity: 0.5;\n}\n\n.cs_margin.editing-header>.cs-page-footer,\n.cs_margin.editing-footer>.cs-page-header {\n opacity: 0.5;\n}\n\n.col-item {\n flex: 1 1 0;\n min-width: var(--col-item-min-width, 60px);\n /* min-height: var(--col-item-min-height, 40px); */\n position: relative;\n display: flex;\n flex-direction: column;\n /* padding: var(--col-item-padding, 4px); */\n}\n\n/* Show placeholder only when column is truly empty (no block content) */\n.col-item:empty::before {\n content: 'Drop block here';\n display: block;\n color: var(--muted);\n font-size: 14px;\n text-align: center;\n padding: 12px;\n /* border: 1px dashed var(--line); */\n border-radius: 4px;\n pointer-events: none;\n}\n\n/* Hide placeholder if column has any block content */\n.col-item:has(> .cs_block_s)::before,\n.col-item:has(> .canvas-block)::before {\n content: '';\n display: none;\n}\n\n/* Column divider (resize handle between columns) — a 10px-wide draggable\n strip with a visible 1px line in the middle. The whole strip is the hit\n area so it's easy to grab. */\n.cs-line-divider {\n flex: 0 0 10px;\n background: transparent;\n cursor: col-resize;\n position: relative;\n align-self: stretch;\n transition: background 120ms ease;\n z-index: 5;\n}\n\n.cs-line-divider::before {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n width: 1px;\n background: transparent;\n transform: translateX(-50%);\n pointer-events: none;\n}\n\n.row-item:hover .cs-line-divider::before {\n background: rgba(92, 92, 255, 0.25);\n}\n\n.cs-line-divider:hover,\n.cs-line-divider.cs-line-divider--active {\n background: rgba(92, 92, 255, 0.12);\n}\n\n.cs-line-divider:hover::before,\n.cs-line-divider.cs-line-divider--active::before {\n background: var(--accent);\n width: 2px;\n}\n\n/* Premium Drop Indicators & Animations */\n@keyframes csDropPulse {\n 0% {\n box-shadow: 0 0 0 0 rgba(36, 133, 103, 0.6);\n opacity: 0.85;\n }\n\n 50% {\n box-shadow: 0 0 0 8px rgba(36, 133, 103, 0.1);\n opacity: 1;\n }\n\n 100% {\n box-shadow: 0 0 0 0 rgba(36, 133, 103, 0.6);\n opacity: 0.85;\n }\n}\n\n@keyframes csBgPan {\n to {\n background-position: 200% center;\n }\n}\n\n@keyframes scaleInDrop {\n 0% {\n transform: scale(0.92);\n opacity: 0;\n }\n\n 100% {\n transform: scale(1);\n opacity: 1;\n }\n}\n\n.cs-drop-indicator {\n position: absolute;\n background: #248567;\n background-size: 200% auto;\n border-radius: 4px;\n pointer-events: none;\n z-index: 9999;\n box-shadow: 0 0 8px rgba(92, 92, 255, 0.5);\n animation: csDropPulse 1.5s infinite ease-in-out, csBgPan 2s linear infinite;\n /* Smoothly animate movement as the user drags across slots */\n transition: top 0.15s cubic-bezier(0.2, 0, 0, 1), left 0.15s cubic-bezier(0.2, 0, 0, 1), width 0.15s cubic-bezier(0.2, 0, 0, 1), height 0.15s cubic-bezier(0.2, 0, 0, 1);\n}\n\n.cs-drop-indicator--horizontal {\n height: 2px !important;\n margin-top: -1px;\n}\n\n.cs-drop-indicator--vertical {\n width: 4px !important;\n margin-left: -1px;\n}\n\n/* ============================================================\n Inline hover insert (+) control\n ============================================================ */\n.cs-inline-insert-line {\n position: fixed;\n height: 2px;\n transform: translateY(-50%);\n background: rgba(36, 133, 103, 0.18);\n box-shadow: 0 0 0 1px rgba(36, 133, 103, 0.04);\n pointer-events: none;\n z-index: 9996;\n opacity: 0;\n transition: opacity 120ms ease;\n}\n\n.cs-inline-insert-line.is-visible {\n opacity: 1;\n}\n\n/* New-column variant: vertical line on a block's left/right edge. */\n.cs-inline-insert-line--vertical {\n height: auto;\n width: 2px;\n transform: translateX(-50%);\n}\n\n.cs-inline-insert-line.is-active {\n background: rgba(36, 133, 103, 0.9);\n box-shadow: 0 0 0 1px rgba(36, 133, 103, 0.08);\n}\n\n.cs-inline-insert {\n position: fixed;\n width: 25px;\n height: 25px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n transform: translate(0%, -50%);\n border: 1px solid #dfe5df;\n border-radius: 999px;\n background: #f7fbf8;\n color: #b7c1b8;\n font-size: 20px;\n line-height: 1;\n cursor: pointer;\n box-shadow: 0 4px 10px rgba(20, 24, 60, 0.05);\n z-index: 9997;\n opacity: 0;\n pointer-events: none;\n transition: opacity 120ms ease, transform 120ms ease, background 120ms ease, color 120ms ease, border-color 120ms ease;\n}\n\n.cs-inline-insert span {\n transform: translateY(-1px);\n pointer-events: none;\n}\n\n/* New-column variant: centre the + on the vertical line's top point. */\n.cs-inline-insert--vertical {\n transform: translate(-50%, -50%);\n}\n\n.cs-inline-insert.is-visible {\n opacity: 1;\n pointer-events: auto;\n}\n\n.cs-inline-insert:hover,\n.cs-inline-insert.is-open {\n background: #248567;\n border-color: #248567;\n color: #ffffff;\n}\n\n\n.cs-inline-insert::after {\n content: attr(title);\n position: absolute;\n left: 181%;\n top: -26px;\n transform: translateX(-50%);\n padding: 6px 10px;\n border-radius: 4px;\n background: rgba(33, 33, 33, 0.92);\n color: #ffffff;\n font-size: 12px;\n font-weight: 500;\n white-space: nowrap;\n opacity: 0;\n pointer-events: none;\n transition: opacity 120ms ease;\n}\n\n.cs-inline-insert:hover::after,\n.cs-inline-insert.is-open::after {\n opacity: 1;\n}\n\n.cs-inline-insert-menu {\n position: fixed;\n width: 270px;\n max-height: min(420px, calc(100vh - 24px));\n overflow: auto;\n padding: 8px 0;\n border: 1px solid #e2e6f0;\n border-radius: 10px;\n background: #ffffff;\n box-shadow: 0 18px 40px rgba(20, 24, 60, 0.16);\n z-index: 9998;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-4px);\n transition: opacity 140ms ease, transform 140ms ease;\n}\n\n.cs-inline-insert-menu.is-open {\n opacity: 1;\n pointer-events: auto;\n transform: translateY(0);\n}\n\n.cs-inline-insert-menu__section+.cs-inline-insert-menu__section {\n margin-top: 8px;\n padding-top: 8px;\n border-top: 1px solid #eef1f6;\n}\n\n.cs-inline-insert-menu__title {\n padding: 6px 14px 8px;\n color: #7b8198;\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.cs-inline-insert-menu__item {\n width: 100%;\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 14px;\n border: 0;\n background: transparent;\n text-align: left;\n color: #243047;\n cursor: pointer;\n transition: background 120ms ease, color 120ms ease;\n}\n\n.cs-inline-insert-menu__item:hover {\n background: #f3f8f6;\n color: #248567;\n}\n\n.cs-inline-insert-menu__icon {\n width: 22px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: #8b93a7;\n font-size: 14px;\n flex: 0 0 22px;\n}\n\n.cs-inline-insert-menu__item:hover .cs-inline-insert-menu__icon {\n color: #248567;\n}\n\n.cs-inline-insert-menu__label {\n font-size: 14px;\n font-weight: 500;\n}\n\n\n.cs-inline-insert-menu {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n padding: 14px 12px 24px;\n\n /* Invisible scrollbar */\n &::-webkit-scrollbar {\n width: 0;\n background: transparent;\n }\n\n &::-webkit-scrollbar-track {\n background: transparent;\n }\n\n &::-webkit-scrollbar-thumb {\n background: transparent;\n }\n\n /* Firefox */\n scrollbar-width: none;\n -ms-overflow-style: none;\n}\n\n/* Add a premium pop animation when a new block is dropped on canvas */\n.custom-form-design>.cs_paper .cs_block_s,\n.cs_margin .cs_block_s {\n animation: scaleInDrop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);\n}\n\n/* Top-level blocks inside flow canvas (direct children of col-item) — strip\n absolute positioning. In-section blocks keep absolute positioning.\n Width is auto by default (block elements fill their column); inline width\n set via resize wins because we don't !important it. */\n.cs-flow-canvas .col-item>.cs_block_s,\n.cs-flow-canvas .col-item>.canvas-block {\n position: relative !important;\n top: auto !important;\n left: auto !important;\n max-width: none !important;\n /* TODO : multiple col little gap reduced not sure incase we need gap please uncomments the below margin code */\n /* margin: 0 0 6px 0; */\n display: block;\n box-sizing: border-box;\n padding: 2px;\n}\n\n.cs-flow-canvas .col-item>.cs_block_s:last-child,\n.cs-flow-canvas .col-item>.canvas-block:last-child {\n margin-bottom: 0;\n}\n\n/* Section content area is a nested row/col flow region — its height is\n driven entirely by the rows it contains, so a table that grows from a\n {% for %} loop naturally stretches the section box too. */\n.cs-flow-canvas .section-container-content {\n position: relative;\n /* min-height: var(--cs-section-min-height, 160px); */\n /* background: var(--cs-section-bg, #e5e7e7); */\n display: flex;\n flex-direction: column;\n /* padding: 8px; */\n /* gap: 4px; */\n /* outline: 1px solid #e1e1e1; */\n height: auto !important;\n}\n\n/* The section's outer block wrapper must not lock to a fixed height —\n the rendered output (and PDF) needs to grow with the {% for %} body. */\n.cs-flow-canvas .cs_block_s:has(> .section-container-content) {\n height: auto !important;\n min-height: 0 !important;\n}\n\n/* Rows inside a section behave like rows in the doc root. */\n.cs-flow-canvas .section-container-content>.row-item {\n flex: 0 0 auto;\n}\n\n/* Flexible block: free positioning canvas for child blocks */\n.cs-flow-canvas .cs-flexible-content {\n position: relative;\n display: block !important;\n /* min-height: 80px;\n padding: 8px; */\n /* outline: 1px dashed #cfd4f6; */\n /* background: #fafbfe; */\n}\n\n/* Cover page: the .cs_page itself IS the free-move canvas sheet. No .cs_margin\n and no .cs-flexible-content wrapper — blocks are absolutely-positioned DIRECT\n children. Give it A4 page dimensions and make it the positioning context. */\n.cs-cover-canvas.cs_page {\n width: var(--cs-page-width, 794px);\n min-height: var(--cs-page-min-height, 1123px);\n height: auto;\n margin: 24px auto;\n background: var(--cs-page-bg, #ffffff);\n border: 1px solid #e1e1e1;\n position: relative;\n box-sizing: border-box;\n}\n\n.cs-cover-canvas>.cs_block_s[data-cs-in-section=\"1\"] {\n position: absolute !important;\n display: block !important;\n margin: 0 !important;\n z-index: 1;\n}\n\n/* Blocks inside flexible use absolute positioning for free movement */\n.cs-flow-canvas .cs-flexible-content>.cs_block_s[data-cs-in-section=\"1\"] {\n position: absolute !important;\n display: block !important;\n margin: 0 !important;\n top: 8px;\n left: 8px;\n}\n\n/* Drop surface highlight while dragging from sidebar */\n.cs-flow-canvas.drop-surface--active .col-item:empty::before {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* ============================================================\n BLOCK WIDTH NORMALIZATION (flow contexts only)\n In a row/column flow canvas, blocks fill their column width.\n In absolute canvas mode the user controls width via drag-resize,\n so we don't force anything there.\n ============================================================ */\n.cs_margin>.cs_block_s,\n.cs_margin>.canvas-block,\n.col-item>.cs_block_s,\n.col-item>.canvas-block {\n width: 100% !important;\n max-width: 100% !important;\n box-sizing: border-box;\n}\n\n\n/* ============================================================\n Block reorder — grip handle + dragging state\n ============================================================ */\n.cs-block-grip {\n position: absolute;\n top: 50%;\n left: -28px;\n transform: translateY(-50%);\n width: 22px;\n height: 26px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: #ffffff;\n border: 1px solid #d8dbef;\n border-radius: 4px;\n color: #8a90b8;\n cursor: grab;\n opacity: 0;\n transition: opacity 120ms ease, background 120ms ease, color 120ms ease, border-color 120ms ease;\n user-select: none;\n z-index: 30;\n line-height: 1;\n touch-action: none;\n box-shadow: 0 1px 2px rgba(20, 24, 60, 0.05);\n}\n\n.cs-block-grip svg {\n display: block;\n pointer-events: none;\n}\n\n/* Show the grip when the block (or grip itself) is hovered */\n.cs-flow-canvas .col-item>.cs_block_s:hover>.cs-block-grip,\n.cs-flow-canvas .col-item>.canvas-block:hover>.cs-block-grip,\n.cs-block-grip:hover {\n opacity: 1;\n}\n\n.cs-block-grip:hover {\n background: #248567;\n border-color: #248567;\n color: #ffffff;\n}\n\n.cs-block-grip:active {\n cursor: grabbing;\n background: #5c5cff;\n color: #ffffff;\n}\n\n/* The host block needs position:relative so the grip's absolute placement\n anchors correctly. Top-level flow blocks already have position:relative\n from the normalize step. */\n.cs-flow-canvas .col-item>.cs_block_s,\n.cs-flow-canvas .col-item>.canvas-block {\n position: relative;\n}\n\n/* During drag, fade out block content but keep grip icon visible for better UX */\n.cs-flow-canvas .cs-block--dragging {\n opacity: 0.15;\n pointer-events: none;\n z-index: 100;\n}\n\n/* Keep grip icon fully visible during drag */\n.cs-flow-canvas .cs-block--dragging .cs-block-grip {\n opacity: 1;\n}\n\n/* Field panel UI lives in the parent Angular app — see app.scss. */\n\n\n\n.body-main-content {\n /* padding: 16px; */\n}\n\n@media print {\n .cs_margin {\n height: var(--cs-page-min-height, 1123px) !important;\n }\n\n .cs-page-number {\n display: none !important;\n }\n\n /* Disable all animations during PDF generation */\n * {\n animation: none !important;\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition: none !important;\n transition-duration: 0s !important;\n }\n\n .cs-drop-indicator,\n .cs_block_s,\n .canvas-block {\n animation: none !important;\n }\n}\n\n/* Dimension indicator — shows block dimensions during resize */\n@keyframes cs-dimension-fade-in {\n from {\n opacity: 0;\n transform: translate(-50%, -8px) scale(0.95);\n }\n\n to {\n opacity: 1;\n transform: translate(-50%, 0) scale(1);\n }\n}\n\n@keyframes cs-dimension-fade-out {\n from {\n opacity: 1;\n transform: translate(-50%, 0) scale(1);\n }\n\n to {\n opacity: 0;\n transform: translate(-50%, -8px) scale(0.95);\n }\n}\n\n.cs-dimension-indicator {\n position: fixed;\n pointer-events: none;\n z-index: 9999;\n opacity: 0;\n}\n\n.cs-dimension-indicator--visible {\n animation: cs-dimension-fade-in 200ms ease forwards;\n}\n\n.cs-dimension-indicator--visible:not(.cs-dimension-indicator--hide) {\n animation: cs-dimension-fade-in 200ms ease forwards;\n}\n\n.cs-dimension-indicator__text {\n display: inline-block;\n padding: 8px 14px;\n background: rgba(0, 0, 0, 0.9);\n color: #ffffff;\n font-size: 13px;\n font-weight: 500;\n border-radius: 6px;\n white-space: nowrap;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);\n letter-spacing: 0.3px;\n}\n</style>\n <link rel=\"stylesheet\"\n href=\"https://cdnjs.cloudflare.com/ajax/libs/froala-editor/4.3.1/css/froala_editor.pkgd.min.css\" />\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\" />\n <style data-src=\"./editor/editor.css\">\n/* ============================================================\n Block state machine: idle → selected → editing\n ------------------------------------------------------------\n - idle: no chrome, block is just placed\n - selected: blue outline + top badge (move handle + menu)\n - editing: blue outline + 8 resize handles + Froala active\n ============================================================ */\n\n.cs_block_s {\n cursor: pointer;\n /* No visible border at rest — only hover / selected / editing reveal it by\n swapping border-color below. `transparent` (not `none`) keeps the 1px so\n hovering never shifts layout. Overrides the solid #e1e1e1 base in\n custom-form.css (editor.css loads last). */\n border: 1px solid transparent;\n transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);\n}\n\n.cs_block_s:hover:not(.cs-selected):not(.cs-editing) {\n border-color: #76767629;\n /* box-shadow: 0 4px 16px rgba(92, 92, 255, 0.12);\n transform: translateY(-1px); */\n}\n\n/* ---------- Selected state ---------- */\n.cs_block_s.cs-selected,\n.cs_block_s.cs-editing {\n border-color: #248567;\n box-shadow: 0 0 0 1px rgba(92, 92, 255, 0.2);\n outline: none;\n}\n\n@keyframes slideUpBadge {\n 0% {\n transform: translateY(8px);\n opacity: 0;\n }\n\n 100% {\n transform: translateY(0);\n opacity: 1;\n }\n}\n\n/* The blue badge at the top-left of a selected/editing block */\n.cs-block-badge {\n position: absolute;\n top: -26px;\n left: -1px;\n height: 24px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 0 8px;\n background: #248567;\n color: #ffffff;\n font-family: Inter, \"Segoe UI\", sans-serif;\n font-size: 12px;\n font-weight: 600;\n border-radius: 6px 6px 0 0;\n user-select: none;\n z-index: 10;\n white-space: nowrap;\n cursor: grab;\n touch-action: none;\n animation: slideUpBadge 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;\n}\n\n.cs-block-badge__handle {\n cursor: grab;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n font-size: 12px;\n line-height: 1;\n}\n\n.cs-block-badge__handle:active {\n cursor: grabbing;\n}\n\n.cs-block-badge__label {\n pointer-events: none;\n}\n\n/* Renaming the block (Ctrl+R): the label becomes an inline text field. Override\n the badge's user-select:none / label's pointer-events:none so the caret and\n text selection work. */\n.cs-block-badge__label--editing {\n pointer-events: auto;\n user-select: text;\n cursor: text;\n outline: none;\n min-width: 12px;\n padding: 0 4px;\n border-radius: 2px;\n background: #ffffff;\n color: #1f2937;\n caret-color: #1f2937;\n}\n\n/* Live X/Y (move) or W/H (resize) readout shown in place of the title while\n dragging/resizing a free-move block. Blue HUD so it reads as a measurement. */\n.cs-block-badge__label--metric {\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.02em;\n}\n\n.cs-block-badge:has(.cs-block-badge__label--metric) {\n background: #248567;\n}\n\n/* While the live readout is active (dragging/resizing), show ONLY the X/Y\n (or W/H) value — hide the move handle, duplicate, delete and menu. */\n.cs-block-badge:has(.cs-block-badge__label--metric) .cs-block-badge__handle,\n.cs-block-badge:has(.cs-block-badge__label--metric) .cs-block-badge__actions,\n.cs-block-badge:has(.cs-block-badge__label--metric) .cs-block-badge__menu {\n display: none;\n}\n\n/* ============================================================\n Cover-page grouping: marquee, multi-select, group, toolbar\n ============================================================ */\n\n/* Rubber-band marquee rectangle */\n.cs-marquee {\n pointer-events: none;\n background: rgba(92, 92, 255, 0.10);\n border: 1px solid rgba(92, 92, 255, 0.55);\n border-radius: 2px;\n}\n\n/* Dotted bounding box around the whole multi-selection (shown before grouping) */\n.cs-group-bounds {\n pointer-events: none;\n border: 1px dashed #20233d;\n background: transparent;\n}\n\n/* Blocks caught by the marquee (multi-selected, pre-group) */\n.cs-multi-selected {\n outline: 1px solid #248567;\n outline-offset: 1px;\n box-shadow: 0 0 0 1px rgba(92, 92, 255, 0.25);\n}\n\n/* A group container on the cover page */\n.cs-group-block {\n box-sizing: border-box;\n}\n\n.cs-group-block.cs-selected,\n.cs-group-block.cs-editing {\n outline: 1.5px solid #5c5cff;\n outline-offset: 0;\n}\n\n/* Children of a group are absolutely positioned within the group box */\n.cs-group-block>.cs_block_s[data-cs-in-section=\"1\"] {\n position: absolute !important;\n display: block !important;\n margin: 0 !important;\n}\n\n/* Floating Group / Ungroup button */\n.cs-group-toolbar {\n align-items: center;\n gap: 4px;\n padding: 2px;\n background: #1b2030;\n /* border: 1px solid rgba(255, 255, 255, 0.12); */\n /* border-radius: 9px; */\n /* box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4); */\n}\n\n/* Align / distribute icon buttons inside the multi-select toolbar. */\n.cs-group-toolbar__ico {\n width: 28px;\n height: 28px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #e5e7f0;\n cursor: pointer;\n padding: 0;\n}\n\n.cs-group-toolbar__ico:hover {\n background: rgba(92, 92, 255, 0.3);\n}\n\n.cs-group-toolbar__ico svg {\n width: 16px;\n height: 16px;\n}\n\n.cs-group-toolbar__ico svg rect {\n fill: currentColor;\n}\n\n.cs-group-toolbar__ico svg line {\n stroke: currentColor;\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n\n.cs-group-toolbar__sep {\n width: 1px;\n align-self: stretch;\n margin: 2px 3px;\n background: rgba(255, 255, 255, 0.14);\n}\n\n.cs-group-toolbar__btn {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n height: 28px;\n padding: 0 12px;\n border: none;\n border-radius: 6px;\n background: #5c5cff;\n color: #ffffff;\n font-family: Inter, \"Segoe UI\", sans-serif;\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n box-shadow: 0 6px 16px rgba(92, 92, 255, 0.35);\n transition: background 120ms ease;\n}\n\n.cs-group-toolbar__btn:hover {\n background: #4a4ae6;\n}\n\n.cs-block-badge__menu {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n margin-left: 4px;\n border-radius: 3px;\n background: rgba(255, 255, 255, 0.18);\n font-size: 14px;\n line-height: 1;\n letter-spacing: 1px;\n}\n\n.cs-block-badge__menu:hover {\n background: rgba(255, 255, 255, 0.32);\n}\n\n/* Action buttons (move up/down, duplicate, delete) on the right of the badge */\n.cs-block-badge__actions {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n margin-left: 4px;\n padding-left: 6px;\n border-left: 1px solid rgba(255, 255, 255, 0.28);\n}\n\n.cs-block-badge__btn {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 3px;\n background: rgba(255, 255, 255, 0.18);\n color: #ffffff;\n font-size: 10px;\n line-height: 1;\n transition: background 120ms ease;\n}\n\n.cs-block-badge__btn:hover {\n background: rgba(255, 255, 255, 0.34);\n}\n\n.cs-block-badge__btn--danger:hover {\n background: #e5484d;\n}\n\n/* In editing mode the move handle + actions disappear (matches screenshot 2) */\n.cs_block_s.cs-editing .cs-block-badge__handle,\n.cs_block_s.cs-editing .cs-block-badge__actions,\n.cs_block_s.cs-editing .cs-block-badge__menu {\n display: none;\n}\n\n/* While editing, suppress the hover border (cleaner UI) */\n.cs_block_s.cs-editing {\n cursor: text;\n}\n\n@keyframes popResizeHandle {\n 0% {\n transform: scale(0);\n opacity: 0;\n }\n\n 100% {\n transform: scale(1);\n opacity: 1;\n }\n}\n\n/* ---------- Resize handles (editing state) ---------- */\n.cs-resize-handle {\n position: absolute;\n width: 10px;\n height: 10px;\n background: #ffffff;\n border: 1.5px solid #5c5cff;\n border-radius: 50%;\n /* Make them circular for a modern look */\n z-index: 9000;\n box-sizing: border-box;\n pointer-events: auto;\n box-shadow: 0 2px 4px rgba(92, 92, 255, 0.3);\n animation: popResizeHandle 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275) backwards;\n}\n\n/* Add slight delay staggering to handles for an amazing unrolling effect */\n.cs-resize-handle[data-dir=\"nw\"] {\n animation-delay: 0.00s;\n}\n\n.cs-resize-handle[data-dir=\"n\"] {\n animation-delay: 0.02s;\n}\n\n.cs-resize-handle[data-dir=\"ne\"] {\n animation-delay: 0.04s;\n}\n\n.cs-resize-handle[data-dir=\"e\"] {\n animation-delay: 0.06s;\n}\n\n.cs-resize-handle[data-dir=\"se\"] {\n animation-delay: 0.08s;\n}\n\n.cs-resize-handle[data-dir=\"s\"] {\n animation-delay: 0.10s;\n}\n\n.cs-resize-handle[data-dir=\"sw\"] {\n animation-delay: 0.12s;\n}\n\n.cs-resize-handle[data-dir=\"w\"] {\n animation-delay: 0.14s;\n}\n\n/* Make sure the parent block doesn't clip the handles that sit on its edge */\n.cs_block_s.cs-editing,\n.cs_block_s.cs-selected {\n overflow: visible !important;\n}\n\n.cs-resize-handle[data-dir=\"nw\"] {\n top: -6px;\n left: -6px;\n cursor: nwse-resize;\n}\n\n.cs-resize-handle[data-dir=\"n\"] {\n top: -6px;\n left: 50%;\n cursor: ns-resize;\n transform: translateX(-50%);\n}\n\n.cs-resize-handle[data-dir=\"ne\"] {\n top: -6px;\n right: -6px;\n cursor: nesw-resize;\n}\n\n.cs-resize-handle[data-dir=\"e\"] {\n top: 50%;\n right: -6px;\n cursor: ew-resize;\n transform: translateY(-50%);\n}\n\n.cs-resize-handle[data-dir=\"se\"] {\n bottom: -6px;\n right: -6px;\n cursor: nwse-resize;\n}\n\n.cs-resize-handle[data-dir=\"s\"] {\n bottom: -6px;\n left: 50%;\n cursor: ns-resize;\n transform: translateX(-50%);\n}\n\n.cs-resize-handle[data-dir=\"sw\"] {\n bottom: -6px;\n left: -6px;\n cursor: nesw-resize;\n}\n\n.cs-resize-handle[data-dir=\"w\"] {\n top: 50%;\n left: -6px;\n cursor: ew-resize;\n transform: translateY(-50%);\n}\n\n/* ---------- Editable content target ---------- */\n/* Force-override Froala's default min-height (200px) — otherwise the block\n inflates on edit-init, the resize handles end up far from where the user\n clicked, and bottom handles may even be clipped by the canvas overflow. */\n.cs_block_s.cs-editing .edit_me,\n.cs_block_s.cs-editing .fr-element,\n.cs_block_s.cs-editing .fr-view {\n cursor: text;\n outline: none;\n min-height: 1em !important;\n height: auto !important;\n overflow: visible !important;\n}\n\n.cs_block_s.cs-editing .fr-wrapper,\n.cs_block_s.cs-editing .fr-box {\n min-height: 0 !important;\n height: auto !important;\n}\n\n/* Froala wraps .edit_me in .fr-box; make sure the wrapper doesn't shrink */\n.cs_block_s .fr-box {\n width: 100%;\n display: block;\n}\n\n/* Keep the block tall enough for resize handles to be reachable */\n.cs_block_s.cs-editing {\n min-height: 27px;\n min-width: 60px;\n}\n\n/* Free-form blocks (flexible containers + their absolutely-positioned\n in-section children) are meant to be sized freely, so they may shrink\n below the default editing minimums. */\n.cs-flexible-content>.cs_block_s.cs-editing[data-cs-in-section=\"1\"],\n.cs_block_s.cs-flexible-block.cs-editing {\n min-width: 20px;\n min-height: 20px;\n}\n\n.edit_me {\n height: auto;\n padding: 0;\n margin: 0;\n overflow: hidden;\n color: #505b65;\n line-height: 1.5;\n width: 100%;\n word-break: break-word;\n overflow-wrap: break-word;\n text-align: left;\n}\n\n.edit_me:empty:before {\n content: attr(placeholder);\n color: #aaa;\n font-family: sans-serif;\n line-height: 1.1;\n}\n\n/* ---------- Froala tweaks ---------- */\n.fr-counter,\n.fr-word-count,\n.fr-word-counter,\n.fr-footer .fr-counter {\n display: none !important;\n}\n\n/* Keep Froala's popup toolbar above the resize handles */\n.fr-popup,\n.fr-toolbar {\n z-index: 9999 !important;\n}\n\n.cs_block_s p {\n margin: 0;\n}\n\n.normal-table-width table {\n width: 100%;\n}\n\n.section-container-content {\n height: 100% !important;\n /* background: #e5e7e7; */\n outline: none;\n min-height: 100px;\n}\n\n\n\n/* image block */\n\n\n@use './cs-mixin-style' as mixins;\n\n/* ============================== image block css ======================================= */\n.image-container {\n display: flex;\n flex-direction: column;\n align-items: center;\n overflow: hidden;\n margin: auto;\n aspect-ratio: 1;\n max-width: 100%;\n max-height: 100%;\n width: 100%;\n height: 100%;\n clip-path: unset !important;\n}\n\n.image-container img {\n max-width: 100%;\n max-height: 100%;\n width: 100%;\n height: 100%;\n object-fit: cover;\n border-radius: inherit;\n}\n\n/* ---- Image zoom / pan (active only while the block is in edit mode) ---- */\n/* Hint that the image can be scrolled to zoom while editing. */\n.cs-image-block.cs-editing .image-container img {\n user-select: none;\n -webkit-user-drag: none;\n}\n\n/* Once zoomed past 1x the image is draggable to reposition the crop. */\n.cs-image-block.cs-editing.cs-img-zoomed .image-container {\n cursor: grab;\n}\n\n.cs-image-block.cs-img-panning .image-container,\n.cs-image-block.cs-img-panning .image-container img {\n cursor: grabbing !important;\n}\n\n/* ============================== upload-image block css ======================================= */\n.img-btn {\n background-color: var(--upload-image-placeholder-bg);\n border: 1px solid var(--upload-image-placeholder-border);\n box-sizing: border-box;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n min-height: fit-content;\n max-height: 100%;\n width: 100%;\n height: 100%;\n padding: 10px;\n cursor: default;\n}\n\n.icon-group {\n width: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center\n}\n\n.icon-layer {\n padding: 0;\n margin-bottom: 10px;\n color: var(--upload-icon-text-bg);\n cursor: pointer;\n font-size: 14px;\n width: 24px;\n height: 24px;\n}\n\n.img-btn-txt {\n width: 100%;\n text-align: center;\n font-size: 14px;\n line-height: 19px;\n color: var(--Text-colors-Secondary);\n display: flex;\n align-items: center;\n flex-direction: row;\n justify-content: center;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n cursor: pointer;\n}\n\n.img-icon {\n background: var(--upload-plus-text-bg);\n border-radius: 6px;\n}\n\n.icon-layer,\n.img-btn-txt {\n cursor: pointer !important;\n position: relative;\n z-index: 10;\n pointer-events: auto !important;\n}\n\n.cs_block_s img:not(.pricing-section-image-section) {\n max-width: 100% !important;\n max-height: 100% !important;\n object-fit: contain;\n}\n\n.cs-image-block {\n display: flex;\n width: auto;\n height: auto;\n}\n\n.EHP .cs-image-block,\n.EHP .cs-video-block {\n position: relative;\n width: max-content;\n}\n\n.ECP .cs-image-block,\n.ECP .cs-video-block {\n position: relative;\n width: 100%;\n}\n\n.cs-image-block.block-selected,\n.cs-video-block.block-selected {\n .img-btn {\n cursor: move;\n\n .icon-layer,\n .img-btn-txt {\n cursor: pointer;\n }\n }\n}\n\n/* ============================== image block shapes css ======================================= */\n/* Frame shapes are applied to the CONTAINER only. The container is never\n * transformed, so its clip/round stays fixed while the <img> inside can still\n * be zoomed/panned (image-zoom.js) and gets clipped to the frame by the\n * container's `overflow: hidden`. clip-path needs !important to beat the base\n * `.image-container { clip-path: unset !important }` rule above. */\n.image-container.square-image {\n border-radius: 0;\n clip-path: none !important;\n}\n\n.image-container.rounded-square-image {\n border-radius: 16px;\n clip-path: none !important;\n}\n\n.image-container.circle-image {\n aspect-ratio: 1 !important;\n border-radius: 50%;\n clip-path: none !important;\n}\n\n.image-container.diagonal-corners-image {\n border-radius: 0;\n clip-path: polygon(20% 0%, 80% 0%, 100% 20%, 100% 80%, 80% 100%, 20% 100%, 0% 80%, 0% 20%) !important;\n}\n\n.image-container.star {\n border-radius: 0;\n clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%) !important;\n}\n\n.image-container.polygon {\n border-radius: 0;\n clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%) !important;\n}\n\n.image-container.rectangle-image {\n border-radius: 0;\n clip-path: none !important;\n}\n\n.image-container.rectangle-image {\n aspect-ratio: unset;\n}\n\n.ECP .image-container.rectangle-image {\n width: 100%;\n height: auto;\n}\n\n.ListBlankHorizontal .circle-image.image-container .img-btn {\n height: calc(100% - 10px);\n width: auto;\n}\n\n.image-container.circle-image .img-btn {\n min-width: unset;\n min-height: unset;\n}\n\n.cs_margin .circle-image {\n margin: auto;\n}\n\n/* ============================== icon-block css ======================================= */\n.cs-icon-block {\n padding: 8px;\n aspect-ratio: 1 / 1;\n min-width: 42px;\n width: auto;\n max-width: 100%;\n height: auto !important;\n container-type: inline-size;\n display: flex;\n place-items: center;\n align-items: center;\n justify-content: center;\n\n .icon-box {\n width: 100%;\n height: 100%;\n max-width: 100%;\n max-height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 1;\n\n i.icon-text {\n font-size: 80cqw;\n /* Adjust icon size based on container width (80cqw given bcz it will 80% of parent width, remainging will be around space) */\n height: 100%;\n width: 100%;\n max-width: 100%;\n max-height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n line-height: 1;\n }\n }\n}\n\n/* ============================== content-page-image-block css ======================================= */\n.cs_margin div.cs_block_s[data^=\"Image\"],\n.cs_margin div.cs_block_s[data^=\"Video\"] {\n text-align: center;\n user-select: none;\n}\n\n.cs_margin div.cs_block_s[data^=\"Image\"] .cs-image-cover {\n display: revert !important;\n}\n\n.cs_margin .drag-move-icon svg {\n margin: 0;\n}\n\n.cs_content_block {\n /* Default values */\n --rotate: 0deg;\n --zoom: 1;\n\n /* The magic line: combine them automatically */\n transform: rotate(var(--rotate)) scale(var(--zoom)) !important;\n transform-origin: center center;\n}\n\n.fr-view table td,\n.fr-view table th {\n padding: 4px;\n width: 185px;\n font-weight: 500;\n}\n\n.fr-view table td:empty,\n.fr-view table th:empty {\n height: 33px;\n}\n\n/* ============================ Pen Shape block ============================ */\n/* Default height — a CSS rule (not inline) so normalizeForFlow's inline-style\n strip on drop can't wipe it. A manual resize writes an inline height that\n overrides this, so resizing (smaller or larger) still works. */\n.cs-pen-shape-block {\n height: 200px;\n}\n\n.cs-pen-shape-block .cs-pen-shape {\n position: relative;\n width: 100%;\n height: 100%;\n min-height: 40px;\n box-sizing: border-box;\n}\n\n.cs-pen-shape-block .cs-pen-svg {\n display: block;\n width: 100%;\n height: 100%;\n /* Clip the shape to the block so a dragged anchor/curve can never spill\n outside the block's bounds. */\n overflow: hidden;\n}\n\n/* While editing the shape, hide only the 4 CORNER resize handles — they sit\n exactly on the corner anchors and make those anchors impossible to grab. The\n 4 SIDE handles (n/e/s/w) stay, so the box is still resizable from its edges\n without deselecting. The toolbar \"Resize box\" (⤢) toggle adds .cs-pen-resizing\n to bring ALL handles back (anchors hidden meanwhile) for full corner resize. */\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"nw\"],\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"ne\"],\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"se\"],\n.cs-pen-shape-block.cs-editing .cs-resize-handle[data-dir=\"sw\"] {\n display: none !important;\n}\n\n.cs-pen-shape-block.cs-editing.cs-pen-resizing .cs-resize-handle {\n display: block !important;\n}\n\n.cs-pen-shape-block.cs-pen-resizing .cs-pen-overlay {\n cursor: default;\n}\n\n/* Editing overlay — only present while the block is in edit mode. */\n.cs-pen-overlay {\n position: absolute;\n inset: 0;\n z-index: 20;\n cursor: crosshair;\n touch-action: none;\n}\n\n/* Space held → \"move whole clip-path\" mode: show the grab/hand cursor. */\n.cs-pen-overlay.cs-pen-pan {\n cursor: grab;\n}\n\n.cs-pen-overlay.cs-pen-pan:active {\n cursor: grabbing;\n}\n\n.cs-pen-overlay-svg {\n position: absolute;\n inset: 0;\n overflow: visible;\n pointer-events: none;\n}\n\n.cs-pen-anchor {\n fill: #fff;\n stroke: #5c5cff;\n stroke-width: 1.5;\n}\n\n.cs-pen-anchor.is-sel {\n fill: #5c5cff;\n}\n\n.cs-pen-anchor.is-first {\n stroke: #16a34a;\n stroke-width: 2;\n}\n\n/* Anchors of a NON-active sub-path — dimmed so the active clip-path stands out.\n Clicking such a shape's fill selects it (then its anchors become solid). */\n.cs-pen-anchor.is-dim {\n fill: rgba(255, 255, 255, 0.5);\n stroke: rgba(92, 92, 255, 0.45);\n}\n\n.cs-pen-handle {\n fill: #5c5cff;\n stroke: #fff;\n stroke-width: 1;\n}\n\n.cs-pen-handle-line {\n stroke: #5c5cff;\n stroke-width: 1;\n stroke-dasharray: 2 2;\n}\n\n.cs-pen-rubber {\n stroke: #9aa0ff;\n stroke-width: 1;\n stroke-dasharray: 4 3;\n}\n\n/* Pen-tool hover affordances on a finished shape: + (add a point on an edge),\n × (remove the point under the cursor). */\n.cs-pen-add {\n fill: rgba(36, 133, 103, 0.16);\n stroke: #248567;\n stroke-width: 1.5;\n}\n\n.cs-pen-add-mark {\n stroke: #248567;\n stroke-width: 2;\n stroke-linecap: round;\n}\n\n.cs-pen-remove {\n fill: rgba(225, 29, 72, 0.14);\n stroke: #e11d48;\n stroke-width: 1.5;\n}\n\n.cs-pen-remove-mark {\n stroke: #e11d48;\n stroke-width: 2;\n stroke-linecap: round;\n}\n\n/* Smart alignment guide — appears when a point lines up with another point's\n x or y (straight edges / equal heights / equal widths). */\n.cs-pen-guide {\n stroke: #f43f7e;\n stroke-width: 1;\n stroke-dasharray: 4 3;\n pointer-events: none;\n}\n\n/* Align / distribute toolbar (free-move block selection). */\n.cs-align-bar {\n position: fixed;\n z-index: 9996;\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 4px;\n background: #1b2030;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 8px;\n box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);\n transform: translateX(-50%);\n}\n\n.cs-align-bar button {\n width: 28px;\n height: 28px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #e5e7f0;\n cursor: pointer;\n padding: 0;\n}\n\n.cs-align-bar button:hover {\n background: rgba(92, 92, 255, 0.25);\n}\n\n.cs-align-bar svg {\n width: 16px;\n height: 16px;\n}\n\n.cs-align-bar svg rect {\n fill: currentColor;\n}\n\n.cs-align-bar svg line {\n stroke: currentColor;\n stroke-width: 1.5;\n stroke-linecap: round;\n}\n\n.cs-align-bar__sep {\n width: 1px;\n align-self: stretch;\n margin: 2px 3px;\n background: rgba(255, 255, 255, 0.14);\n}\n\n/* Right-click context menu. */\n.cs-ctx-menu {\n position: fixed;\n z-index: 9999;\n min-width: 184px;\n padding: 6px;\n background: #ffffff;\n border: 1px solid #e2e6f0;\n border-radius: 10px;\n box-shadow: 0 18px 40px rgba(20, 24, 60, 0.18);\n font-size: 13px;\n}\n\n.cs-ctx-menu__item {\n width: 100%;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 18px;\n padding: 8px 10px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: #243047;\n cursor: pointer;\n text-align: left;\n}\n\n.cs-ctx-menu__item:hover {\n background: #eef2ff;\n}\n\n.cs-ctx-menu__item.is-danger {\n color: #dc2626;\n}\n\n.cs-ctx-menu__item.is-danger:hover {\n background: #fef2f2;\n}\n\n.cs-ctx-menu__hint {\n color: #9aa0b4;\n font-size: 11px;\n}\n\n.cs-ctx-menu__sep {\n height: 1px;\n margin: 5px 6px;\n background: #eef1f6;\n}\n\n/* Keyboard-shortcuts help overlay. */\n.cs-shortcuts {\n position: fixed;\n inset: 0;\n z-index: 10000;\n display: grid;\n place-items: center;\n}\n\n.cs-shortcuts__backdrop {\n position: absolute;\n inset: 0;\n background: rgba(20, 24, 48, 0.55);\n backdrop-filter: blur(3px);\n}\n\n.cs-shortcuts__panel {\n position: relative;\n width: min(720px, calc(100vw - 32px));\n max-height: calc(100vh - 48px);\n overflow: auto;\n background: #ffffff;\n border-radius: 14px;\n box-shadow: 0 28px 64px rgba(14, 20, 48, 0.4);\n padding: 18px 20px 22px;\n}\n\n.cs-shortcuts__head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 16px;\n font-weight: 700;\n color: #1f2937;\n margin-bottom: 14px;\n}\n\n.cs-shortcuts__close {\n border: none;\n background: #f3f4f6;\n width: 30px;\n height: 30px;\n border-radius: 8px;\n cursor: pointer;\n color: #6b7280;\n font-size: 14px;\n}\n\n.cs-shortcuts__close:hover {\n background: #e5e7eb;\n}\n\n.cs-shortcuts__cols {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n gap: 18px 28px;\n}\n\n.cs-shortcuts__title {\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: #7b8198;\n margin-bottom: 8px;\n}\n\n.cs-shortcuts__row {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 4px 0;\n font-size: 13px;\n color: #374151;\n}\n\n.cs-shortcuts__row kbd {\n flex: 0 0 auto;\n min-width: 64px;\n text-align: center;\n font-family: inherit;\n font-size: 11px;\n font-weight: 600;\n color: #243047;\n background: #f3f4f6;\n border: 1px solid #e2e6f0;\n border-radius: 5px;\n padding: 3px 7px;\n}\n\n/* Floating pen toolbar */\n.cs-pen-toolbar {\n position: absolute;\n bottom: calc(100% + 6px);\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n width: max-content;\n max-width: none;\n gap: 2px;\n padding: 3px 6px;\n background: #23243d;\n border-radius: 8px;\n box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);\n z-index: 9100;\n cursor: default;\n white-space: nowrap;\n}\n\n.cs-pen-toolbar select {\n height: 24px;\n border: none;\n border-radius: 4px;\n background: #57586b;\n color: #fff;\n font-size: 11px;\n padding: 0 4px;\n cursor: pointer;\n}\n\n.cs-pen-fill-solid,\n.cs-pen-fill-gradient,\n.cs-pen-fill-image {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n\n.cs-pen-toolbar input[type=\"range\"] {\n width: 64px;\n}\n\n.cs-pen-toolbar button {\n width: 28px;\n height: 28px;\n border: none;\n border-radius: 5px;\n background: transparent;\n color: #e5e7f0;\n font-size: 15px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cs-pen-toolbar button:hover {\n background: #f48220;\n}\n\n.cs-pen-toolbar button.is-active {\n background: #248567;\n color: #fff;\n}\n\n.cs-pen-sep {\n width: 1px;\n height: 20px;\n background: #248567;\n margin: 0 3px;\n}\n\n.cs-pen-swatch,\n.cs-pen-num {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n color: #cbd0e0;\n font-size: 10px;\n padding: 0 2px;\n}\n\n.cs-pen-swatch input[type=\"color\"] {\n width: 22px;\n height: 22px;\n padding: 0;\n border: none;\n background: none;\n cursor: pointer;\n}\n\n.cs-pen-num input {\n width: 34px;\n height: 22px;\n border: none;\n border-radius: 4px;\n background: #353b4d;\n color: #fff;\n font-size: 11px;\n text-align: center;\n}\n\n/* Layers strip — one chip per shape, click to select. */\n.cs-pen-layers {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n max-width: 180px;\n overflow-x: auto;\n padding: 2px;\n}\n\n.cs-pen-layer {\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n border-radius: 4px;\n border: 1px solid rgba(255, 255, 255, 0.4);\n cursor: pointer;\n padding: 0;\n}\n\n.cs-pen-layer.is-active {\n border: 2px solid #fff;\n box-shadow: 0 0 0 1px #248567;\n}\n\n/* Gradient colour stops live inline next to the +/- buttons. */\n.cs-pen-grad-stops {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n\n/* ============================================================\n PAGE BACKGROUND SHAPE DESIGNER (full-screen modal)\n Reuses the pen tool (.cs-pen-*) on a page-sized stage.\n ============================================================ */\n.cs-page-shape-modal {\n position: fixed;\n inset: 0;\n z-index: 99999;\n display: flex;\n flex-direction: column;\n}\n\n.cs-page-shape-modal__backdrop {\n position: absolute;\n inset: 0;\n background: rgba(17, 24, 39, 0.78);\n backdrop-filter: blur(2px);\n}\n\n.cs-page-shape-modal__panel {\n position: relative;\n z-index: 1;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.cs-page-shape-modal__header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 10px 18px;\n background: #111827;\n color: #f9fafb;\n flex: 0 0 auto;\n}\n\n.cs-page-shape-modal__title {\n font-size: 14px;\n font-weight: 600;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.cs-page-shape-modal__dims {\n font-size: 11px;\n font-weight: 500;\n color: #9ca3af;\n background: #1f2937;\n padding: 3px 8px;\n border-radius: 999px;\n}\n\n.cs-page-shape-modal__pagepick {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n font-weight: 600;\n color: #9ca3af;\n margin-left: auto;\n margin-right: 16px;\n}\n\n.cs-page-shape-modal__pagepick select {\n background: #1f2937;\n color: #f9fafb;\n border: 1px solid #374151;\n border-radius: 6px;\n padding: 5px 10px;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-modal__actions {\n display: flex;\n gap: 8px;\n}\n\n.cs-page-shape-btn {\n border: none;\n border-radius: 6px;\n padding: 7px 14px;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-btn--ghost {\n background: #374151;\n color: #e5e7eb;\n}\n\n.cs-page-shape-btn--ghost:hover {\n background: #4b5563;\n}\n\n.cs-page-shape-btn--primary {\n background: #248567;\n color: #fff;\n}\n\n.cs-page-shape-btn--primary:hover {\n background: #1e6f57;\n}\n\n.cs-page-shape-modal__body {\n flex: 1 1 auto;\n display: flex;\n flex-direction: row;\n min-height: 0;\n}\n\n/* Left layers panel (Photoshop-style). */\n.cs-page-shape-layers {\n flex: 0 0 248px;\n display: flex;\n flex-direction: column;\n background: #1b2030;\n color: #e5e7f0;\n border-right: 1px solid rgba(255, 255, 255, 0.08);\n}\n\n.cs-page-shape-layers__title {\n padding: 12px 14px;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: #8aa4e6;\n border-bottom: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n.cs-page-shape-layers__list {\n flex: 1 1 auto;\n overflow-y: auto;\n padding: 8px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-width: 250px;\n}\n\n.cs-page-shape-layers__actions {\n display: flex;\n gap: 6px;\n padding: 8px;\n border-top: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n.cs-page-shape-layers__actions button {\n flex: 1 1 0;\n height: 30px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 6px;\n background: rgba(255, 255, 255, 0.05);\n color: #e5e7f0;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-layers__actions button:hover {\n background: rgba(92, 92, 255, 0.22);\n border-color: #5c5cff;\n}\n\n.cs-page-shape-layers__hint {\n padding: 8px 12px;\n font-size: 10px;\n color: #6b7280;\n border-top: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n/* One layer row */\n.cs-pen-layer-row {\n display: flex;\n align-items: center;\n gap: 4px;\n padding: 6px 7px;\n border-radius: 8px;\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid transparent;\n cursor: pointer;\n user-select: none;\n}\n\n.cs-pen-layer-row:hover {\n background: rgba(255, 255, 255, 0.07);\n}\n\n.cs-pen-layer-row.is-active {\n background: rgba(92, 92, 255, 0.18);\n border-color: #5c5cff;\n}\n\n.cs-pen-layer-row.is-multi {\n background: rgba(92, 92, 255, 0.12);\n border-color: rgba(92, 92, 255, 0.5);\n}\n\n.cs-pen-layer-row.is-hidden {\n opacity: 0.5;\n}\n\n.cs-pen-layer-row.is-locked .cs-pen-layer-row__name {\n opacity: 0.7;\n font-style: italic;\n}\n\n.cs-pen-layer-row.is-dragging {\n opacity: 0.4;\n}\n\n.cs-pen-layer-row.is-drop {\n border-color: #248567;\n box-shadow: 0 -2px 0 #248567 inset;\n}\n\n.cs-pen-layer-row__eye,\n.cs-pen-layer-row__act {\n flex: 0 0 auto;\n width: 20px;\n height: 20px;\n border: none;\n background: transparent;\n color: #cbd0e0;\n font-size: 12px;\n cursor: pointer;\n border-radius: 4px;\n line-height: 1;\n padding: 0;\n}\n\n.cs-pen-layer-row__eye:hover,\n.cs-pen-layer-row__act:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.cs-pen-layer-row__act:disabled {\n opacity: 0.3;\n cursor: default;\n}\n\n/* Keep rows compact: the action buttons (up/down/rename/dup/delete) only show\n on hover or for the active layer, so the name has room otherwise. */\n.cs-pen-layer-row__act {\n display: none;\n}\n\n.cs-pen-layer-row:hover .cs-pen-layer-row__act,\n.cs-pen-layer-row.is-active .cs-pen-layer-row__act {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n/* Checkerboard behind the thumbnail to show transparency, Photoshop-style. */\n.cs-pen-layer-row__thumb {\n flex: 0 0 auto;\n width: 34px;\n height: 34px;\n border-radius: 4px;\n border: 1px solid rgba(255, 255, 255, 0.18);\n overflow: hidden;\n background-color: #fff;\n background-image:\n linear-gradient(45deg, #cfd2dd 25%, transparent 25%),\n linear-gradient(-45deg, #cfd2dd 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, #cfd2dd 75%),\n linear-gradient(-45deg, transparent 75%, #cfd2dd 75%);\n background-size: 10px 10px;\n background-position: 0 0, 0 5px, 5px -5px, -5px 0;\n}\n\n.cs-pen-layer-thumb__svg {\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.cs-pen-layer-row__name {\n flex: 1 1 auto;\n font-size: 12px;\n color: #e5e7f0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.cs-pen-layer-row__rename {\n flex: 1 1 auto;\n min-width: 0;\n font-size: 12px;\n padding: 2px 6px;\n border: 1px solid #5c5cff;\n border-radius: 4px;\n background: #0f1320;\n color: #fff;\n}\n\n/* When the side panel is active, hide the toolbar's duplicate copy of shape\n management + the compact chip strip (they live in the panel now). */\n.cs-pen-toolbar.cs-pen-has-panel .cs-pen-layers,\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"dup\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"del-shape\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"fwd\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen=\"back\"],\n.cs-pen-toolbar.cs-pen-has-panel [data-pen^=\"preset-\"] {\n display: none;\n}\n\n/* Props container: inline inside the floating toolbar by default … */\n.cs-pen-props,\n.cs-pen-props__group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n\n.cs-pen-props__label {\n display: none;\n}\n\n/* … but a stacked, labelled section when relocated to the right panel. */\n.cs-pen-props.cs-pen-props--panel {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 12px;\n padding: 12px;\n}\n\n.cs-pen-props--panel .cs-pen-props__group {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n}\n\n.cs-pen-props--panel .cs-pen-props__label {\n display: block;\n width: 100%;\n font-size: 10px;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n color: #8aa4e6;\n}\n\n.cs-pen-props--panel select {\n height: 26px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n padding: 0 6px;\n cursor: pointer;\n}\n\n.cs-pen-props--panel button {\n min-width: 28px;\n height: 28px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: rgba(255, 255, 255, 0.05);\n color: #e5e7f0;\n font-size: 14px;\n cursor: pointer;\n}\n\n.cs-pen-props--panel button:hover {\n background: rgba(92, 92, 255, 0.22);\n border-color: #5c5cff;\n}\n\n.cs-pen-props--panel input[type=\"range\"] {\n flex: 1 1 80px;\n}\n\n/* Hide the toolbar separator that preceded the props block in panel mode. */\n.cs-pen-toolbar.cs-pen-has-props-panel {}\n\n/* ---------------------------------------------------------------------------\n In-canvas pen toolbar — a slim VERTICAL dock on the RIGHT of the block,\n trimmed to the essentials (Pen / Edit, Undo / Redo, solid Fill colour,\n layer order + delete + the shapes strip). The full toolbar still renders in\n the page-background modal, which carries .cs-pen-has-props-panel and is\n excluded by every :not() selector below.\n --------------------------------------------------------------------------- */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n justify-items: stretch;\n align-items: center;\n width: 180px;\n box-sizing: border-box;\n left: calc(100% + 8px);\n right: auto;\n top: 0;\n bottom: auto;\n transform: none;\n gap: 3px;\n}\n\n/* Separators, the props block and the layers strip span the full grid width so\n only the icon buttons flow 3-per-row — keeps the dock short instead of one\n long single column. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-sep,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-props,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-layers {\n grid-column: 1 / -1;\n}\n\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>.cs-pen-sep {\n width: 100%;\n height: 1px;\n margin: 2px 0;\n}\n\n/* Drop every non-essential control. */\n/* .cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-sep,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"resize\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"snap\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"smooth\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"dup\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"clear\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen=\"delete\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) [data-pen^=\"preset-\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--transform,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--opacity,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--stroke {\n display: none !important;\n} */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-layers {\n display: none;\n}\n\n/* Fill group: keep ONLY the solid colour swatch (gradient / image fill live in\n the page-background modal's full toolbar). */\n/* .cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-props__label,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill [data-pen=\"fill-type\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-fill-gradient,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-fill-image {\n display: none !important;\n} */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-group--fill .cs-pen-fill-solid {\n display: flex !important;\n justify-content: center;\n}\n\n/* Props block: stack its groups full-width so wide controls (gradient selects,\n colour stops, angle, blend, stroke) wrap INSIDE the dock instead of spilling\n out to the side. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 6px;\n width: 100%;\n box-sizing: border-box;\n}\n\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props__group,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-fill-solid,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-fill-gradient,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-fill-image,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-grad-stops {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 3px;\n width: 100%;\n box-sizing: border-box;\n}\n\n/* Every text/number/select control fills the row; ranges + swatches too. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props select,\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props input[type=\"number\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-props input[type=\"range\"],\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-num {\n flex: 1 1 100%;\n width: 100%;\n min-width: 0;\n box-sizing: border-box;\n}\n\n/* Numeric fields keep their tiny icon label inline, input takes the rest. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-num input {\n flex: 1 1 auto;\n width: auto;\n min-width: 0;\n}\n\n/* Layers strip wraps to the column width instead of scrolling sideways. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel) .cs-pen-layers {\n flex-wrap: wrap;\n justify-content: center;\n max-width: none;\n width: 100%;\n overflow-x: visible;\n}\n\n/* Icon buttons fill their grid cell. */\n.cs-pen-toolbar:not(.cs-pen-has-props-panel)>button {\n width: 100%;\n min-width: 0;\n}\n\n/* Right-side shapes panel (preset library). */\n.cs-page-shape-shapes {\n flex: 0 0 250px;\n display: flex;\n flex-direction: column;\n background: #1b2030;\n color: #e5e7f0;\n border-left: 1px solid rgba(255, 255, 255, 0.08);\n overflow-y: auto;\n}\n\n.cs-page-shape-props {\n border-bottom: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n.cs-page-shape-shapes__title {\n padding: 12px 14px;\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: #8aa4e6;\n border-bottom: 1px solid rgba(255, 255, 255, 0.07);\n}\n\n/* Width / height for dropping a sized shape. */\n.cs-page-shape-size {\n display: flex;\n gap: 8px;\n padding: 12px 12px 0;\n}\n\n.cs-page-shape-size label {\n flex: 1 1 0;\n display: flex;\n align-items: center;\n gap: 4px;\n font-size: 11px;\n color: #8aa4e6;\n font-weight: 600;\n}\n\n.cs-page-shape-size input {\n width: 100%;\n min-width: 0;\n height: 26px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n padding: 0 6px;\n}\n\n.cs-page-shape-shapes__grid {\n padding: 12px;\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 8px;\n}\n\n.cs-page-shape-shapes__grid button {\n height: 42px;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 8px;\n background: rgba(255, 255, 255, 0.04);\n color: #e5e7f0;\n font-size: 20px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cs-page-shape-shapes__grid button:hover {\n background: rgba(92, 92, 255, 0.18);\n border-color: #5c5cff;\n}\n\n/* Custom in-page colour picker — replaces the native <input type=\"color\">\n popup, which clips at the screen edge when a swatch sits in the right-docked\n panel. Positioned in JS (fixed) and clamped inside the viewport. */\n.cs-pen-cpick {\n position: fixed;\n z-index: 2147483600;\n width: 208px;\n padding: 10px;\n box-sizing: border-box;\n background: #232a3d;\n border: 1px solid rgba(255, 255, 255, 0.16);\n border-radius: 10px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);\n user-select: none;\n -webkit-user-select: none;\n}\n\n.cs-pen-cpick__sv {\n position: relative;\n width: 100%;\n height: 130px;\n border-radius: 6px;\n cursor: crosshair;\n background:\n linear-gradient(to top, #000, rgba(0, 0, 0, 0)),\n linear-gradient(to right, #fff, var(--cs-cpick-hue, #f00));\n touch-action: none;\n}\n\n.cs-pen-cpick__svthumb,\n.cs-pen-cpick__huethumb {\n position: absolute;\n width: 12px;\n height: 12px;\n margin: -6px 0 0 -6px;\n border: 2px solid #fff;\n border-radius: 50%;\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);\n pointer-events: none;\n}\n\n.cs-pen-cpick__hue {\n position: relative;\n width: 100%;\n height: 14px;\n margin-top: 10px;\n border-radius: 7px;\n cursor: pointer;\n touch-action: none;\n background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);\n}\n\n.cs-pen-cpick__huethumb {\n top: 50%;\n width: 14px;\n height: 14px;\n margin: -7px 0 0 -7px;\n}\n\n.cs-pen-cpick__row {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 10px;\n}\n\n.cs-pen-cpick__hex {\n flex: 0 0 84px;\n height: 26px;\n border: 1px solid rgba(255, 255, 255, 0.16);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n text-transform: uppercase;\n padding: 0 6px;\n box-sizing: border-box;\n}\n\n.cs-pen-cpick__sw {\n flex: 1 1 auto;\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n}\n\n.cs-pen-cpick__sw button {\n width: 16px;\n height: 16px;\n padding: 0;\n border: 1px solid rgba(255, 255, 255, 0.2);\n border-radius: 4px;\n cursor: pointer;\n}\n\n/* Stage area centres the page-sized drawing block. */\n.cs-page-shape-stagewrap {\n flex: 1 1 auto;\n display: flex;\n /* `safe` keeps the stage's top-left reachable (scrollable) when it's zoomed\n larger than the viewport, instead of clipping the overflow. Plain `center`\n is the fallback for parsers without `safe`. */\n align-items: center;\n align-items: safe center;\n justify-content: center;\n justify-content: safe center;\n overflow: auto;\n padding: 24px;\n}\n\n/* Zoom control — pinned to the viewport bottom-centre (fixed ignores the\n stagewrap scroll). */\n.cs-page-shape-modal .cs-page-shape-zoom {\n position: fixed;\n bottom: 18px;\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 4px;\n background: #1b2030;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);\n z-index: 5;\n}\n\n.cs-page-shape-zoom button {\n min-width: 30px;\n height: 28px;\n padding: 0 8px;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #e5e7f0;\n font-size: 15px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-zoom button:hover {\n background: rgba(92, 92, 255, 0.22);\n}\n\n.cs-page-shape-zoom__val {\n font-size: 12px !important;\n min-width: 46px !important;\n}\n\n/* The drawing stage holds a page-aspect pen-shape block. */\n.cs-page-shape-stage {\n position: relative;\n background: #ffffff;\n box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);\n}\n\n.cs-page-shape-block {\n height: 100%;\n position: relative;\n z-index: 1;\n}\n\n/* Keep the designer's pen block transparent so the trace-reference image\n behind it stays visible while drawing. */\n.cs-page-shape-block,\n.cs-page-shape-block .cs-pen-shape,\n.cs-page-shape-block .cs-pen-svg {\n background: transparent !important;\n}\n\n/* Page designer ONLY: let the pen overlay extend a bleed margin PAST the page so\n anchors + handles can be placed and grabbed OFF-PAGE. The overlay is a\n transparent hit + marker layer; it's clipped to the scrolling stagewrap so it\n never overlaps the side panels, and the toolbar/zoom (position:fixed) stay on\n top. The rendered fill still clips to the page (off-page = bleed). Zoom out to\n see/reach the whole margin without scrolling. */\n.cs-page-shape-block .cs-pen-overlay {\n inset: -50%;\n}\n\n/* Trace-reference image: faint guide behind the pen block, clicks pass through. */\n.cs-page-shape-ref-img {\n position: absolute;\n inset: 0;\n background-repeat: no-repeat;\n background-position: center;\n background-size: 100% 100%;\n pointer-events: none;\n z-index: 0;\n display: none;\n}\n\n.cs-page-shape-ref-img.is-on {\n display: block;\n}\n\n/* Trace-reference controls (top of the right-hand shapes panel). */\n.cs-page-shape-ref {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 12px;\n}\n\n.cs-page-shape-ref__btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 8px 12px;\n border: 1px dashed rgba(255, 255, 255, 0.22);\n border-radius: 6px;\n background: rgba(255, 255, 255, 0.04);\n color: #e5e7f0;\n font-size: 12px;\n font-weight: 600;\n cursor: pointer;\n}\n\n.cs-page-shape-ref__btn:hover {\n border-color: #5c5cff;\n background: rgba(92, 92, 255, 0.16);\n}\n\n.cs-page-shape-ref__btn input {\n display: none;\n}\n\n.cs-page-shape-ref__op {\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 11px;\n font-weight: 600;\n color: #8aa4e6;\n}\n\n.cs-page-shape-ref__op input {\n flex: 1 1 auto;\n min-width: 0;\n}\n\n.cs-page-shape-ref__clear {\n padding: 6px 10px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 6px;\n background: transparent;\n color: #cbd5e1;\n font-size: 12px;\n cursor: pointer;\n}\n\n.cs-page-shape-ref__clear:hover {\n border-color: #ef4444;\n color: #ef4444;\n}\n\n.cs-page-shape-ref__hint {\n margin: 0;\n font-size: 11px;\n line-height: 1.4;\n color: #94a3b8;\n}\n\n.cs-page-shape-ref__chk {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n font-size: 11px;\n line-height: 1.35;\n color: #cbd5e1;\n cursor: pointer;\n}\n\n.cs-page-shape-ref__chk input {\n margin-top: 1px;\n flex: 0 0 auto;\n}\n\n/* \"Outline only\" trace mode: show the traced clip-paths as outlines (no fill)\n while drawing, so the reference image underneath stays visible. View-only —\n the saved shape keeps its real fills (this class isn't in the captured SVG). */\n.cs-page-shape-stage.cs-trace-outline .cs-pen-fill {\n fill: none !important;\n stroke: #e11d48 !important;\n stroke-width: 1.6px !important;\n vector-effect: non-scaling-stroke;\n}\n\n/* Float the pen toolbar at the top of the modal instead of above the block\n (the block fills the stage, so the default `bottom: 100%` would clip it). */\n.cs-page-shape-modal .cs-pen-toolbar {\n position: fixed;\n top: 20px;\n bottom: auto;\n left: 50%;\n transform: translateX(-50%);\n flex-wrap: wrap;\n max-width: 92vw;\n justify-content: center;\n}\n\n/* ===========================================================================\n CustomRichEditor — inline rich-text toolbar (replaces the commercial Froala\n toolbar). Floats above the current selection while a text block is editing.\n =========================================================================== */\n.cre-toolbar {\n position: fixed;\n z-index: 9500;\n display: none;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px;\n max-width: 96vw;\n padding: 5px 6px;\n background: #1f2533;\n border: 1px solid rgba(255, 255, 255, 0.10);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n font-family: Inter, \"Segoe UI\", sans-serif;\n user-select: none;\n}\n\n.cre-toolbar.is-visible {\n display: flex;\n /* animation: slideUpBadge 0.16s ease forwards; */\n}\n\n/* Docked mode — pinned to the top of the canvas viewport as a full-width sticky\n strip (Page Settings → \"Inline text toolbar\" OFF). Overrides the floating\n inline placement; top/left are forced so any leftover inline coords lose. */\n.cre-toolbar--docked {\n /* The editor runs in an iframe that GROWS to fit every page, and the HOST\n scrolls it — so position:fixed would pin to the iframe's content-top and\n scroll out of view for blocks lower down. We use position:absolute and JS\n moves `top` to the current visible viewport top each frame (see\n CustomRichEditor.trackDockedBar). top:0 is the safe fallback. */\n position: absolute;\n top: 0 !important;\n left: 0 !important;\n right: 0 !important;\n margin: auto;\n width: 92%;\n max-width: 100%;\n justify-content: center;\n border-radius: 0;\n border-left: none;\n border-right: none;\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);\n animation: none;\n}\n\n/* Idle placeholder — the always-present docked bar shown when docked mode is ON\n and no block is being edited. Looks like the real bar but is non-interactive\n (\"select a block to use it\"). The functional bar replaces it on edit. */\n.cre-toolbar--placeholder {\n pointer-events: none;\n opacity: 0.55;\n cursor: default;\n}\n\n.cre-group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding-right: 5px;\n margin-right: 1px;\n border-right: 1px solid rgba(255, 255, 255, 0.10);\n}\n\n.cre-group:last-child {\n border-right: none;\n padding-right: 0;\n margin-right: 0;\n}\n\n.cre-toolbar button {\n min-width: 26px;\n height: 26px;\n padding: 0 5px;\n border: none;\n border-radius: 5px;\n background: transparent;\n color: #e5e7f0;\n font-size: 13px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cre-toolbar button:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.cre-toolbar button.is-active {\n background: #248567;\n color: #fff;\n}\n\n.cre-toolbar select {\n height: 26px;\n max-width: 96px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3042;\n color: #fff;\n font-size: 12px;\n padding: 0 4px;\n cursor: pointer;\n}\n\n.cre-color {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n border-radius: 5px;\n color: #e5e7f0;\n font-size: 13px;\n font-weight: 700;\n cursor: pointer;\n overflow: hidden;\n}\n\n.cre-color:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n/* The native colour input fills the swatch but stays invisible — the glyph\n is the visible affordance. The input triggers the native colour picker. */\n.cre-color input[type=\"color\"] {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n opacity: 0;\n border: none;\n padding: 0;\n cursor: pointer;\n}\n\n/* Google-Docs-style colour glyph: letter \"A\" with a colour bar beneath it.\n The bar's background is updated in JS to reflect the picked colour. */\n.cre-color__glyph {\n position: relative;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 1px;\n pointer-events: none;\n font-size: 12px;\n font-weight: 800;\n line-height: 1;\n}\n\n.cre-color__glyph::after {\n content: '';\n display: block;\n width: 14px;\n height: 3px;\n border-radius: 1.5px;\n background: var(--cre-swatch, #e5e7f0);\n}\n\n.cre-color__glyph--hi::after {\n background: var(--cre-swatch, #ffff00);\n}\n\n/* Centre glyphs in toolbar buttons so the SVG icons (alignment + the table\n block's appended structure icons) sit crisply centred. */\n.cre-toolbar button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n.cre-toolbar button svg,\n.cre-color svg {\n display: block;\n pointer-events: none;\n}\n\n/* Lists made inside a text block: visible markers + sensible indent. The marker\n tracks the <li> font-size, which CustomRichEditor syncs to the content size\n so \"1.\" / \"•\" match large text instead of staying tiny. */\n.edit_me ol,\n.edit_me ul {\n margin: 0;\n padding-left: 1.4em;\n}\n\n.edit_me ol {\n list-style: decimal outside;\n}\n\n.edit_me ul {\n list-style: disc outside;\n}\n\n.edit_me li {\n line-height: 1.4;\n}\n\n/* Font-size dropdown in the toolbar. */\n.cre-toolbar .cre-size {\n width: 64px;\n min-width: 64px;\n}\n\n/* Headings (H1–H6) created in a text block get a clear size hierarchy. An\n explicit font-size the user applies on a run still wins over these. */\n.edit_me h1 {\n font-size: 2em;\n}\n\n.edit_me h2 {\n font-size: 1.6em;\n}\n\n.edit_me h3 {\n font-size: 1.35em;\n}\n\n.edit_me h4 {\n font-size: 1.15em;\n}\n\n.edit_me h5 {\n font-size: 1em;\n}\n\n.edit_me h6 {\n font-size: 0.85em;\n}\n\n.edit_me h1,\n.edit_me h2,\n.edit_me h3,\n.edit_me h4,\n.edit_me h5,\n.edit_me h6 {\n font-weight: 700;\n margin: 0;\n line-height: 1.2;\n}\n\n/* ===========================================================================\n Static Table block (Canva-style)\n =========================================================================== */\n.cs-table-block {\n width: 100%;\n}\n\n.cs-table {\n width: 100%;\n border-collapse: collapse;\n table-layout: fixed;\n font-size: 14px;\n color: #333;\n}\n\n/* Target every cell, not just `.cs-cell`: in legacy Froala mode Froala's own\n \"insert column/row\" creates bare <td>s without our class, and they must still\n show a border. The normalizer re-stamps `.cs-cell` afterwards, but this keeps\n the border visible regardless. */\n.cs-table .cs-cell,\n.cs-table td,\n.cs-table th {\n border: 1px solid #d0d5e2;\n padding: 8px 10px;\n vertical-align: top;\n min-width: 24px;\n /* On a <td>, `height` is a MINIMUM — keeps empty cells from collapsing to a\n thin strip while still growing with content. */\n height: 36px;\n word-break: break-word;\n overflow-wrap: break-word;\n}\n\n.cs-table .cs-cell--head {\n background: #f3f5fb;\n font-weight: 700;\n color: #1f2937;\n}\n\n/* Editing affordances. */\n.cs-table-block.cs-editing .cs-cell {\n outline: none;\n}\n\n/* The cell holding the caret gets a clear highlight. */\n.cs-table .cs-cell:focus {\n box-shadow: inset 0 0 0 1px #248567;\n background: rgba(92, 92, 255, 0.06);\n}\n\n/* Canva-style cell selection: a soft tint on each cell + one crisp rectangle\n (.cs-tbl-selrect) drawn around the whole selection. */\n.cs-table .cs-cell--selected {\n background: rgba(92, 92, 255, 0.18);\n}\n\n.cs-tbl-selrect {\n position: fixed;\n z-index: 9450;\n display: none;\n pointer-events: none;\n border: 1px solid #248567;\n border-radius: 2px;\n box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6);\n}\n\n/* Red delete-preview (shown while hovering a Delete menu item). */\n.cs-table .cs-cell--danger {\n box-shadow: inset 0 0 0 1px #e5484d;\n background: rgba(229, 72, 77, 0.16) !important;\n}\n\n/* Right-click context menu. */\n.cs-tbl-menu {\n position: fixed;\n z-index: 9700;\n min-width: 190px;\n padding: 5px;\n background: #fff;\n border: 1px solid #e5e7eb;\n border-radius: 10px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);\n font-family: Inter, \"Segoe UI\", sans-serif;\n user-select: none;\n}\n\n.cs-tbl-menu__item {\n display: flex;\n align-items: center;\n width: 100%;\n padding: 8px 12px;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: #1f2937;\n font-size: 13px;\n text-align: left;\n cursor: pointer;\n white-space: nowrap;\n}\n\n.cs-tbl-menu__item:hover {\n background: #eef2ff;\n}\n\n.cs-tbl-menu__item--danger {\n color: #e5484d;\n}\n\n.cs-tbl-menu__item--danger:hover {\n background: #fdecec;\n}\n\n.cs-tbl-menu__sep {\n height: 1px;\n margin: 4px 6px;\n background: #eceef3;\n}\n\n/* Floating table toolbar (lives in <body>, never exported). */\n.cs-tbl-toolbar {\n position: fixed;\n z-index: 9600;\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px;\n max-width: 96vw;\n padding: 5px 6px;\n background: #1f2533;\n border: 1px solid rgba(255, 255, 255, 0.10);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n font-family: Inter, \"Segoe UI\", sans-serif;\n user-select: none;\n animation: slideUpBadge 0.16s ease forwards;\n}\n\n.cs-tbl-group {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding-right: 5px;\n margin-right: 1px;\n border-right: 1px solid rgba(255, 255, 255, 0.10);\n}\n\n.cs-tbl-group:last-child {\n border-right: none;\n padding-right: 0;\n margin-right: 0;\n}\n\n.cs-tbl-toolbar button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 28px;\n height: 26px;\n padding: 0 6px;\n border: none;\n border-radius: 5px;\n background: transparent;\n color: #e5e7f0;\n font-size: 12px;\n line-height: 1;\n cursor: pointer;\n}\n\n/* Crisp SVG glyphs that follow the button's text colour. */\n.cs-tbl-toolbar button svg,\n.cs-tbl-color svg {\n display: block;\n pointer-events: none;\n}\n\n.cs-tbl-toolbar button:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n/* Font family / size / line-height / letter-spacing dropdowns. */\n.cs-tbl-toolbar select {\n height: 26px;\n max-width: 112px;\n padding: 0 4px;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 5px;\n background: #2a3142;\n color: #e5e7f0;\n font-size: 12px;\n line-height: 1;\n cursor: pointer;\n}\n\n.cs-tbl-toolbar select:hover {\n border-color: rgba(255, 255, 255, 0.30);\n}\n\n.cs-tbl-toolbar select:focus {\n outline: none;\n border-color: #5b8cff;\n}\n\n.cs-tbl-color {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n border-radius: 5px;\n color: #e5e7f0;\n font-size: 13px;\n cursor: pointer;\n overflow: hidden;\n}\n\n.cs-tbl-color:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.cs-tbl-color input[type=\"color\"] {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n opacity: 0;\n border: none;\n padding: 0;\n cursor: pointer;\n}\n\n/* ===================== List block (synchronised columns) ================== */\n\n/* Three selectable tiers: the List (outer), each Column (\"Container\"), and the\n blocks inside. Each tier gets a little padding so there's a clickable band to\n select it without hitting the tier below.\n Neutralise the leaked `.cs_block_s` base (width:250px / border / max-content)\n so the List always spans its column full-width. */\n.cs-synclist-block {\n width: 100% !important;\n max-width: 100% !important;\n height: auto !important;\n /* border: none !important; */\n padding: 10px !important;\n box-sizing: border-box;\n}\n\n/* Wix-repeater-style wrapping row, optimised for SMOOTH px resize. Before any\n resize columns split the row equally (flex: 1 1 0). Once a column is resized\n they all take that exact px width (--col-item-w) via .cs-synclist--sized — so\n the drag is 1:1/smooth — pack from the left with a consistent gap, and wrap\n to the next line when they no longer fit (partial last row stays left-\n aligned). Row height is uniform via --col-item-h. */\n.cs-synclist {\n display: flex;\n flex-wrap: wrap;\n align-items: stretch;\n align-content: flex-start;\n justify-content: space-between;\n gap: 5px;\n width: 100%;\n box-sizing: border-box;\n}\n\n/* A column is a flex item AND a free canvas (position:relative) — its blocks\n are absolutely positioned and drag/resize freely, bounded to it. */\n.cs-synclist__col {\n /* Default: split the row equally. */\n flex: 1 1 0;\n min-width: 0;\n /* Floor the height (the leaked .cs_block_s height:max-content would collapse\n the column to ~0 since its blocks are absolutely positioned).\n --col-item-h is inherited and bumped on height-resize. */\n min-height: var(--col-item-h, 240px);\n /* Override the leaked width:250px / height:max-content. */\n width: auto;\n height: auto;\n position: relative;\n display: block;\n padding: 0;\n /* border-left: 1px solid #e6e8f2; */\n box-sizing: border-box;\n}\n\n/* After a resize: every column is the SAME exact px width/height (smooth, 1:1\n with the drag); flex-wrap pushes them to the next line when the row is full. */\n.cs-synclist--sized .cs-synclist__col {\n flex: 0 0 var(--col-item-w, 200px);\n width: var(--col-item-w, 200px);\n height: var(--col-item-h, 240px);\n}\n\n\n/* Free-floating content block — keep its own size, just cap width to the\n column so it can't spill out sideways. */\n.cs-synclist__col>.cs_block_s {\n max-width: 100%;\n margin: 0 !important;\n}\n\n.cs-synclist__col img {\n max-width: 100%;\n height: auto;\n}\n\n/* Light column guides on hover — editing aid, harmless in export. */\n.cs-synclist-block:hover .cs-synclist__col {\n box-shadow: inset 0 0 0 1px #eef0f6;\n}\n\n/* The Container resizes with the right (e), bottom (s) and bottom-right (se)\n handles — drag them to size the column (width sticks via the flex-basis\n mirror in synclist.js; height stretches every column via the flex row).\n The other handles are hidden. Content blocks keep all 8 handles. */\n.cs-synclist__col>.cs-resize-handle {\n display: none !important;\n}\n\n.cs-synclist__col>.cs-resize-handle[data-dir=\"e\"],\n.cs-synclist__col>.cs-resize-handle[data-dir=\"s\"],\n.cs-synclist__col>.cs-resize-handle[data-dir=\"se\"] {\n display: block !important;\n}\n\n.cs-synclist__col.cs-editing,\n.cs-synclist__col.cs-selected {\n border: 1px solid #248567;\n}\n\n/* Drop highlight while dragging a sidebar block into a Container. */\n.cs-synclist__col--dropping {\n box-shadow: inset 0 0 0 2px #5c5cff !important;\n background: rgba(92, 92, 255, 0.06);\n}\n\n/* Blink a child block when it's preventing the column from resizing narrower.\n Two flashes of a red outline so the user sees exactly which block is the blocker. */\n@keyframes cs-synclist-blink {\n 0% {\n outline: 2px solid transparent;\n outline-offset: 1px;\n }\n\n 20% {\n outline: 2px solid #e11d48;\n outline-offset: 1px;\n }\n\n 40% {\n outline: 2px solid transparent;\n outline-offset: 1px;\n }\n\n 65% {\n outline: 2px solid #e11d48;\n outline-offset: 1px;\n }\n\n 100% {\n outline: 2px solid transparent;\n outline-offset: 1px;\n }\n}\n\n.cs-synclist__blink {\n animation: cs-synclist-blink 0.55s ease forwards;\n z-index: 10;\n}\n\n/* ===========================================================================\n Aiden — AI writing assistant block (chrome; stripped from exports)\n =========================================================================== */\n.cs-aiden-block .cs-aiden-text {\n min-height: 1.5em;\n outline: none;\n}\n\n/* While the AI flow is open, the block becomes a single row: the editable text\n on the left and the action bar docked on the right — all INSIDE the input\n box. */\n/* High specificity (+ [data-block-type]) so it beats the cover-page rule\n `.cs-cover-canvas > .cs_block_s[data-cs-in-section=\"1\"] { display:block !important }`\n — otherwise the editable + bar stack onto two lines instead of one row. */\n.cs_block_s.cs-aiden-block.cs-aiden--active[data-block-type=\"aiden\"] {\n display: flex !important;\n flex-direction: row !important;\n flex-wrap: nowrap !important;\n align-items: center;\n gap: 10px;\n box-shadow: 0 0 0 1.5px #8b5cf6, 0 8px 24px rgba(124, 58, 237, 0.14);\n border-radius: 4px;\n}\n\n.cs_block_s.cs-aiden-block.cs-aiden--active .cs-aiden-text {\n flex: 1 1 auto;\n min-width: 0;\n}\n\n/* When a multi-line result is showing, dock the buttons to the top-right\n instead of vertically centring them against tall text. */\n.cs_block_s.cs-aiden-block.cs-aiden--active[data-aiden-phase=\"result\"] {\n align-items: flex-start;\n}\n\n/* Action bar docked inside the block, right-aligned. */\n.cs-aiden-bar {\n position: relative;\n flex: 0 0 auto;\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 13px;\n}\n\n.cs-aiden-bar__sp {\n display: none;\n}\n\n.cs-aiden-btn {\n border: none;\n border-radius: 7px;\n padding: 7px 14px;\n font-size: 13px;\n font-weight: 600;\n cursor: pointer;\n white-space: nowrap;\n line-height: 1;\n background: transparent;\n transition: background 120ms ease, opacity 120ms ease, color 120ms ease;\n}\n\n.cs-aiden-btn--primary {\n color: #fff;\n background: linear-gradient(90deg, #7c3aed, #d9772b);\n}\n\n.cs-aiden-btn--primary:hover {\n opacity: 0.92;\n}\n\n.cs-aiden-btn--ghost {\n color: #2563eb;\n}\n\n.cs-aiden-btn--ghost:hover {\n background: #eef2ff;\n}\n\n.cs-aiden-btn--stop {\n color: #fff;\n background: #6b7280;\n}\n\n.cs-aiden-btn--stop:hover {\n background: #4b5563;\n}\n\n.cs-aiden-btn--link {\n color: #2563eb;\n padding: 7px 8px;\n}\n\n.cs-aiden-btn--link:hover {\n background: #eef2ff;\n}\n\n.cs-aiden-status {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n color: #c026a8;\n font-weight: 600;\n}\n\n.cs-aiden-spin {\n width: 14px;\n height: 14px;\n border: 2px solid rgba(192, 38, 168, 0.25);\n border-top-color: #c026a8;\n border-radius: 50%;\n animation: cs-aiden-spin 0.7s linear infinite;\n}\n\n@keyframes cs-aiden-spin {\n to {\n transform: rotate(360deg);\n }\n}\n\n.cs-aiden-flash {\n color: #dc2626;\n font-weight: 600;\n}\n\n/* Adjust-tone popup — anchored above the action bar. */\n.cs-aiden-pop {\n position: absolute;\n right: 0;\n bottom: calc(100% + 10px);\n z-index: 9997;\n display: none;\n flex-direction: column;\n gap: 10px;\n width: 250px;\n padding: 14px;\n background: #ffffff;\n border: 1px solid #ececf3;\n border-radius: 10px;\n box-shadow: 0 16px 36px rgba(20, 24, 60, 0.18);\n font-size: 13px;\n text-align: left;\n}\n\n.cs-aiden-pop.is-open {\n display: flex;\n}\n\n.cs-aiden-pop__title {\n font-weight: 700;\n color: #1f2937;\n}\n\n.cs-aiden-pop__row {\n display: flex;\n align-items: center;\n gap: 8px;\n color: #374151;\n cursor: pointer;\n}\n\n.cs-aiden-pop__foot {\n display: flex;\n justify-content: flex-end;\n}\n</style>\n</head>\n\n<body>\n\n\n\n <div id=\"place_everything\" class=\"notranslate\">\n <div class=\"page_container\">\n <!--\n Canvas mount point. The outer .cs_paper is host-owned (rendered\n by this HTML). flow-canvas.js will inject .cs_margin pages directly\n into this .cs_paper — it must NOT create a second one inside the\n canvas.\n -->\n <div dnddropzone=\"\" class=\"cs_paper\">\n <div class=\"cs_page custom-form-design centercontent\" style=\"visibility: visible;\">\n </div>\n </div>\n </div>\n </div>\n\n <script src=\"https://code.jquery.com/jquery-3.7.1.min.js\"><\/script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/froala-editor/4.3.1/js/froala_editor.pkgd.min.js\"><\/script>\n <script data-src=\"./js/font-config.js\">\n/**\n * Font family configuration for Froala editor\n * Includes Google Fonts and system fonts with CDN links\n */\n(function () {\n // Google Fonts imports - add to <head> dynamically\n const GOOGLE_FONTS = [\n 'https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Raleway:wght@300;400;500;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;400;600;700&display=swap',\n 'https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap',\n ];\n\n // Font family definitions - mapped as CSS value => Display name\n // (Froala uses keys as dropdown display text, values as CSS values)\n const FONT_FAMILIES = {\n // System fonts\n 'Arial': 'Arial',\n 'Helvetica': 'Helvetica',\n 'Times New Roman': 'Times New Roman',\n 'Courier New': 'Courier New',\n 'Georgia': 'Georgia',\n 'Verdana': 'Verdana',\n\n // Google Fonts - Modern/Sans-serif\n \"'Roboto', sans-serif\": 'Roboto',\n \"'Poppins', sans-serif\": 'Poppins',\n \"'Sora', sans-serif\": 'Sora',\n \"'Open Sans', sans-serif\": 'Open Sans',\n \"'Lato', sans-serif\": 'Lato',\n \"'Montserrat', sans-serif\": 'Montserrat',\n \"'Raleway', sans-serif\": 'Raleway',\n \"'Inter', sans-serif\": 'Inter',\n \"'Nunito', sans-serif\": 'Nunito',\n \"'Source Sans Pro', sans-serif\": 'Source Sans Pro',\n\n // Google Fonts - Serif/Display\n \"'Playfair Display', serif\": 'Playfair Display',\n };\n\n // Load Google Fonts into the document\n function loadGoogleFonts() {\n GOOGLE_FONTS.forEach(fontUrl => {\n const link = document.createElement('link');\n link.rel = 'stylesheet';\n link.href = fontUrl;\n link.data_font_config = 'true'; // Mark as auto-loaded\n document.head.appendChild(link);\n });\n }\n\n // Initialize fonts when DOM is ready\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', loadGoogleFonts);\n } else {\n loadGoogleFonts();\n }\n\n // Expose for Froala configuration\n window.FROALA_FONTS = FONT_FAMILIES;\n})();\n\n<\/script>\n <script data-src=\"./editor/rich-text-editor.js\">\n/**\n * @fileoverview CustomRichEditor — a dependency-free inline rich-text editor.\n *\n * Built to REPLACE the commercial Froala editor for in-canvas text blocks\n * (Title / Heading / Body, etc.) so the project carries no third-party editor\n * licence. It is a DROP-IN for the Froala instance used by inline-editor.js:\n *\n * const ed = new CustomRichEditor(target, opts);\n * ed.commands.exec('bold'); // ← same call shape froala-style-handler uses\n * ed.commands.exec('textColor', ['#f00']);\n * ed.destroy();\n *\n * It edits the element in place (contenteditable) — exactly like Froala did —\n * so the rest of the app (HTML export, style panel, save/load) needs no change.\n *\n * Toolbar (inline, floats above the selection):\n * bold · italic · underline · strikethrough · sub · super\n * heading (inline) · font family · font size · line height\n * letter spacing · text case (UPPER / Capitalize / lower / as typed)\n * text colour · highlight colour\n * align L/C/R/justify\n * ordered / unordered list · outdent / indent\n * link · unlink · clear formatting\n * undo · redo\n *\n * Exposes: window.CustomRichEditor\n */\n(function () {\n 'use strict';\n\n const DEFAULT_SIZES = ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '88', '96'];\n const DEFAULT_FONTS = {\n 'Arial': 'Arial',\n \"'Roboto', sans-serif\": 'Roboto',\n \"'Poppins', sans-serif\": 'Poppins',\n \"'Open Sans', sans-serif\": 'Open Sans',\n \"'Lato', sans-serif\": 'Lato',\n \"'Montserrat', sans-serif\": 'Montserrat',\n \"'Inter', sans-serif\": 'Inter',\n \"'Playfair Display', serif\": 'Playfair Display',\n 'Georgia, serif': 'Georgia',\n \"'Courier New', monospace\": 'Courier New',\n };\n\n // Heading levels map to inline font-size + weight (NOT block <h1> tags) so a\n // heading styles only the SELECTED run — e.g. make \"balan\" H1 without turning\n // the rest of the line into a heading. Shared by apply + toolbar sync.\n const HEADING_SPEC = {\n h1: { fontSize: '32px', fontWeight: '700' },\n h2: { fontSize: '24px', fontWeight: '700' },\n h3: { fontSize: '19px', fontWeight: '700' },\n h4: { fontSize: '16px', fontWeight: '700' },\n h5: { fontSize: '13px', fontWeight: '700' },\n h6: { fontSize: '11px', fontWeight: '700' },\n };\n\n // Proper text-alignment icons (rows of lines, like a word processor) drawn as\n // inline SVG so they read clearly instead of the ambiguous arrow glyphs.\n const alignSvg = (rows) => {\n // rows: array of [x, width] for each of the 4 lines (viewBox 16 wide).\n const bars = rows.map((r, i) =>\n `<rect x=\"${r[0]}\" y=\"${2 + i * 4}\" width=\"${r[1]}\" height=\"2\" rx=\"1\"/>`).join('');\n return `<svg width=\"15\" height=\"15\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\">${bars}</svg>`;\n };\n\n // Helper: stroke-only SVG icon (14×14, viewBox 0 0 16 16).\n const _s = (d) => `<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${d}</svg>`;\n\n // SVG/text glyphs for toolbar buttons (no icon font needed).\n const ICON = {\n // B / I / U / S keep styled-text glyphs — universally recognised in every editor.\n bold: 'B',\n italic: 'I',\n underline: 'U',\n strike: 'S',\n // subscript / superscript\n sub: 'x<sub style=\"font-size:8px;line-height:1\">2</sub>',\n sup: 'x<sup style=\"font-size:8px;line-height:1\">2</sup>',\n // alignment (word-processor row-of-lines style, already SVG)\n alignLeft: alignSvg([[1, 14], [1, 8], [1, 14], [1, 8]]),\n alignCenter: alignSvg([[1, 14], [4, 8], [1, 14], [4, 8]]),\n alignRight: alignSvg([[1, 14], [7, 8], [1, 14], [7, 8]]),\n alignJustify: alignSvg([[1, 14], [1, 14], [1, 14], [1, 14]]),\n // numbered list: three lines + filled square markers on the left\n ol: _s(`<line x1=\"7\" y1=\"4\" x2=\"14\" y2=\"4\"/><line x1=\"7\" y1=\"8.5\" x2=\"14\" y2=\"8.5\"/><line x1=\"7\" y1=\"13\" x2=\"14\" y2=\"13\"/><rect x=\"2\" y=\"2.5\" width=\"3.2\" height=\"3\" rx=\"0.6\" fill=\"currentColor\" stroke=\"none\"/><rect x=\"2\" y=\"7\" width=\"3.2\" height=\"3\" rx=\"0.6\" fill=\"currentColor\" stroke=\"none\"/><rect x=\"2\" y=\"11.5\" width=\"3.2\" height=\"3\" rx=\"0.6\" fill=\"currentColor\" stroke=\"none\"/>`),\n // bullet list: three lines + filled circle markers on the left\n ul: _s(`<line x1=\"7\" y1=\"4\" x2=\"14\" y2=\"4\"/><line x1=\"7\" y1=\"8.5\" x2=\"14\" y2=\"8.5\"/><line x1=\"7\" y1=\"13\" x2=\"14\" y2=\"13\"/><circle cx=\"3.5\" cy=\"4\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"3.5\" cy=\"8.5\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"3.5\" cy=\"13\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/>`),\n // outdent: lines + left-pointing chevron\n outdent: _s(`<line x1=\"2\" y1=\"2.5\" x2=\"14\" y2=\"2.5\"/><polyline points=\"6,5.5 3,8 6,10.5\"/><line x1=\"7.5\" y1=\"8\" x2=\"14\" y2=\"8\"/><line x1=\"2\" y1=\"13.5\" x2=\"14\" y2=\"13.5\"/>`),\n // indent: lines + right-pointing chevron\n indent: _s(`<line x1=\"2\" y1=\"2.5\" x2=\"14\" y2=\"2.5\"/><polyline points=\"3,5.5 6,8 3,10.5\"/><line x1=\"7.5\" y1=\"8\" x2=\"14\" y2=\"8\"/><line x1=\"2\" y1=\"13.5\" x2=\"14\" y2=\"13.5\"/>`),\n // link: two interlocking arcs (chain link)\n link: _s(`<path d=\"M7 9.5C7.6 11 9 12 10.5 12C12.4 12 14 10.4 14 8.5C14 6.6 12.4 5 10.5 5L9.5 5\"/><path d=\"M9 6.5C8.4 5 7 4 5.5 4C3.6 4 2 5.6 2 7.5C2 9.4 3.6 11 5.5 11L6.5 11\"/>`),\n // unlink: same arcs dimmed + diagonal slash\n unlink: _s(`<path d=\"M7 9.5C7.6 11 9 12 10.5 12C12.4 12 14 10.4 14 8.5C14 6.6 12.4 5 10.5 5L9.5 5\" stroke-opacity=\"0.35\"/><path d=\"M9 6.5C8.4 5 7 4 5.5 4C3.6 4 2 5.6 2 7.5C2 9.4 3.6 11 5.5 11L6.5 11\" stroke-opacity=\"0.35\"/><line x1=\"3\" y1=\"13\" x2=\"13\" y2=\"3\"/>`),\n // clear formatting: eraser shape\n clear: _s(`<path d=\"M10.5 2L14 5.5L7.5 12H3.5V8.5L10.5 2Z\"/><line x1=\"7\" y1=\"5.5\" x2=\"11\" y2=\"9.5\"/><line x1=\"1.5\" y1=\"12\" x2=\"7.5\" y2=\"12\"/>`),\n // undo: curved arrow counter-clockwise\n undo: _s(`<path d=\"M5 6C6 3.8 8.3 2.5 10.5 2.5C13.5 2.5 15 5 15 7.5C15 10.5 12.8 13 10 13H8\"/><polyline points=\"5,2.5 5,6 8.5,6\"/>`),\n // redo: curved arrow clockwise\n redo: _s(`<path d=\"M11 6C10 3.8 7.7 2.5 5.5 2.5C2.5 2.5 1 5 1 7.5C1 10.5 3.2 13 6 13H8\"/><polyline points=\"11,2.5 11,6 7.5,6\"/>`),\n };\n\n let uid = 0;\n\n // The toolbar markup is shared by the live (functional) editor bar and the\n // docked placeholder bar, so it's built from one template.\n function toolbarInnerHTML(fonts, sizes) {\n const fontOpts = Object.entries(fonts || DEFAULT_FONTS)\n .map(([val, label]) => `<option value=\"${val.replace(/\"/g, '&quot;')}\">${label}</option>`).join('');\n const sizeOpts = (sizes || DEFAULT_SIZES).map((s) => `<option value=\"${s}\">${s}</option>`).join('');\n return `\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"bold\" title=\"Bold\" style=\"font-weight:700\">${ICON.bold}</button>\n <button type=\"button\" data-cmd=\"italic\" title=\"Italic\" style=\"font-style:italic\">${ICON.italic}</button>\n <button type=\"button\" data-cmd=\"underline\" title=\"Underline\" style=\"text-decoration:underline\">${ICON.underline}</button>\n <button type=\"button\" data-cmd=\"strikeThrough\" title=\"Strikethrough\" style=\"text-decoration:line-through\">${ICON.strike}</button>\n <button type=\"button\" data-cmd=\"subscript\" title=\"Subscript\">${ICON.sub}</button>\n <button type=\"button\" data-cmd=\"superscript\" title=\"Superscript\">${ICON.sup}</button>\n </div>\n <div class=\"cre-group\">\n <select data-sel=\"format\" title=\"Paragraph / heading\">\n <option value=\"\">Normal</option>\n <option value=\"h1\">H1</option>\n <option value=\"h2\">H2</option>\n <option value=\"h3\">H3</option>\n <option value=\"h4\">H4</option>\n <option value=\"h5\">H5</option>\n <option value=\"h6\">H6</option>\n </select>\n <select data-sel=\"font\" title=\"Font family\"><option value=\"\">Font</option>${fontOpts}</select>\n <select data-sel=\"size\" class=\"cre-size\" title=\"Font size\"><option value=\"\">Size</option>${sizeOpts}</select>\n <select data-sel=\"lineheight\" title=\"Line height\">\n <option value=\"\">↕ LH</option>\n <option value=\"1\">1.0</option>\n <option value=\"1.15\">1.15</option>\n <option value=\"1.3\">1.3</option>\n <option value=\"1.5\">1.5</option>\n <option value=\"2\">2.0</option>\n <option value=\"2.5\">2.5</option>\n <option value=\"3\">3.0</option>\n </select>\n <select data-sel=\"letterspacing\" title=\"Letter spacing\">\n <option value=\"\">⇿ LS</option>\n <option value=\"normal\">Normal</option>\n <option value=\"0.5px\">0.5</option>\n <option value=\"1px\">1</option>\n <option value=\"2px\">2</option>\n <option value=\"3px\">3</option>\n <option value=\"4px\">4</option>\n <option value=\"6px\">6</option>\n <option value=\"8px\">8</option>\n </select>\n <select data-sel=\"textcase\" title=\"Text case\">\n <option value=\"\">Aa Case</option>\n <option value=\"none\">As typed</option>\n <option value=\"uppercase\">UPPERCASE</option>\n <option value=\"capitalize\">Capitalize Each</option>\n <option value=\"lowercase\">lowercase</option>\n </select>\n </div>\n <div class=\"cre-group\">\n <label class=\"cre-color\" title=\"Text colour\"><span class=\"cre-color__glyph\">A</span><input type=\"color\" data-color=\"fore\" value=\"#000000\"></label>\n <label class=\"cre-color cre-color--bg\" title=\"Highlight colour\"><span class=\"cre-color__glyph cre-color__glyph--hi\">A</span><input type=\"color\" data-color=\"back\" value=\"#ffff00\"></label>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"justifyLeft\" title=\"Align left\">${ICON.alignLeft}</button>\n <button type=\"button\" data-cmd=\"justifyCenter\" title=\"Align center\">${ICON.alignCenter}</button>\n <button type=\"button\" data-cmd=\"justifyRight\" title=\"Align right\">${ICON.alignRight}</button>\n <button type=\"button\" data-cmd=\"justifyFull\" title=\"Justify\">${ICON.alignJustify}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"insertOrderedList\" title=\"Numbered list\">${ICON.ol}</button>\n <button type=\"button\" data-cmd=\"insertUnorderedList\" title=\"Bullet list\">${ICON.ul}</button>\n <button type=\"button\" data-cmd=\"outdent\" title=\"Decrease indent\">${ICON.outdent}</button>\n <button type=\"button\" data-cmd=\"indent\" title=\"Increase indent\">${ICON.indent}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-act=\"link\" title=\"Insert / edit link\">${ICON.link}</button>\n <button type=\"button\" data-cmd=\"unlink\" title=\"Remove link\">${ICON.unlink}</button>\n <button type=\"button\" data-cmd=\"removeFormat\" title=\"Clear formatting\">${ICON.clear}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-cmd=\"undo\" title=\"Undo\">${ICON.undo}</button>\n <button type=\"button\" data-cmd=\"redo\" title=\"Redo\">${ICON.redo}</button>\n </div>`;\n }\n\n // All currently-alive editor instances (normally 0 or 1). Used to decide when\n // the docked placeholder bar should show (only when nothing is being edited).\n const liveEditors = new Set();\n // Set true by non-rich editors (e.g. the table block) that show their OWN\n // docked bar, so the placeholder hides and two bars never stack at the top.\n let externalDockedActive = false;\n\n // Persistent docked toolbar: a non-interactive copy of the bar that stays\n // pinned to the top of the canvas whenever docked mode is ON and no block is\n // being edited. As soon as a text block is edited, the real (functional) bar\n // takes its place; on teardown the placeholder returns. So in docked mode a\n // bar is ALWAYS visible — never hidden.\n const DockedPlaceholder = {\n el: null,\n ensure(doc) {\n if (this.el && this.el.ownerDocument === doc && doc.body.contains(this.el)) return this.el;\n const tb = doc.createElement('div');\n tb.className = 'cre-toolbar cre-toolbar--docked cre-toolbar--placeholder';\n tb.setAttribute('data-cs-chrome', '');\n tb.setAttribute('aria-hidden', 'true');\n tb.innerHTML = toolbarInnerHTML(DEFAULT_FONTS, DEFAULT_SIZES);\n // Inert: swallow any interaction so it can't steal focus / fire commands.\n tb.addEventListener('mousedown', (e) => e.preventDefault());\n doc.body.appendChild(tb);\n this.el = tb;\n return tb;\n },\n sync(doc) {\n doc = doc || document;\n const win = doc.defaultView || window;\n const docked = !!(typeof win.isRichToolbarDocked === 'function' && win.isRichToolbarDocked());\n if (!docked) { if (this.el) this.el.classList.remove('is-visible'); return; }\n this.ensure(doc);\n // Show the placeholder only while no real editor bar is up — and not while\n // another editor (e.g. the table block) is showing its own docked bar.\n const show = liveEditors.size === 0 && !externalDockedActive;\n this.el.classList.toggle('is-visible', show);\n // Follow the host scroll like any docked bar (or stop when hidden).\n if (show) CustomRichEditor.trackDockedBar(this.el);\n else CustomRichEditor.untrackDockedBar(this.el);\n }\n };\n\n // Global hook: when the Page Settings toggle flips docked mode (and on first\n // load), update the placeholder even if no editor instance is alive.\n document.addEventListener('canvas:rich-toolbar-mode', () => DockedPlaceholder.sync(document));\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => DockedPlaceholder.sync(document));\n } else {\n DockedPlaceholder.sync(document);\n }\n\n class CustomRichEditor {\n constructor(target, opts = {}) {\n this.target = target;\n this.doc = target.ownerDocument || document;\n this.win = this.doc.defaultView || window;\n this.opts = opts;\n this.fonts = opts.fonts || DEFAULT_FONTS;\n this.sizes = opts.fontSizes || DEFAULT_SIZES;\n this.id = ++uid;\n this.lastRange = null;\n this.destroyed = false;\n\n // Froala-compatible no-op surfaces (inline-editor calls these defensively).\n this.popups = { hideAll: () => this._hideToolbar() };\n this.toolbar = { hide: () => this._hideToolbar() };\n // The command surface the style panel / froala-style-handler talk to.\n this.commands = { exec: (name, args) => this._exec(name, args) };\n\n this._init();\n }\n\n /* ------------------------------- lifecycle ------------------------------ */\n _init() {\n const t = this.target;\n // Anchor the toolbar to the BLOCK (stable) rather than the live caret —\n // otherwise it jumps around as the selection/text moves while typing.\n this.anchor = t.closest('.cs_block_s') || t;\n t.setAttribute('contenteditable', 'true');\n t.setAttribute('spellcheck', 'false');\n t.classList.add('cre-editable');\n\n this._buildToolbar();\n\n // Bound handlers (so destroy can remove the exact same refs).\n this._onSelChange = () => this._syncFromSelection();\n this._onFocus = () => this._showToolbar();\n this._onBlur = () => this._maybeHideToolbar();\n this._onReflow = () => { if (this._toolbarVisible) this._positionToolbar(); };\n // Page Settings → \"Inline text toolbar\" toggle flips inline ↔ docked while\n // a block is open; re-place the live bar and refresh the placeholder.\n this._onDockMode = () => {\n if (this._toolbarVisible) this._positionToolbar();\n DockedPlaceholder.sync(this.doc);\n };\n this._onKey = (e) => this._onKeydown(e);\n // Keep the block hugging its content so new lines (Enter) expand the box\n // rather than overflowing a fixed height.\n this._onInputGrow = () => {\n this.anchor.style.height = 'auto';\n t.style.height = 'auto';\n if (this._toolbarVisible) this._positionToolbar();\n };\n\n this.doc.addEventListener('selectionchange', this._onSelChange);\n t.addEventListener('focus', this._onFocus);\n t.addEventListener('blur', this._onBlur);\n t.addEventListener('keydown', this._onKey);\n t.addEventListener('input', this._onInputGrow);\n this._onInputGrow();\n this.win.addEventListener('scroll', this._onReflow, true);\n this.win.addEventListener('resize', this._onReflow);\n this.doc.addEventListener('canvas:rich-toolbar-mode', this._onDockMode);\n\n // Editing has begun: register this instance and hide the docked\n // placeholder (the real, functional bar takes over).\n liveEditors.add(this);\n DockedPlaceholder.sync(this.doc);\n\n // Match Froala's behaviour: focus immediately on init.\n try { t.focus(); } catch (e) { /* */ }\n this._showToolbar();\n }\n\n destroy() {\n if (this.destroyed) return;\n this.destroyed = true;\n const t = this.target;\n this.doc.removeEventListener('selectionchange', this._onSelChange);\n t.removeEventListener('focus', this._onFocus);\n t.removeEventListener('blur', this._onBlur);\n t.removeEventListener('keydown', this._onKey);\n t.removeEventListener('input', this._onInputGrow);\n this.win.removeEventListener('scroll', this._onReflow, true);\n this.win.removeEventListener('resize', this._onReflow);\n this.doc.removeEventListener('canvas:rich-toolbar-mode', this._onDockMode);\n t.removeAttribute('contenteditable');\n t.removeAttribute('spellcheck');\n t.classList.remove('cre-editable');\n if (this._toolbar) { CustomRichEditor.untrackDockedBar(this._toolbar); this._toolbar.remove(); }\n this._toolbar = null;\n // Editing finished: bring the docked placeholder back so a bar stays\n // visible at the top in docked mode.\n liveEditors.delete(this);\n DockedPlaceholder.sync(this.doc);\n }\n\n /* ------------------------------- toolbar -------------------------------- */\n _buildToolbar() {\n const tb = this.doc.createElement('div');\n tb.className = 'cre-toolbar';\n tb.setAttribute('data-cs-chrome', ''); // never exported / never starts a drag\n\n tb.innerHTML = toolbarInnerHTML(this.fonts, this.sizes);\n\n // Keep focus/selection in the text while pressing a toolbar control.\n tb.addEventListener('mousedown', (e) => {\n // Selects + colour inputs NEED focus to open; everything else must not\n // steal it (so execCommand applies to the live selection).\n if (!e.target.closest('select, input')) e.preventDefault();\n });\n\n tb.addEventListener('click', (e) => {\n const cmdBtn = e.target.closest('button[data-cmd]');\n if (cmdBtn) { e.preventDefault(); this._runCommand(cmdBtn.dataset.cmd); return; }\n const actBtn = e.target.closest('button[data-act]');\n if (actBtn) { e.preventDefault(); this._runAction(actBtn.dataset.act); return; }\n });\n\n // Font family — keep the chosen value shown (no reset) so the dropdown\n // reflects the selected text's font.\n tb.querySelector('[data-sel=\"font\"]').addEventListener('change', (e) => {\n if (e.target.value) this._wrapStyle({ fontFamily: e.target.value });\n });\n // Font size — dropdown (like font family). Any current/custom size is\n // injected as an option by _syncFontControls so it still displays.\n tb.querySelector('[data-sel=\"size\"]').addEventListener('change', (e) => {\n const v = parseInt(e.target.value, 10);\n if (v > 0) this._wrapStyle({ fontSize: v + 'px' });\n });\n // Paragraph / heading format (H1–H6, Normal).\n tb.querySelector('[data-sel=\"format\"]').addEventListener('change', (e) => this._applyFormatBlock(e.target.value));\n // Line height.\n tb.querySelector('[data-sel=\"lineheight\"]').addEventListener('change', (e) => {\n if (e.target.value) this._setLineHeight(e.target.value);\n });\n // Letter spacing.\n tb.querySelector('[data-sel=\"letterspacing\"]').addEventListener('change', (e) => {\n if (e.target.value) this._setLetterSpacing(e.target.value);\n });\n // Text case (CSS text-transform).\n tb.querySelector('[data-sel=\"textcase\"]').addEventListener('change', (e) => {\n if (e.target.value) this._setTextCase(e.target.value);\n });\n tb.querySelector('[data-color=\"fore\"]').addEventListener('input', (e) => {\n e.target.closest('.cre-color').style.setProperty('--cre-swatch', e.target.value);\n this._setForeColor(e.target.value);\n });\n tb.querySelector('[data-color=\"back\"]').addEventListener('input', (e) => {\n e.target.closest('.cre-color').style.setProperty('--cre-swatch', e.target.value);\n this._setBackColor(e.target.value);\n });\n\n this.doc.body.appendChild(tb);\n this._toolbar = tb;\n this._toolbarVisible = false;\n }\n\n _showToolbar() {\n if (!this._toolbar) return;\n this._toolbar.classList.add('is-visible');\n this._toolbarVisible = true;\n this._positionToolbar();\n this._syncActiveStates();\n }\n\n _hideToolbar() {\n if (!this._toolbar) return;\n this._toolbar.classList.remove('is-visible');\n this._toolbarVisible = false;\n CustomRichEditor.untrackDockedBar(this._toolbar);\n }\n\n // Hide only if focus truly left the editor AND the toolbar (a click on a\n // toolbar select/colour input blurs the text but should keep the bar up).\n _maybeHideToolbar() {\n // Docked mode: the bar lives at the top for the whole edit session — never\n // hide it on blur (teardown/destroy hands back to the placeholder instead).\n const docked = (typeof this.win.isRichToolbarDocked === 'function') ? this.win.isRichToolbarDocked() : false;\n if (docked) return;\n this.win.setTimeout(() => {\n if (this.destroyed) return;\n const a = this.doc.activeElement;\n if (a && (a === this.target || this.target.contains(a) || (this._toolbar && this._toolbar.contains(a)))) return;\n this._hideToolbar();\n }, 80);\n }\n\n _positionToolbar() {\n const tb = this._toolbar;\n if (!tb) return;\n\n // Docked mode: pin the bar to the top of the canvas viewport as a\n // full-width sticky strip (CSS drives layout; trackDockedBar follows the\n // host scroll). We clear the inline left left over from inline mode.\n const docked = (typeof this.win.isRichToolbarDocked === 'function')\n ? this.win.isRichToolbarDocked() : false;\n tb.classList.toggle('cre-toolbar--docked', docked);\n if (docked) {\n tb.style.left = '';\n CustomRichEditor.trackDockedBar(tb);\n return;\n }\n CustomRichEditor.untrackDockedBar(tb);\n\n // Inline mode — anchor to the block (stable) — not the selection — so the\n // bar holds its place while the user types or moves the caret.\n const rect = (this.anchor || this.target).getBoundingClientRect();\n const tbw = tb.offsetWidth, tbh = tb.offsetHeight;\n const vw = this.win.innerWidth, vh = this.win.innerHeight;\n let top = rect.top - tbh - 8;\n if (top < 8) top = Math.min(rect.bottom + 8, vh - tbh - 8);\n let left = rect.left + (rect.width / 2) - (tbw / 2);\n if (left + tbw > vw - 8) left = vw - tbw - 8;\n if (left < 8) left = 8;\n tb.style.top = top + 'px';\n tb.style.left = left + 'px';\n }\n\n /* ----------------------------- selection -------------------------------- */\n _selectionRect() {\n const sel = this.doc.getSelection();\n if (!sel || !sel.rangeCount) return null;\n const r = sel.getRangeAt(0);\n if (!this._inEditor(r.commonAncestorContainer)) return null;\n const rect = r.getBoundingClientRect();\n if (rect && (rect.width || rect.height || rect.top)) return rect;\n return null;\n }\n\n _inEditor(node) {\n return !!node && (node === this.target || this.target.contains(node));\n }\n\n // Remember the live range so colour/select changes (which blur the text)\n // can be re-applied to what the user had selected.\n _syncFromSelection() {\n if (this.destroyed) return;\n const sel = this.doc.getSelection();\n if (sel && sel.rangeCount && this._inEditor(sel.getRangeAt(0).commonAncestorContainer)) {\n this.lastRange = sel.getRangeAt(0).cloneRange();\n // Refresh button active-states only — DON'T reposition (keeps the bar\n // anchored to the block instead of chasing the caret).\n if (this._toolbarVisible) this._syncActiveStates();\n }\n }\n\n _restoreSelection() {\n this.target.focus();\n if (!this.lastRange) return;\n const sel = this.doc.getSelection();\n sel.removeAllRanges();\n sel.addRange(this.lastRange);\n }\n\n _syncActiveStates() {\n if (!this._toolbar) return;\n const map = {\n bold: 'bold', italic: 'italic', underline: 'underline', strikeThrough: 'strikeThrough',\n justifyLeft: 'justifyLeft', justifyCenter: 'justifyCenter', justifyRight: 'justifyRight',\n justifyFull: 'justifyFull', insertOrderedList: 'insertOrderedList', insertUnorderedList: 'insertUnorderedList',\n };\n this._toolbar.querySelectorAll('button[data-cmd]').forEach((btn) => {\n const q = map[btn.dataset.cmd];\n if (!q) return;\n let on = false;\n try { on = this.doc.queryCommandState(q); } catch (e) { /* */ }\n btn.classList.toggle('is-active', on);\n });\n this._syncFontControls();\n }\n\n // Reflect the selected text's actual font size / family / line-height /\n // heading in the toolbar so the dropdowns SHOW the current style.\n _syncFontControls() {\n if (!this._toolbar) return;\n const el = this._currentEl();\n let cs = null;\n try { cs = this.win.getComputedStyle(el.nodeType === 1 ? el : el.parentElement); } catch (e) { /* */ }\n if (!cs) return;\n\n const sizeSel = this._toolbar.querySelector('[data-sel=\"size\"]');\n if (sizeSel) {\n const px = Math.round(parseFloat(cs.fontSize));\n // Drop any previously-injected custom option so they don't pile up.\n sizeSel.querySelectorAll('option[data-dynamic=\"1\"]').forEach((o) => o.remove());\n if (!isNaN(px)) {\n const val = String(px);\n // Make sure the current size exists as an option so it shows even if\n // it isn't one of the presets (e.g. 70), then select it.\n if (!Array.from(sizeSel.options).some((o) => o.value === val)) {\n const opt = this.doc.createElement('option');\n opt.value = val; opt.textContent = val;\n opt.dataset.dynamic = '1';\n sizeSel.appendChild(opt);\n }\n sizeSel.value = val;\n } else {\n sizeSel.value = '';\n }\n }\n\n const fontSel = this._toolbar.querySelector('[data-sel=\"font\"]');\n if (fontSel) {\n const cur = this._famKey(cs.fontFamily);\n let val = '';\n for (const opt of fontSel.options) { if (opt.value && this._famKey(opt.value) === cur) { val = opt.value; break; } }\n fontSel.value = val;\n }\n\n const lhSel = this._toolbar.querySelector('[data-sel=\"lineheight\"]');\n if (lhSel) {\n const fs = parseFloat(cs.fontSize), lh = parseFloat(cs.lineHeight);\n let val = '';\n if (!isNaN(fs) && !isNaN(lh) && fs) {\n const ratio = lh / fs;\n for (const opt of lhSel.options) { if (opt.value && Math.abs(parseFloat(opt.value) - ratio) < 0.09) { val = opt.value; break; } }\n }\n lhSel.value = val;\n }\n\n const fmtSel = this._toolbar.querySelector('[data-sel=\"format\"]');\n if (fmtSel) {\n const blk = this._closestBlock();\n const tag = (blk && blk !== this.target) ? blk.tagName.toLowerCase() : '';\n let val = /^h[1-6]$/.test(tag) ? tag : '';\n // Headings are applied as inline size+weight, so reflect the level by\n // matching the computed (bold) size back to a preset.\n if (!val) {\n const px = Math.round(parseFloat(cs.fontSize));\n const bold = (parseInt(cs.fontWeight, 10) || 400) >= 600;\n if (bold) {\n for (const [lvl, spec] of Object.entries(HEADING_SPEC)) {\n if (Math.round(parseFloat(spec.fontSize)) === px) { val = lvl; break; }\n }\n }\n }\n fmtSel.value = val;\n }\n\n const lsSel = this._toolbar.querySelector('[data-sel=\"letterspacing\"]');\n if (lsSel) {\n const ls = cs.letterSpacing;\n const norm = (!ls || ls === 'normal') ? '' : (parseFloat(ls) + 'px');\n let val = '';\n for (const opt of lsSel.options) { if (opt.value && opt.value === norm) { val = opt.value; break; } }\n lsSel.value = val;\n }\n\n const tcSel = this._toolbar.querySelector('[data-sel=\"textcase\"]');\n if (tcSel) {\n const tt = (cs.textTransform && cs.textTransform !== 'none') ? cs.textTransform : '';\n let val = '';\n for (const opt of tcSel.options) { if (opt.value && opt.value === tt) { val = opt.value; break; } }\n tcSel.value = val;\n }\n }\n\n _famKey(f) {\n return String(f || '').split(',')[0].replace(/['\"]/g, '').trim().toLowerCase();\n }\n\n // The element holding the current caret/selection start (within the editor).\n // IMPORTANT: when the range starts BEFORE a child element — which is exactly\n // what happens after we re-select a freshly-wrapped <span> (setStartBefore)\n // — startContainer is the PARENT. We must descend into childNodes[offset]\n // (the span) so reads hit the styled element, not the parent. Without this,\n // the toolbar shows the parent's size (e.g. 14) and changing the font family\n // would capture+restore 14, wiping the 70 the user had set.\n _currentEl() {\n const sel = this.doc.getSelection();\n if (!sel || !sel.rangeCount) return this.target;\n const range = sel.getRangeAt(0);\n let n = range.startContainer;\n if (n && n.nodeType === 1) {\n n = n.childNodes[range.startOffset] || n.childNodes[range.startOffset - 1] || n;\n }\n if (n && n.nodeType === 3) n = n.parentElement;\n return (n && n.nodeType === 1 && this._inEditor(n)) ? n : this.target;\n }\n\n // Nearest block-level element around the selection, capped at the editor.\n _closestBlock() {\n for (let n = this._currentEl(); n && n !== this.target.parentElement; n = n.parentElement) {\n if (n === this.target) return this.target;\n if (n.tagName && /^(P|DIV|LI|H[1-6]|BLOCKQUOTE|PRE)$/.test(n.tagName)) return n;\n }\n return this.target;\n }\n\n // First explicit inline value of `prop` on the chain from the selection up\n // to (and including) the editor — used to keep size/family/weight when one\n // of the others is being changed.\n _currentStyleProp(prop) {\n for (let n = this._currentEl(); n && n !== this.target.parentElement; n = n.parentElement) {\n if (n.style && n.style[prop]) return n.style[prop];\n if (n === this.target) break;\n }\n return '';\n }\n\n /* ------------------------------ commands -------------------------------- */\n _runCommand(cmd) {\n // Indent/outdent ourselves with margin-left. Native execCommand('outdent')\n // won't reverse a CSS-margin indent, so \"decrease indent\" did nothing\n // after an indent or an align change. Doing both directions by hand keeps\n // them symmetric and reliable. (User-reported.)\n if (cmd === 'indent') return this._changeIndent(1);\n if (cmd === 'outdent') return this._changeIndent(-1);\n this._restoreSelection();\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n try { this.doc.execCommand(cmd, false, null); } catch (e) { /* */ }\n this._afterChange();\n }\n\n // Step the current block's left indent by ±40px, clamped at 0.\n _changeIndent(dir) {\n this._restoreSelection();\n const STEP = 40;\n const el = this._closestBlock();\n const cur = parseFloat(this.win.getComputedStyle(el).marginLeft) || 0;\n const next = Math.max(0, cur + dir * STEP);\n if (next <= 0) el.style.removeProperty('margin-left');\n else el.style.marginLeft = next + 'px';\n this._afterChange();\n }\n\n // Letter spacing — to the selection if there is one, else the whole block\n // (mirrors line height). 'normal' clears it.\n _setLetterSpacing(value) {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n if (sel && sel.rangeCount && !sel.isCollapsed) {\n this._wrapStyle({ letterSpacing: value });\n } else {\n this.target.style.letterSpacing = value;\n this.target.querySelectorAll('[style*=\"letter-spacing\"]').forEach((el) => el.style.removeProperty('letter-spacing'));\n this._afterChange();\n }\n }\n\n // Text case via CSS text-transform: none (as typed) / uppercase /\n // capitalize / lowercase. Non-destructive — the underlying text is unchanged.\n _setTextCase(value) {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n if (sel && sel.rangeCount && !sel.isCollapsed) {\n this._wrapStyle({ textTransform: value });\n } else {\n this.target.style.textTransform = value;\n this.target.querySelectorAll('[style*=\"text-transform\"]').forEach((el) => el.style.removeProperty('text-transform'));\n this._afterChange();\n }\n }\n\n _runAction(act) {\n if (act === 'link') this._insertLink();\n }\n\n _insertLink() {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n const existing = this._closestTag('a');\n const url = this.win.prompt('Link URL:', existing ? existing.getAttribute('href') : 'https://');\n if (url === null) return;\n if (url === '') { try { this.doc.execCommand('unlink'); } catch (e) { /* */ } this._afterChange(); return; }\n if (sel && sel.isCollapsed && !existing) {\n // No selection — insert the URL as its own link text.\n const a = this.doc.createElement('a');\n a.href = url; a.textContent = url;\n sel.getRangeAt(0).insertNode(a);\n } else {\n try { this.doc.execCommand('createLink', false, url); } catch (e) { /* */ }\n }\n this._afterChange();\n }\n\n _closestTag(tag) {\n const sel = this.doc.getSelection();\n let n = sel && sel.rangeCount ? sel.getRangeAt(0).commonAncestorContainer : null;\n for (; n && n !== this.target; n = n.parentNode) {\n if (n.nodeType === 1 && n.tagName.toLowerCase() === tag) return n;\n }\n return null;\n }\n\n _setForeColor(hex) {\n this._restoreSelection();\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n try { this.doc.execCommand('foreColor', false, hex); } catch (e) { /* */ }\n this._afterChange();\n }\n\n _setBackColor(hex) {\n this._restoreSelection();\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n // hiliteColor is the standard; backColor is the WebKit fallback.\n let ok = false;\n try { ok = this.doc.execCommand('hiliteColor', false, hex); } catch (e) { /* */ }\n if (!ok) { try { this.doc.execCommand('backColor', false, hex); } catch (e) { /* */ } }\n this._afterChange();\n }\n\n // Apply arbitrary inline CSS (font-size / font-family / font-weight) to the\n // current selection. Uses the classic `fontSize=7` wrapper trick so the\n // exact selected run gets wrapped, then rewrites each wrapper to a <span>\n // carrying the requested style — works across multi-node selections.\n //\n // The trick's `fontSize` command WIPES any existing font-size on the run, so\n // before wrapping we capture the current size/family/weight the caller is\n // NOT changing and re-apply them — otherwise picking a new font family would\n // silently reset a font-size the user had set (e.g. 70 → back to default).\n _wrapStyle(styleObj) {\n this._restoreSelection();\n const sel = this.doc.getSelection();\n if (!sel || !sel.rangeCount) return;\n if (sel.isCollapsed) return; // nothing selected → nothing to style\n\n const keep = {};\n ['fontSize', 'fontFamily', 'fontWeight'].forEach((p) => {\n if (styleObj[p] != null) return;\n const v = this._currentStyleProp(p);\n if (v && !(p === 'fontWeight' && (v === '400' || v === 'normal'))) keep[p] = v;\n });\n\n try { this.doc.execCommand('styleWithCSS', false, false); } catch (e) { /* */ }\n try { this.doc.execCommand('fontSize', false, '7'); } catch (e) { /* */ }\n const spans = [];\n this.target.querySelectorAll('font[size=\"7\"]').forEach((f) => {\n const span = this.doc.createElement('span');\n Object.assign(span.style, styleObj);\n Object.keys(keep).forEach((k) => { if (!span.style[k]) span.style[k] = keep[k]; });\n // Carry over any colour the trick may have set on the <font>.\n if (f.getAttribute('color')) span.style.color = f.getAttribute('color');\n while (f.firstChild) span.appendChild(f.firstChild);\n f.replaceWith(span);\n spans.push(span);\n });\n try { this.doc.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n // Keep the just-styled text selected so the user can apply more changes\n // (font + size + colour …) without re-selecting every time.\n this._reselect(spans);\n this._afterChange();\n }\n\n // Re-select a list of nodes (from first to last) and remember the range.\n _reselect(nodes) {\n if (!nodes || !nodes.length) return;\n try {\n const range = this.doc.createRange();\n range.setStartBefore(nodes[0]);\n range.setEndAfter(nodes[nodes.length - 1]);\n const sel = this.doc.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n this.lastRange = range.cloneRange();\n } catch (e) { /* */ }\n }\n\n // Apply a heading level. With a real text selection it styles ONLY the\n // selected run (inline size + weight) so \"balan\" can become H1 without\n // turning \"mani\" in the same line into a heading too — a true block <h1>\n // would swallow the whole line. With just a caret (nothing selected) we\n // fall back to a block-level heading/paragraph for the whole line.\n _applyFormatBlock(tag) {\n this._restoreSelection();\n const t = (tag && /^h[1-6]$/i.test(tag)) ? tag.toLowerCase() : '';\n const sel = this.doc.getSelection();\n const hasSelection = sel && sel.rangeCount && !sel.isCollapsed;\n\n if (hasSelection) {\n if (t) {\n this._wrapStyle(HEADING_SPEC[t]);\n } else {\n // Normal → strip the heading look from the selection. Set explicit\n // base size + normal weight (an empty value would just inherit the\n // surrounding heading span, so it must be explicit).\n const base = this.win.getComputedStyle(this.target).fontSize;\n this._wrapStyle({ fontSize: base, fontWeight: '400' });\n }\n return;\n }\n\n // Caret only → turn the whole line into a block heading / <p>, then strip\n // explicit font-size so the heading's own size shows.\n try { this.doc.execCommand('formatBlock', false, '<' + (t ? t.toUpperCase() : 'P') + '>'); } catch (e) { /* */ }\n const blk = this._closestBlock();\n if (blk && blk !== this.target) {\n blk.style.removeProperty('font-size');\n blk.querySelectorAll('[style*=\"font-size\"]').forEach((el) => el.style.removeProperty('font-size'));\n }\n this._afterChange();\n }\n\n // Line-height applies to the WHOLE text block and clears any per-element\n // line-height so a new value always takes effect (re-setting works).\n _setLineHeight(value) {\n this._restoreSelection();\n this.target.style.lineHeight = value;\n this.target.querySelectorAll('[style*=\"line-height\"]').forEach((el) => el.style.removeProperty('line-height'));\n this._afterChange();\n }\n\n _setAlign(align) {\n const map = { left: 'justifyLeft', center: 'justifyCenter', right: 'justifyRight', justify: 'justifyFull' };\n this._runCommand(map[align] || 'justifyLeft');\n }\n\n _setParagraphWeight(styleName) {\n const w = {\n 'font-weight-light': '300', 'normal': '400', 'font-weight-medium': '500',\n 'font-weight-semi-bold': '600', 'font-weight-bold': '700', 'bold': '700',\n }[styleName] || styleName;\n this._wrapStyle({ fontWeight: String(w) });\n }\n\n // List markers (1. 2. / bullets) use the <li>'s OWN font-size, but our font\n // controls size a nested <span>, so the marker stays tiny next to big text.\n // Bring each <li> up to the largest font-size found in its content.\n _syncListMarkers() {\n this.target.querySelectorAll('li').forEach((li) => {\n let max = 0, found = '';\n li.querySelectorAll('[style]').forEach((el) => {\n const fs = el.style && el.style.fontSize;\n if (!fs) return;\n const px = parseFloat(fs);\n if (!isNaN(px) && px > max) { max = px; found = fs; }\n });\n if (found) li.style.fontSize = found;\n });\n }\n\n _afterChange() {\n // Re-sync state + let the app know content changed (mirrors a user edit).\n this._syncListMarkers();\n this._syncActiveStates();\n try {\n this.target.dispatchEvent(new this.win.Event('input', { bubbles: true }));\n } catch (e) { /* */ }\n }\n\n _onKeydown(e) {\n // Native browser shortcuts already cover bold/italic/underline/undo/redo;\n // we just refresh button states afterwards.\n if (e.key === 'Escape') { this.target.blur(); return; }\n this.win.setTimeout(() => this._syncActiveStates(), 0);\n }\n\n /* -------- Froala-compatible command bridge (froala-style-handler) -------- */\n _exec(name, rawArgs) {\n const args = Array.isArray(rawArgs) ? rawArgs : (rawArgs === undefined ? [] : [rawArgs]);\n switch (name) {\n case 'bold': case 'italic': case 'underline':\n case 'strikeThrough': case 'subscript': case 'superscript':\n case 'insertOrderedList': case 'insertUnorderedList':\n case 'outdent': case 'indent': case 'undo': case 'redo':\n return this._runCommand(name);\n case 'removeFormat':\n this._runCommand('removeFormat'); try { this.doc.execCommand('unlink'); } catch (e) { /* */ } return;\n case 'textColor': return this._setForeColor(args[0]);\n case 'backgroundColor': return this._setBackColor(args[0]);\n case 'fontSize': {\n const v = String(args[0] || '');\n return this._wrapStyle({ fontSize: /px|em|rem|%/.test(v) ? v : (v + 'px') });\n }\n case 'fontFamily': return this._wrapStyle({ fontFamily: args[0] });\n case 'letterSpacing': {\n const v = String(args[0] || 'normal');\n return this._setLetterSpacing(/px|em|rem|%/.test(v) || v === 'normal' ? v : (v + 'px'));\n }\n case 'align': return this._setAlign(args[0]);\n case 'paragraphStyle': return this._setParagraphWeight(args[0]);\n default:\n // Best-effort passthrough for any other execCommand name.\n try { this._runCommand(name); } catch (e) { /* */ }\n }\n }\n }\n\n // Let other editors (the table block) suppress the docked placeholder while\n // they display their own docked toolbar — keeps a single bar at the top.\n CustomRichEditor.setExternalDockedActive = (on) => {\n externalDockedActive = !!on;\n DockedPlaceholder.sync(document);\n };\n\n // Shared toolbar markup so the table block can render the IDENTICAL text-format\n // bar (and just append its own table-structure group), instead of maintaining\n // a second look-alike toolbar.\n CustomRichEditor.toolbarInnerHTML = (fonts, sizes) => toolbarInnerHTML(fonts, sizes);\n\n // --- Docked bar: follow the host's scroll --------------------------------\n // The editor lives in an iframe that GROWS to fit ALL pages; the HOST window\n // scrolls it. A position:fixed bar therefore pins to the iframe's content-top\n // and scrolls off-screen for blocks lower down (that's the \"toolbar hides at\n // the top\" bug). We keep the bar IN the iframe (position:absolute) and, since\n // we're same-origin, read our own <iframe> element to find where the visible\n // viewport currently starts, moving the bar there each animation frame.\n const dockedVisibleTop = () => {\n try {\n const fe = window.frameElement; // same-origin → readable; null if standalone\n if (fe) return Math.max(0, -fe.getBoundingClientRect().top);\n } catch (e) { /* cross-origin — fall through to own scroll */ }\n return window.scrollY || window.pageYOffset || 0;\n };\n const dockedBars = new Set();\n let lastDockTop = -1;\n const applyDockedTop = () => {\n const t = dockedVisibleTop();\n if (t === lastDockTop) return;\n lastDockTop = t;\n dockedBars.forEach((el) => { if (el && el.isConnected) el.style.top = t + 'px'; });\n };\n // rAF-throttle the scroll/resize bursts to one reposition per frame.\n let dockScheduled = false;\n const onDockScroll = () => {\n if (dockScheduled) return;\n dockScheduled = true;\n requestAnimationFrame(() => { dockScheduled = false; applyDockedTop(); });\n };\n let dockListenersOn = false;\n const ensureDockListeners = () => {\n if (dockListenersOn) return;\n dockListenersOn = true;\n // Capture phase catches scrolling of ANY element in the host (the window OR\n // a scroll container — we can't predict which). Same-origin lets us reach\n // the parent document; wrapped in try/catch for the cross-origin/standalone\n // case where we just watch our own scroll.\n try { if (window.parent && window.parent !== window) { window.parent.document.addEventListener('scroll', onDockScroll, true); window.parent.addEventListener('resize', onDockScroll); } } catch (e) { /* */ }\n window.addEventListener('scroll', onDockScroll, true);\n window.addEventListener('resize', onDockScroll);\n };\n CustomRichEditor.trackDockedBar = (el) => {\n if (!el) return;\n ensureDockListeners();\n dockedBars.add(el);\n el.style.top = dockedVisibleTop() + 'px';\n };\n CustomRichEditor.untrackDockedBar = (el) => {\n if (!el) return;\n dockedBars.delete(el);\n el.style.top = '';\n };\n\n window.CustomRichEditor = CustomRichEditor;\n console.log('rich-text-editor: CustomRichEditor ready');\n})();\n\n<\/script>\n <script data-src=\"./editor/inline-editor.js\">\n/**\n * Block interaction state machine.\n *\n * idle ──click──▶ selected ──click again──▶ editing\n * ▲ │ │\n * └──── click outside / Esc ────────────────────┘\n *\n * - selected: shows badge (move handle + menu), block is draggable via the handle\n * - editing : shows badge (label only) + 8 resize handles, Froala inline toolbar active\n */\n(function () {\n const RESIZE_DIRS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];\n const blockEditors = new WeakMap();\n\n let selectedBlock = null;\n let editingBlock = null;\n // Set whenever clearAll runs. The very next surface click is forced to\n // enterSelected, regardless of DOM classes — prevents a single user click\n // from doing both teardown AND edit-mode entry in one gesture.\n let forceFreshSelect = false;\n // In-progress rename of a block's badge label (Ctrl+R). Holds the block, the\n // contenteditable label span, and its original text so we can save or cancel.\n let labelEdit = null;\n // True when the most recent press landed inside the active block. A drag to\n // select text can start inside the editor and release (mouseup) outside the\n // page; the browser then fires `click` on a common ancestor outside the\n // canvas, which would otherwise look like an \"outside click\" and tear down\n // editing mid-selection. We use this to skip that teardown.\n let pressStartedInActive = false;\n\n const isFroalaAvailable = () => typeof FroalaEditor !== 'undefined';\n\n /**\n * Swallow Froala 4's async teardown error: \"Cannot read properties of\n * undefined (reading 'top')\". It fires from popup handlers that run after\n * destroy(), on detached DOM. Harmless, but pollutes the console.\n */\n window.addEventListener('error', (event) => {\n const msg = (event.message || '').toLowerCase();\n const src = (event.filename || '').toLowerCase();\n if (src.includes('froala') && msg.includes(\"reading 'top'\")) {\n event.preventDefault();\n return false;\n }\n });\n\n /* ----------------------------- badge / chrome ----------------------------- */\n\n const buildBadge = (block) => {\n const label = block.getAttribute('custom-name') || block.getAttribute('data') || 'Block';\n const badge = document.createElement('div');\n badge.className = 'cs-block-badge';\n badge.setAttribute('data-cs-chrome', '');\n // Up/Down reorder is meaningless for free-move blocks (cover page /\n // flexible) — their position is absolute, not a flow order — so omit those\n // buttons there and keep just the move handle, duplicate and delete.\n const reorderBtns = isFreeFormBlock(block) ? '' : `\n <button type=\"button\" class=\"cs-block-badge__btn\" data-cs-action=\"move-up\" title=\"Move up\">&#x25B2;</button>\n <button type=\"button\" class=\"cs-block-badge__btn\" data-cs-action=\"move-down\" title=\"Move down\">&#x25BC;</button>`;\n badge.innerHTML = `\n <span class=\"cs-block-badge__handle\" data-cs-move title=\"Drag to move\">&#x2725;</span>\n <span class=\"cs-block-badge__label\">${label}</span>\n <span class=\"cs-block-badge__actions\">${reorderBtns}\n <button type=\"button\" class=\"cs-block-badge__btn\" data-cs-action=\"duplicate\" title=\"Duplicate\">&#x2398;</button>\n <button type=\"button\" class=\"cs-block-badge__btn cs-block-badge__btn--danger\" data-cs-action=\"delete\" title=\"Delete\">&#x2715;</button>\n </span>\n `;\n return badge;\n };\n\n /* ----------------------------- rename (Ctrl+R) ----------------------------- */\n // Renaming edits ONLY the friendly `custom-name` attribute (shown in the\n // badge). The `data` attribute — the block-type identifier the rest of the\n // app reads — is never touched.\n\n const onLabelKeydown = (event) => {\n // Keep the keystroke inside the label: don't trigger the block-level\n // shortcuts (Escape teardown, arrow nudge, Ctrl+R again, copy/paste).\n event.stopPropagation();\n if (event.key === 'Enter') {\n event.preventDefault();\n commitLabelEdit(true);\n } else if (event.key === 'Escape') {\n event.preventDefault();\n commitLabelEdit(false);\n }\n };\n\n const onLabelBlur = () => commitLabelEdit(true);\n\n // Finish an in-progress rename. save=true writes the new name to custom-name;\n // save=false (or empty input) restores the original. Idempotent — safe to call\n // from Enter, blur, or a teardown (clearAll) without double-applying.\n function commitLabelEdit(save) {\n if (!labelEdit) return;\n const { block, label, original } = labelEdit;\n labelEdit = null;\n\n label.removeEventListener('keydown', onLabelKeydown);\n label.removeEventListener('blur', onLabelBlur);\n label.removeAttribute('contenteditable');\n label.classList.remove('cs-block-badge__label--editing');\n\n const next = (label.textContent || '').replace(/\\s+/g, ' ').trim();\n if (save && next) {\n block.setAttribute('custom-name', next); // data attribute stays untouched\n label.textContent = next;\n } else {\n label.textContent = original;\n }\n }\n\n // Make the selected block's badge label editable, focused, and fully selected.\n const startLabelEdit = (block) => {\n if (!block || labelEdit) return;\n const badge = block.querySelector(':scope > .cs-block-badge');\n const label = badge && badge.querySelector('.cs-block-badge__label');\n if (!label) return;\n\n labelEdit = { block, label, original: label.textContent };\n label.setAttribute('contenteditable', 'true');\n label.classList.add('cs-block-badge__label--editing');\n label.addEventListener('keydown', onLabelKeydown);\n label.addEventListener('blur', onLabelBlur);\n\n label.focus();\n const range = document.createRange();\n range.selectNodeContents(label);\n const sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n };\n\n // Run a badge action button. The button carries data-cs-action; the owning\n // block is resolved from the badge's parent. All actions delegate to the\n // FlowCanvas helpers so behaviour stays consistent with keyboard shortcuts.\n const runBadgeAction = (action, block) => {\n if (!block) return;\n const FC = window.FlowCanvas || {};\n switch (action) {\n case 'move-up': FC.moveBlock?.(block, 'up'); break;\n case 'move-down': FC.moveBlock?.(block, 'down'); break;\n case 'duplicate': FC.duplicateBlock?.(block); break;\n case 'delete': clearAll(); FC.deleteBlock?.(block); break;\n }\n };\n\n const buildResizeHandles = () => {\n const frag = document.createDocumentFragment();\n RESIZE_DIRS.forEach((dir) => {\n const h = document.createElement('div');\n h.className = 'cs-resize-handle';\n h.setAttribute('data-dir', dir);\n h.setAttribute('data-cs-chrome', '');\n frag.appendChild(h);\n });\n return frag;\n };\n\n const removeChrome = (block) => {\n block.querySelectorAll('[data-cs-chrome]').forEach((el) => {\n // The pen-shape tool manages its own overlay lifecycle (it tags the\n // overlay data-cs-chrome only so export/insert logic treats it as chrome).\n // Leave it alone — otherwise the editing UI gets wiped on attachChrome.\n if (el.classList.contains('cs-pen-overlay')) return;\n // Aiden's in-block action bar / tone popup own their own lifecycle too\n // (removed when the AI session ends) — don't let chrome teardown wipe them\n // mid-session.\n if (el.classList.contains('cs-aiden-bar') || el.classList.contains('cs-aiden-pop')) return;\n el.remove();\n });\n };\n\n /* ----------------------------- editor lifecycle ----------------------------- */\n\n const findEditTarget = (block) =>\n block.querySelector('.edit_me') || block.querySelector('.canvas-block__content') || null;\n\n const startFroala = (block) => {\n // The List block and its columns are structural containers — they have no\n // text of their own, and any `.edit_me` matches belong to nested cells.\n // Never start an editor on them (that would hijack a cell's text editor).\n if (block.dataset.blockType === 'sync-list' || block.dataset.blockType === 'sync-list-col') return;\n\n const target = findEditTarget(block);\n if (!target) return;\n\n // Section containers use custom markup and should not be initialized with Froala.\n if (block.dataset.blockType === 'section-container') {\n target.setAttribute('contenteditable', 'true');\n target.focus();\n return;\n }\n\n // Scrub any stale Froala state before re-init (defensive: handles edge cases\n // where the user click-storms between blocks faster than destroy() finishes).\n hardCleanFroala(block);\n\n // Lock the block's WIDTH at its rendered value before the editor wraps\n // things — otherwise the box can collapse. Force HEIGHT to auto (clearing\n // any pinned/resized height) so the block grows as the user types / hits\n // Enter, instead of overflowing a fixed-height box.\n const rect = block.getBoundingClientRect();\n block.style.width = `${rect.width}px`;\n block.style.maxWidth = 'none';\n block.style.height = 'auto';\n const editTarget = findEditTarget(block);\n if (editTarget) editTarget.style.height = 'auto';\n\n // Engine switch (CanvasConfig.editor.useFroala): false → our custom editor,\n // true → legacy Froala. See canvas-config.js.\n const useFroala = (typeof window.isFroalaEditor === 'function') ? window.isFroalaEditor() : false;\n\n // NEW custom editor (default). Dependency-free, edits in place, and exposes\n // the same `.commands.exec()` / `.destroy()` surface so froala-style-handler\n // + the style panel keep working.\n if (!useFroala && typeof window.CustomRichEditor === 'function') {\n try {\n const editor = new window.CustomRichEditor(target, {\n placeholder: target.getAttribute('placeholder') || 'Enter text here',\n fonts: window.FROALA_FONTS || null,\n fontSizes: ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '88', '96'],\n });\n blockEditors.set(block, editor);\n return;\n } catch (err) {\n console.warn('CustomRichEditor init failed, falling back:', err);\n }\n }\n\n // Froala needs the element to be contenteditable-friendly; it handles that itself.\n if (isFroalaAvailable()) {\n try {\n const editor = new FroalaEditor(target, {\n toolbarInline: true,\n toolbarVisibleWithoutSelection: false,\n charCounterCount: false,\n wordCounterCount: false,\n quickInsertEnabled: false,\n attribution: false,\n key: '',\n toolbarButtons: [\n ['bold', 'italic', 'underline', 'strikeThrough', 'subscript', 'superscript'],\n ['fontSize', 'fontFamily', 'textColor', 'backgroundColor'],\n ['align', 'formatOL', 'formatUL', 'outdent', 'indent'],\n ['insertLink', 'insertImage', 'insertTable', 'insertVideo'],\n ['removeFormat', 'clearFormatting', 'html'],\n ['undo', 'redo'],\n ['selectAll', 'copy', 'cut', 'paste'],\n ['quote', 'insertHR', 'lineHeight', 'letterSpacing', 'paragraphStyle'],\n ['spellChecker']\n ],\n fontSize: ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '88', '96', '100'],\n fontSizeSelection: true,\n // Font family configuration with Google Fonts + System fonts\n // Format: CSS value => Display name (keys shown in dropdown)\n fontFamily: window.FROALA_FONTS || {\n 'Arial': 'Arial',\n \"'Roboto', sans-serif\": 'Roboto',\n \"'Poppins', sans-serif\": 'Poppins',\n \"'Sora', sans-serif\": 'Sora',\n \"'Open Sans', sans-serif\": 'Open Sans',\n \"'Lato', sans-serif\": 'Lato',\n \"'Montserrat', sans-serif\": 'Montserrat',\n \"'Raleway', sans-serif\": 'Raleway',\n \"'Playfair Display', serif\": 'Playfair Display',\n \"'Inter', sans-serif\": 'Inter',\n },\n paragraphStyles: {\n 'font-weight-light': 'Light (300)',\n 'font-weight-medium': 'Medium (500)',\n 'font-weight-bold': 'Bold (700)'\n },\n placeholderText: target.getAttribute('placeholder') || 'Enter text here',\n events: {\n initialized: function () {\n this.events.focus();\n },\n contentChanged: function () {\n // Froala's built-in table insert column/row creates bare <td>s\n // that lack our `cs-cell` class (→ no border) and a junk\n // `style=\"null;…\"`. Re-stamp any static-table cells it touched.\n try {\n const root = this.el;\n if (root && window.TableBlock && typeof window.TableBlock.normalizeCells === 'function') {\n root.querySelectorAll('table.cs-table').forEach((t) => window.TableBlock.normalizeCells(t));\n }\n } catch (err) { /* normalization is best-effort */ }\n }\n }\n });\n blockEditors.set(block, editor);\n return;\n } catch (err) {\n console.warn('Froala init failed, falling back to contenteditable:', err);\n }\n }\n\n // Last resort: if the preferred engine wasn't available (e.g. Froala mode\n // but Froala didn't load), use the custom editor before bare contenteditable.\n if (typeof window.CustomRichEditor === 'function') {\n try {\n const editor = new window.CustomRichEditor(target, {\n placeholder: target.getAttribute('placeholder') || 'Enter text here',\n fonts: window.FROALA_FONTS || null,\n });\n blockEditors.set(block, editor);\n return;\n } catch (err) { /* fall through */ }\n }\n\n // Fallback\n target.setAttribute('contenteditable', 'true');\n target.focus();\n };\n\n /**\n * Strips every artifact Froala leaves behind so a future init starts clean.\n * Froala 4.x sometimes leaves the .fr-element class, contenteditable, and\n * (rarely) wrapper nodes. If we don't scrub these, the next `new FroalaEditor(target)`\n * silently no-ops and the block appears to \"skip\" the selected state.\n */\n const hardCleanFroala = (block) => {\n if (!block) return;\n\n // 1. Unwrap any .fr-box / .fr-wrapper Froala created around the edit target.\n // On a clean destroy these are gone, but we belt-and-suspender it.\n block.querySelectorAll('.fr-box').forEach((box) => {\n const inner = box.querySelector('.fr-element');\n if (inner && box.parentNode) {\n box.parentNode.replaceChild(inner, box);\n }\n });\n\n // 2. Remove any Froala UI nodes accidentally left inside the block.\n block.querySelectorAll(\n '.fr-toolbar, .fr-popup, .fr-modal, .fr-overlay, .fr-second-toolbar, .fr-placeholder, .fr-tooltip'\n ).forEach((el) => el.remove());\n\n // 3. Reset the edit target back to a plain .edit_me div.\n const target = block.querySelector('.edit_me, .fr-element');\n if (target) {\n target.removeAttribute('contenteditable');\n target.removeAttribute('spellcheck');\n target.removeAttribute('dir');\n target.classList.remove('fr-element', 'fr-view', 'fr-box');\n if (!target.classList.contains('edit_me')) {\n target.classList.add('edit_me');\n }\n // Strip every Froala data-* attribute\n Array.from(target.attributes).forEach((attr) => {\n if (attr.name.startsWith('data-fr-') || attr.name.startsWith('fr-')) {\n target.removeAttribute(attr.name);\n }\n });\n // Strip Froala-injected inline sizing (min-height, height, padding etc.)\n // that otherwise survives destroy and squashes the block on re-edit.\n ['min-height', 'height', 'max-height', 'padding', 'padding-top', 'padding-bottom',\n 'padding-left', 'padding-right', 'margin', 'overflow', 'display'].forEach((prop) => {\n target.style.removeProperty(prop);\n });\n }\n };\n\n const stopFroala = (block) => {\n const editor = blockEditors.get(block);\n if (editor) {\n // Hide UI before destroy so Froala's async popup-cleanup handlers don't\n // try to read .offset().top on a detached node (the 'top' of undefined error).\n try { editor.popups && editor.popups.hideAll && editor.popups.hideAll(); } catch (e) { }\n try { editor.toolbar && editor.toolbar.hide && editor.toolbar.hide(); } catch (e) { }\n try { editor.destroy(); } catch (e) { /* noop */ }\n blockEditors.delete(block);\n }\n // Let Froala finish its own DOM unwrap before we forcibly clean. If we\n // hardClean synchronously, we race with Froala's mouseup/blur handlers.\n hardCleanFroala(block);\n\n document.querySelectorAll(\n 'body > .fr-toolbar, body > .fr-popup, body > .fr-modal, body > .fr-overlay, body > .fr-tooltip'\n ).forEach((el) => el.remove());\n };\n\n /* ----------------------------- state transitions ----------------------------- */\n\n const clearAll = ({ internal = false } = {}) => {\n // Save an in-progress rename before the badge (and its label) is removed —\n // a blur event isn't guaranteed once the focused node is detached.\n commitLabelEdit(true);\n\n const stoppedBlock = editingBlock;\n if (stoppedBlock) {\n stopFroala(stoppedBlock);\n editingBlock = null;\n }\n selectedBlock = null;\n // Only flag fresh-select for clearAlls triggered by user input (not for\n // internal calls from enterSelected/enterEditing).\n if (!internal) forceFreshSelect = true;\n\n // Sweep the DOM for any orphaned chrome (other blocks). Skip the one we just\n // stopped — hardCleanFroala already ran on it inside stopFroala, and running\n // it again can race with Froala's async popup teardown.\n document.querySelectorAll('.cs_block_s.cs-editing, .cs_block_s.cs-selected').forEach((b) => {\n b.classList.remove('cs-editing', 'cs-selected');\n removeChrome(b);\n if (b !== stoppedBlock) {\n hardCleanFroala(b);\n }\n });\n\n // Final safety: kill any Froala UI still floating in <body>. Catches the case\n // where destroy() threw before completing.\n document.querySelectorAll(\n 'body > .fr-toolbar, body > .fr-popup, body > .fr-modal, body > .fr-overlay, body > .fr-tooltip'\n ).forEach((el) => el.remove());\n };\n\n const enterSelected = (block) => {\n if (selectedBlock === block && !editingBlock) return;\n clearAll({ internal: true });\n block.classList.add('cs-selected');\n block.appendChild(buildBadge(block));\n selectedBlock = block;\n // On a cover page the inline \"+\" line only shows while idle, so hide it the\n // instant a block is selected instead of waiting for the next pointermove\n // (refreshHover also guards on .cs-selected, but only on the next move).\n if (block.closest?.('[data-cs-cover=\"1\"]')) {\n window.FlowCanvas?.hideInlineInsert?.();\n }\n };\n\n // Drop the caret at viewport coords (x, y) inside the block's edit target.\n // Entering editing makes the element contenteditable only AFTER the click was\n // dispatched, so the browser never placed a native caret from that click and\n // the editor's focus() leaves it at the very start. We re-create the caret the\n // user aimed at from the click coordinates. No-op if the point misses the\n // editable text (e.g. the click landed on padding).\n const placeCaretFromPoint = (block, x, y) => {\n const target = findEditTarget(block);\n if (!target) return;\n\n let range = null;\n if (document.caretRangeFromPoint) {\n range = document.caretRangeFromPoint(x, y); // WebKit / Blink\n } else if (document.caretPositionFromPoint) {\n const pos = document.caretPositionFromPoint(x, y); // Firefox\n if (pos) {\n range = document.createRange();\n range.setStart(pos.offsetNode, pos.offset);\n }\n }\n if (!range || !target.contains(range.startContainer)) return;\n\n range.collapse(true);\n const sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n };\n\n const enterEditing = (block, caretPoint = null) => {\n if (editingBlock === block) return;\n // Re-use the selected chrome; just upgrade it\n if (selectedBlock && selectedBlock !== block) {\n clearAll({ internal: true });\n }\n // Ensure badge exists (we kept .cs-selected on; remove it because cs-editing replaces it visually)\n removeChrome(block);\n block.classList.remove('cs-selected');\n block.classList.add('cs-editing');\n\n // Drop the inline \"+\" insert indicator right away so it never overlaps the\n // editing surface (refreshHover also guards on .cs-editing, but that only\n // fires on the next pointermove — this hides it instantly on entry).\n window.FlowCanvas?.hideInlineInsert?.();\n\n editingBlock = block;\n selectedBlock = null;\n\n // Init Froala FIRST. Add chrome AFTER an rAF tick so Froala's async init\n // (including its `initialized` event handler) finishes mutating DOM before\n // we append the badge + resize handles. Without this delay, Froala's\n // post-init DOM work can wipe siblings on the second edit cycle.\n startFroala(block);\n\n // The editor focuses the target and parks the caret at the start. If the\n // user clicked into existing text, move it to where they clicked. Runs after\n // startFroala so the element is already contenteditable + focused.\n if (caretPoint) placeCaretFromPoint(block, caretPoint.x, caretPoint.y);\n\n const attachChrome = () => {\n if (editingBlock !== block) return; // user already moved on\n removeChrome(block);\n block.appendChild(buildBadge(block));\n block.appendChild(buildResizeHandles());\n };\n requestAnimationFrame(() => requestAnimationFrame(attachChrome));\n };\n\n /* ----------------------------- drag / move (selected only) ----------------------------- */\n\n // The editor surface hosts the click / move / resize listeners. Prefer the\n // multi-page board (.cs_paper) so EVERY page is covered — including added\n // pages and cover pages, which live in their own `.custom-form-design`\n // siblings rather than under page 1's wrapper. Falls back to the single\n // canvas when there's no multi-page board (e.g. embedded web component).\n const dropSurface = () =>\n document.querySelector('.cs_paper') || document.querySelector('.custom-form-design');\n\n const syncFlexibleContentBounds = (block) => {\n window.FlowCanvas?.syncFlexibleContentBounds?.(block);\n };\n\n const getFlexibleMoveBounds = (parent, block) => {\n const parentWidth = parent?.clientWidth ?? 0;\n const parentHeight = parent?.clientHeight ?? 0;\n const blockWidth = block?.offsetWidth ?? 0;\n const blockHeight = block?.offsetHeight ?? 0;\n const minVisible = 40;\n const overflowX = blockWidth - parentWidth;\n const overflowY = blockHeight - parentHeight;\n\n return {\n minLeft: overflowX > 0 ? (minVisible - blockWidth) : 0,\n maxLeft: overflowX > 0 ? Math.max(0, parentWidth - minVisible) : Math.max(0, parentWidth - blockWidth),\n minTop: overflowY > 0 ? (minVisible - blockHeight) : 0,\n maxTop: overflowY > 0 ? Math.max(0, parentHeight - minVisible) : Math.max(0, parentHeight - blockHeight)\n };\n };\n\n const readRenderedPosition = (block, axis) => {\n const inlineValue = parseFloat(block.style[axis]);\n if (!Number.isNaN(inlineValue)) return inlineValue;\n\n const computedValue = parseFloat(window.getComputedStyle(block)[axis]);\n return Number.isNaN(computedValue) ? 0 : computedValue;\n };\n\n let move = null;\n let wasDragged = false;\n\n /* --------- live position / size readout (free-move blocks only) ---------\n * While dragging or resizing a free-positioned block (cover page or flexible\n * container), show the live X/Y (move) or W/H (resize) right where the title\n * badge sits, then restore the title on release. */\n const metricState = { label: null, orig: null };\n\n const isFreeFormBlock = (block) =>\n !!block && (block.dataset.csInSection === '1'\n || block.classList.contains('cs-flexible-block')\n || !!block.closest?.('[data-cs-cover=\"1\"]'));\n\n const showMetric = (block, text) => {\n const badge = block.querySelector(':scope > .cs-block-badge');\n const label = badge && badge.querySelector('.cs-block-badge__label');\n if (!label) return;\n // New gesture / different block: restore the previous one and snapshot this\n // label's real title so we can put it back when the gesture ends.\n if (metricState.label !== label) {\n restoreMetric();\n metricState.label = label;\n metricState.orig = label.textContent;\n }\n label.textContent = text;\n label.classList.add('cs-block-badge__label--metric');\n };\n\n const restoreMetric = () => {\n if (metricState.label && metricState.orig != null) {\n metricState.label.textContent = metricState.orig;\n metricState.label.classList.remove('cs-block-badge__label--metric');\n }\n metricState.label = null;\n metricState.orig = null;\n };\n\n /* --------- smart alignment guides for free-move (cover / section) blocks ----\n * While dragging or resizing a free block we snap its edges/centre to the\n * page edges/centre and to other blocks' edges/centres (within a few px) and\n * draw pink guide lines — so blocks line up straight, at equal heights, and\n * share widths without guesswork. The guide overlay is editor-only chrome. */\n const ALIGN_TOL = 3; // px\n\n // Candidate snap lines in the parent: page edges + centre, and every sibling\n // block's left/centre/right (vx) and top/middle/bottom (hy).\n const alignLines = (parent, block) => {\n const vx = [0, parent.clientWidth / 2, parent.clientWidth];\n const hy = [0, parent.clientHeight / 2, parent.clientHeight];\n Array.from(parent.children).forEach((c) => {\n if (c === block || !c.matches || !c.matches('.cs_block_s')) return;\n const l = c.offsetLeft, t = c.offsetTop, w = c.offsetWidth, h = c.offsetHeight;\n vx.push(l, l + w / 2, l + w);\n hy.push(t, t + h / 2, t + h);\n });\n return { vx, hy };\n };\n\n // Best snap for the moving box [left,top,w,h]; returns adjusted left/top plus\n // the guide coordinates to draw (or null). `edges` limits which of the box's\n // own anchors may snap (used by resize so only the dragged edge snaps).\n const snapAlign = (parent, block, left, top, w, h, edges) => {\n const { vx, hy } = alignLines(parent, block);\n const ex = edges || { l: true, c: true, r: true, t: true, m: true, b: true };\n let bV = null, bH = null;\n const vAnchors = [];\n if (ex.l) vAnchors.push(0); if (ex.c) vAnchors.push(w / 2); if (ex.r) vAnchors.push(w);\n const hAnchors = [];\n if (ex.t) hAnchors.push(0); if (ex.m) hAnchors.push(h / 2); if (ex.b) hAnchors.push(h);\n vAnchors.forEach((off) => vx.forEach((gx) => {\n const d = Math.abs((left + off) - gx);\n if (d <= ALIGN_TOL && (!bV || d < bV.d)) bV = { d, guide: gx, newLeft: gx - off };\n }));\n hAnchors.forEach((off) => hy.forEach((gy) => {\n const d = Math.abs((top + off) - gy);\n if (d <= ALIGN_TOL && (!bH || d < bH.d)) bH = { d, guide: gy, newTop: gy - off };\n }));\n return {\n left: bV ? bV.newLeft : left,\n top: bH ? bH.newTop : top,\n vGuide: bV ? bV.guide : null,\n hGuide: bH ? bH.guide : null,\n };\n };\n\n let alignGuideEl = null;\n const showAlignGuides = (parent, vGuide, hGuide) => {\n if (vGuide == null && hGuide == null) { clearAlignGuides(); return; }\n if (!alignGuideEl || alignGuideEl.parentElement !== parent) {\n clearAlignGuides();\n alignGuideEl = document.createElement('div');\n alignGuideEl.className = 'cs-align-guides';\n alignGuideEl.setAttribute('data-cs-chrome', '');\n parent.appendChild(alignGuideEl);\n }\n alignGuideEl.innerHTML = '';\n if (vGuide != null) {\n const v = document.createElement('div');\n v.className = 'cs-align-guide cs-align-guide--v';\n v.style.left = `${vGuide}px`;\n alignGuideEl.appendChild(v);\n }\n if (hGuide != null) {\n const hl = document.createElement('div');\n hl.className = 'cs-align-guide cs-align-guide--h';\n hl.style.top = `${hGuide}px`;\n alignGuideEl.appendChild(hl);\n }\n };\n const clearAlignGuides = () => { if (alignGuideEl) { alignGuideEl.remove(); alignGuideEl = null; } };\n\n const onMoveDown = (event) => {\n wasDragged = false;\n // Let resize handles operate freely\n if (event.target.closest('.cs-resize-handle')) return;\n // Badge action buttons are clicks, not drags — never start a move on them.\n if (event.target.closest('[data-cs-action]')) return;\n // Renaming the badge label: clicks place the caret, they don't drag.\n if (event.target.closest('.cs-block-badge__label[contenteditable=\"true\"]')) return;\n\n const block = event.target.closest('.cs_block_s');\n if (!block) return;\n\n // Locked layers (set from the Layers panel) can't be moved.\n if (block.closest('[data-cs-locked=\"1\"]')) return;\n\n // Group containers (and dragging the whole multi-selection) are owned by\n // group.js — it manages those drags with a movement threshold so a clean\n // click can still drill into a child. Inline-editor must not start a move\n // or capture the pointer for a group, or it hijacks that click.\n if (block.classList.contains('cs-group-block')) return;\n\n // Flow canvas owns layout for top-level blocks (in a row/col). Only\n // in-section children use absolute drag.\n if (block.closest('.cs-flow-canvas') && !block.dataset.csInSection) return;\n\n // Check if what they clicked was the badge handle directly\n const isHandle = !!event.target.closest('[data-cs-move]');\n\n // If block is selected, allow dragging from ANYWHERE inside.\n // If block is actively being edited, ONLY allow dragging from the dedicated move badge handle.\n if (block.classList.contains('cs-selected') || (block.classList.contains('cs-editing') && isHandle)) {\n event.preventDefault();\n event.stopPropagation();\n\n const parent = block.offsetParent || dropSurface();\n const parentRect = parent.getBoundingClientRect();\n const blockRect = block.getBoundingClientRect();\n\n move = {\n block,\n parent,\n parentRect,\n offsetX: event.clientX - blockRect.left,\n offsetY: event.clientY - blockRect.top,\n startX: event.clientX,\n startY: event.clientY\n };\n\n const captureNode = isHandle ? event.target.closest('[data-cs-move]') : block;\n captureNode.setPointerCapture?.(event.pointerId);\n }\n };\n\n const onMoveMove = (event) => {\n if (!move) return;\n const { block, parent, parentRect, offsetX, offsetY } = move;\n const { minLeft, maxLeft, minTop, maxTop } = getFlexibleMoveBounds(parent, block);\n\n // If the parent is a section container content, we might not want to strictly constrain the bottom edge if it grows\n // But for bounding logic, using the clientHeight prevents breaking out.\n\n if (Math.abs(event.clientX - move.startX) > 3 || Math.abs(event.clientY - move.startY) > 3) {\n wasDragged = true;\n }\n\n let left = Math.min(Math.max(event.clientX - parentRect.left - offsetX, minLeft), maxLeft);\n let top = Math.min(Math.max(event.clientY - parentRect.top - offsetY, minTop), maxTop);\n\n // Smart-guide snapping (free blocks): align edges/centre to page + siblings.\n if (isFreeFormBlock(block)) {\n const a = snapAlign(parent, block, left, top, block.offsetWidth, block.offsetHeight);\n left = a.left; top = a.top;\n showAlignGuides(parent, a.vGuide, a.hGuide);\n }\n\n block.style.left = `${left}px`;\n block.style.top = `${top}px`;\n\n // Live X/Y readout in the title badge for free-move blocks.\n if (isFreeFormBlock(block)) {\n showMetric(block, `X: ${Math.round(left)} Y: ${Math.round(top)}`);\n }\n };\n\n const onMoveUp = () => {\n restoreMetric();\n clearAlignGuides();\n const moved = move?.block;\n move = null;\n // A child moved inside a group → grow/shrink the group to wrap its children.\n const group = moved?.closest?.('.cs-group-block');\n if (group && group !== moved) window.FlowCanvas?.refitGroupToChildren?.(group);\n // Decay the drag flag after a split second so future regular clicks are guaranteed clean\n setTimeout(() => { wasDragged = false; }, 100);\n };\n\n /* ----------------------------- resize (editing only) ----------------------------- */\n\n let resize = null;\n\n const onResizeDown = (event) => {\n const handle = event.target.closest('.cs-resize-handle');\n if (!handle) return;\n const block = handle.closest('.cs_block_s');\n // Trust the DOM class, not the in-memory ref (which can drift after a\n // destroy race).\n if (!block || !block.classList.contains('cs-editing')) return;\n // Locked layers can't be resized.\n if (block.closest('[data-cs-locked=\"1\"]')) return;\n\n // Flow canvas owns block width for top-level blocks. Section containers\n // and in-section blocks still allow pixel resize (height adjust for sections,\n // free-position for in-section children).\n const isInSection = !!block.dataset.csInSection;\n const isSectionContainer = block.dataset.blockType === 'section-container' ||\n block.getAttribute('data') === 'Section Container';\n\n // We allow resize on normal flow blocks as well, so we do NOT early return here anymore.\n // The onResizeMove handler correctly constraints them (skipping absolute left/top).\n\n event.preventDefault();\n event.stopPropagation();\n\n const rect = block.getBoundingClientRect();\n const parent = block.offsetParent || dropSurface();\n const parentRect = parent.getBoundingClientRect();\n\n resize = {\n block,\n dir: handle.getAttribute('data-dir'),\n startX: event.clientX,\n startY: event.clientY,\n startW: rect.width,\n startH: rect.height,\n startLeft: rect.left - parentRect.left,\n startTop: rect.top - parentRect.top,\n parent,\n parentRect\n };\n\n handle.setPointerCapture?.(event.pointerId);\n };\n\n const onResizeMove = (event) => {\n if (!resize) return;\n const { block, dir, startX, startY, startW, startH, startLeft, startTop } = resize;\n const dx = event.clientX - startX;\n const dy = event.clientY - startY;\n\n let newW = startW;\n let newH = startH;\n let newLeft = startLeft;\n let newTop = startTop;\n\n const MIN = 40;\n // Free-form blocks (flexible containers + their absolutely-positioned\n // in-section children) can be sized much smaller than a normal flow block,\n // so use the configurable flexible minimums for both width and height.\n const isFlexibleBlock =\n block.dataset.blockType === 'flexible' || block.classList.contains('cs-flexible-block');\n const isFreeForm = isFlexibleBlock || !!block.dataset.csInSection;\n const flexCfg = window.CanvasConfig?.flexible || {};\n const MIN_W = isFreeForm ? (flexCfg.minWidth ?? 20) : MIN;\n let MIN_H = isFreeForm ? (flexCfg.minHeight ?? 20) : MIN;\n\n // For a TEXT block, never shrink the height below the text's natural height\n // — otherwise the box clips and the text overflows it (the box would be\n // shorter than the content). The edit target is height:auto, so its\n // scrollHeight is the true content height regardless of the box size.\n const editEl = block.querySelector('.edit_me');\n if (editEl && editEl.closest('.cs_block_s') === block) {\n const csb = getComputedStyle(block);\n const extra = (parseFloat(csb.paddingTop) || 0) + (parseFloat(csb.paddingBottom) || 0)\n + (parseFloat(csb.borderTopWidth) || 0) + (parseFloat(csb.borderBottomWidth) || 0);\n MIN_H = Math.max(MIN_H, Math.ceil(editEl.scrollHeight + extra));\n }\n\n if (dir.includes('e')) newW = Math.max(MIN_W, startW + dx);\n if (dir.includes('s')) newH = Math.max(MIN_H, startH + dy);\n if (dir.includes('w')) {\n newW = Math.max(MIN_W, startW - dx);\n newLeft = startLeft + (startW - newW);\n }\n if (dir.includes('n')) {\n newH = Math.max(MIN_H, startH - dy);\n newTop = startTop + (startH - newH);\n }\n\n // A vertical resize must PIN the height as a min-height floor, not just set\n // `height`. Both the editor's auto-grow (_onInputGrow) and edit-entry\n // (startFroala) force `height:auto`, which would otherwise collapse a\n // manually-enlarged but empty/short block back to its content height the\n // next time it's edited. min-height survives `height:auto` while still\n // letting the box grow when the content is taller.\n const pinHeight = dir.includes('n') || dir.includes('s');\n\n // Flow-mode blocks (sections in a column) aren't absolutely positioned —\n // skip left/top, cap width to parent column.\n const isFlowBlock = block.closest('.cs-flow-canvas') && !block.dataset.csInSection;\n if (isFlowBlock) {\n block.style.height = `${newH}px`;\n if (pinHeight) block.style.minHeight = `${newH}px`;\n\n // Section containers and Flexible blocks rely on their inner content wrapper for visual height.\n // We must explicitly stretch the wrapper's minimum height to match the manual resize.\n const sectionContent = block.querySelector(':scope > .section-container-content, :scope > .cs-flexible-content');\n if (sectionContent) {\n sectionContent.style.minHeight = `${newH}px`;\n }\n\n if (dir.includes('e') || dir.includes('w')) {\n const parent = block.parentElement;\n const maxW = parent ? parent.clientWidth : newW;\n block.style.width = `${Math.min(newW, maxW)}px`;\n }\n syncFlexibleContentBounds(block);\n } else {\n // Smart-guide snapping for the dragged edge(s): line up with the page or\n // sibling blocks, keeping the opposite edge fixed.\n if (isFreeForm && block.offsetParent) {\n const { vx, hy } = alignLines(block.offsetParent, block);\n const near = (val, cands) => { let b = null; cands.forEach((g) => { const d = Math.abs(val - g); if (d <= ALIGN_TOL && (!b || d < b.d)) b = { d, g }; }); return b; };\n let vG = null, hG = null;\n if (dir.includes('e')) { const s = near(newLeft + newW, vx); if (s && (s.g - newLeft) >= MIN_W) { newW = s.g - newLeft; vG = s.g; } }\n else if (dir.includes('w')) { const s = near(newLeft, vx); if (s && ((newLeft + newW) - s.g) >= MIN_W) { newW = (newLeft + newW) - s.g; newLeft = s.g; vG = s.g; } }\n if (dir.includes('s')) { const s = near(newTop + newH, hy); if (s && (s.g - newTop) >= MIN_H) { newH = s.g - newTop; hG = s.g; } }\n else if (dir.includes('n')) { const s = near(newTop, hy); if (s && ((newTop + newH) - s.g) >= MIN_H) { newH = (newTop + newH) - s.g; newTop = s.g; hG = s.g; } }\n showAlignGuides(block.offsetParent, vG, hG);\n }\n block.style.width = `${newW}px`;\n block.style.height = `${newH}px`;\n if (pinHeight) block.style.minHeight = `${newH}px`;\n // Only update left/top if the resize direction includes that corner\n // This preserves position for non-corner resizes\n if (dir.includes('w') || dir.includes('e')) {\n // Horizontal resize - may need to update left if from west\n if (dir.includes('w')) {\n block.style.left = `${newLeft}px`;\n }\n }\n if (dir.includes('n') || dir.includes('s')) {\n // Vertical resize - may need to update top if from north\n if (dir.includes('n')) {\n block.style.top = `${newTop}px`;\n }\n }\n syncFlexibleContentBounds(block);\n }\n // Drop the max-width cap so the block actually grows\n block.style.maxWidth = 'none';\n\n // Live W/H readout in the title badge for free-form blocks.\n if (isFreeForm) {\n showMetric(block, `W: ${Math.round(newW)} H: ${Math.round(newH)}`);\n }\n };\n\n const onResizeUp = () => {\n restoreMetric();\n clearAlignGuides();\n const resized = resize?.block;\n resize = null;\n // A child resized inside a group → grow the group so it wraps all children.\n const group = resized?.closest?.('.cs-group-block');\n if (group && group !== resized) window.FlowCanvas?.refitGroupToChildren?.(group);\n };\n\n /* ----------------------------- click routing ----------------------------- */\n\n const onSurfaceClick = (event) => {\n if (wasDragged) {\n wasDragged = false;\n return;\n }\n\n // Ignore clicks on our own chrome (handled by their own listeners)\n if (event.target.closest('[data-cs-chrome]')) return;\n\n // Innermost block under the click selects directly — a child inside a group\n // selects the child, clicking the group's own area selects the group.\n const block = event.target.closest('.cs_block_s');\n\n if (!block) {\n // No block under the click. If this is the tail of a drag that began\n // inside the active block (text selection released on empty page area or\n // the .cs_paper gutter outside the page), the user never meant to click\n // away — keep editing + the selection. A real click on empty canvas has\n // its press start outside the block, so the flag is false and we tear\n // down as normal. Do NOT reset the flag here: onDocumentClick fires for\n // this same click and must read the same value — onCaptureMouseDown is\n // the sole owner and re-sets it on the next press.\n if (pressStartedInActive) {\n return;\n }\n clearAll();\n return;\n }\n\n const domSaysEditing = block.classList.contains('cs-editing');\n const domSaysSelected = block.classList.contains('cs-selected');\n\n // If a teardown just happened in this same user gesture, force fresh-select.\n // Otherwise a single click could trigger both teardown + immediate edit-mode.\n if (forceFreshSelect) {\n forceFreshSelect = false;\n enterSelected(block);\n return;\n }\n\n if (domSaysEditing) return;\n if (domSaysSelected) {\n // A group is a move-only container — never enter text editing on it.\n if (block.classList.contains('cs-group-block')) return;\n // Pass the click point so the caret lands where the user clicked.\n enterEditing(block, { x: event.clientX, y: event.clientY });\n return;\n }\n enterSelected(block);\n };\n\n const isFroalaUi = (node) => {\n // Froala renders toolbars / popups / dropdowns into document.body. Any element\n // whose class starts with \"fr-\" should be treated as part of the active editor.\n for (let el = node; el && el !== document; el = el.parentElement) {\n if (el.classList && Array.from(el.classList).some((c) => c.startsWith('fr-'))) {\n return true;\n }\n }\n return false;\n };\n\n /**\n * Captures pointerdown/mousedown BEFORE Froala / other listeners. If the user\n * is pressing down on something that isn't the current editing block (and isn't\n * Froala UI), tear down so the subsequent click lands on a clean DOM. Uses\n * pointerdown AND mousedown: Froala doesn't capture pointerdown, so even if\n * its mousedown handler stops propagation, our pointerdown still runs.\n */\n const onCaptureMouseDown = (event) => {\n // selectedBlock OR editingBlock — both should tear down on outside click\n if (!editingBlock && !selectedBlock) {\n pressStartedInActive = false;\n return;\n }\n\n const target = event.target;\n const activeBlock = editingBlock || selectedBlock;\n\n // Remember whether this gesture began inside the active block so the trailing\n // `click` (which may land outside the page if the user drag-selected past the\n // block edge) isn't mistaken for an outside click that exits edit mode.\n pressStartedInActive = activeBlock.contains(target);\n\n // Inside the currently active block — leave alone (Froala / move-handle owns it)\n if (activeBlock.contains(target)) return;\n\n // Inside our own chrome (resize handle, badge) — leave alone\n if (target.closest && target.closest('[data-cs-chrome]')) return;\n\n // Inside Froala's floating UI (toolbar/popup/dropdown rendered to body)\n if (isFroalaUi(target)) return;\n\n // Pressing on another block or empty canvas → tear down now\n clearAll();\n };\n\n const onDocumentClick = (event) => {\n // If the click target is still attached to the document, use it as-is.\n // Otherwise — Froala may have reparented/destroyed it during a state change\n // mid-click. In that case use clientX/clientY to hit-test where the click\n // ACTUALLY landed, so we don't false-positive an \"outside\" click.\n let target = event.target;\n const detached = !document.contains(target);\n if (detached && typeof event.clientX === 'number') {\n target = document.elementFromPoint(event.clientX, event.clientY) || target;\n }\n\n if (target.closest && target.closest('.custom-form-design, [data-cs-chrome]')) return;\n if (isFroalaUi(target)) return;\n\n // Tail of a text-selection drag that began inside the block and released\n // fully outside the page: the browser fires `click` on a common ancestor\n // (e.g. .cs_paper) outside the canvas. The user never meant to click away,\n // so keep editing. Read-only: onSurfaceClick may have already handled this\n // same click — the flag is owned by onCaptureMouseDown, which re-sets it on\n // the next press, so neither click handler must consume it here.\n if (pressStartedInActive) {\n return;\n }\n\n clearAll();\n };\n\n const onKeydown = (event) => {\n // A rename is in progress — the label's own listeners own the keyboard.\n if (labelEdit) return;\n\n if (event.key === 'Escape') {\n clearAll();\n return;\n }\n\n // Ctrl/Cmd+R on the active block → rename it (edit the badge label).\n // preventDefault stops the browser's reload while a block is active.\n if ((event.ctrlKey || event.metaKey) && (event.key === 'r' || event.key === 'R')) {\n const block = selectedBlock || editingBlock;\n if (block) {\n event.preventDefault();\n startLabelEdit(block);\n }\n return;\n }\n\n // Arrow-key nudge — only for in-flexible blocks in selected state\n const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];\n if (!arrowKeys.includes(event.key)) return;\n\n const block = selectedBlock;\n if (!block) return;\n\n // Shift + Up/Down on a flow block → reorder it up/down (same as the badge\n // move buttons). In-section (absolute) blocks keep the nudge behaviour below.\n if (event.shiftKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown') && !block.dataset.csInSection) {\n event.preventDefault();\n window.FlowCanvas?.moveBlock?.(block, event.key === 'ArrowUp' ? 'up' : 'down');\n return;\n }\n\n if (!block.dataset.csInSection) return;\n\n event.preventDefault();\n\n const step = event.shiftKey ? 10 : 1;\n const parent = block.offsetParent;\n const { minLeft, maxLeft, minTop, maxTop } = getFlexibleMoveBounds(parent, block);\n\n let left = readRenderedPosition(block, 'left');\n let top = readRenderedPosition(block, 'top');\n\n if (event.key === 'ArrowLeft') left -= step;\n if (event.key === 'ArrowRight') left += step;\n if (event.key === 'ArrowUp') top -= step;\n if (event.key === 'ArrowDown') top += step;\n\n block.style.left = `${Math.min(Math.max(left, minLeft), maxLeft)}px`;\n block.style.top = `${Math.min(Math.max(top, minTop), maxTop)}px`;\n };\n\n /* ----------------------------- init ----------------------------- */\n\n const init = () => {\n const surface = dropSurface();\n if (!surface) return;\n\n // Capture-phase: runs BEFORE Froala's own handlers. This is what lets us\n // tear down the editing block the moment the user presses on another block.\n // Use BOTH mousedown and pointerdown — Froala may intercept mousedown, but\n // it doesn't capture pointerdown, so this guarantees we always fire.\n document.addEventListener('mousedown', onCaptureMouseDown, true);\n document.addEventListener('pointerdown', onCaptureMouseDown, true);\n\n // HTML5 DnD from the parent sidebar never fires mousedown in this iframe.\n // Listen wide (document, capture) for dragenter/dragover/drop so we tear\n // down the active editor the moment a new block drag enters the canvas.\n // Use throttle flag — dragover fires many times per second.\n let dragTeardownDone = false;\n const onDragSignal = () => {\n if (dragTeardownDone) return;\n if (editingBlock || selectedBlock) {\n clearAll();\n dragTeardownDone = true;\n }\n };\n const resetDragFlag = () => { dragTeardownDone = false; };\n document.addEventListener('dragenter', onDragSignal, true);\n document.addEventListener('dragover', onDragSignal, true);\n document.addEventListener('drop', (e) => { onDragSignal(); resetDragFlag(); }, true);\n document.addEventListener('dragend', resetDragFlag, true);\n document.addEventListener('dragleave', resetDragFlag, true);\n\n // Bulletproof safety net: if a new .cs_block_s appears in the canvas while\n // an editor is active on a DIFFERENT block, tear down. Catches every path\n // that creates a block — drag/drop, programmatic insertion, paste, etc.\n const observer = new MutationObserver((mutations) => {\n if (!editingBlock && !selectedBlock) return;\n for (const m of mutations) {\n for (const node of m.addedNodes) {\n if (node.nodeType !== 1) continue;\n const isBlock = node.classList && node.classList.contains('cs_block_s');\n const newBlock = isBlock ? node : (node.querySelector && node.querySelector('.cs_block_s'));\n if (newBlock && newBlock !== editingBlock && newBlock !== selectedBlock) {\n clearAll();\n return;\n }\n }\n }\n });\n observer.observe(surface, { childList: true, subtree: true });\n\n // Badge action buttons (move/duplicate/delete). Capture phase + stop\n // propagation so the click never reaches onSurfaceClick (which would toggle\n // edit mode) or starts a drag.\n document.addEventListener('click', (event) => {\n const btn = event.target.closest?.('[data-cs-action]');\n if (!btn) return;\n event.preventDefault();\n event.stopPropagation();\n const block = btn.closest('.cs_block_s');\n runBadgeAction(btn.dataset.csAction, block);\n }, true);\n\n surface.addEventListener('click', onSurfaceClick);\n document.addEventListener('click', onDocumentClick);\n document.addEventListener('keydown', onKeydown);\n\n // Pointer events for move + resize (delegated, captured at surface)\n surface.addEventListener('pointerdown', onMoveDown);\n document.addEventListener('pointermove', onMoveMove);\n document.addEventListener('pointerup', onMoveUp);\n document.addEventListener('pointercancel', onMoveUp);\n\n surface.addEventListener('pointerdown', onResizeDown);\n document.addEventListener('pointermove', onResizeMove);\n document.addEventListener('pointerup', onResizeUp);\n document.addEventListener('pointercancel', onResizeUp);\n };\n\n let lastSelectionRange = null;\n const updateSelectionRange = () => {\n const sel = document.getSelection();\n if (sel && sel.rangeCount > 0) {\n lastSelectionRange = sel.getRangeAt(0).cloneRange();\n }\n };\n\n document.addEventListener('selectionchange', updateSelectionRange);\n\n const insertTextAtCursor = (text) => {\n const doc = document;\n const selection = doc.getSelection();\n let range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;\n if (!range && lastSelectionRange) {\n range = lastSelectionRange.cloneRange();\n }\n if (!range) return false;\n\n range.deleteContents();\n range.insertNode(doc.createTextNode(text));\n range.collapse(false);\n if (selection) {\n selection.removeAllRanges();\n selection.addRange(range);\n }\n return true;\n };\n\n window.EditorManager = {\n init,\n clearAll,\n // Programmatically select a block (used by the panel's \"Choose parent\"\n // buttons). Mirrors a fresh user click → idle → selected.\n select: (block) => {\n if (!block || !block.classList || !block.classList.contains('cs_block_s')) return;\n enterSelected(block);\n try { block.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch (e) { /* */ }\n },\n getSelected: () => selectedBlock,\n getEditing: () => editingBlock,\n getFroalaEditor: () => {\n if (!editingBlock) return null;\n return blockEditors.get(editingBlock) || null;\n },\n isInteracting: () => !!(move || resize),\n insertTextAtCursor,\n // Debug: prints what state the editor thinks it's in vs. the DOM.\n debug: () => {\n const selectedDom = document.querySelectorAll('.cs_block_s.cs-selected');\n const editingDom = document.querySelectorAll('.cs_block_s.cs-editing');\n const froalaUiInBody = document.querySelectorAll(\n 'body > .fr-toolbar, body > .fr-popup, body > .fr-modal, body > .fr-overlay, body > .fr-tooltip'\n );\n const frElements = document.querySelectorAll('.fr-element, .fr-box');\n console.log('[EditorManager.debug]', {\n ref_selectedBlock: selectedBlock,\n ref_editingBlock: editingBlock,\n dom_selected: selectedDom.length,\n dom_editing: editingDom.length,\n froala_ui_in_body: froalaUiInBody.length,\n leftover_fr_elements: frElements.length\n });\n }\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./editor/froala-style-handler.js\">\n/**\n * @fileoverview Froala Editor Style Commands Handler\n *\n * Provides a clean API to apply block styles through Froala editor commands\n * when a block is in editing mode. This ensures proper undo/redo integration\n * and consistency with Froala's internal state management.\n *\n * Exposes:\n * window.FroalaStyleHandler.applyColor(hexColor)\n * window.FroalaStyleHandler.applyBackgroundColor(hexColor)\n * window.FroalaStyleHandler.applyFontSize(sizeWithUnit)\n * window.FroalaStyleHandler.applyFontWeight(weightValue)\n * window.FroalaStyleHandler.applyTextAlign(alignValue)\n * window.FroalaStyleHandler.applyBold()\n * window.FroalaStyleHandler.applyItalic()\n * window.FroalaStyleHandler.applyUnderline()\n * window.FroalaStyleHandler.removeFormat()\n * window.FroalaStyleHandler.hasActiveEditor()\n * window.FroalaStyleHandler.getActiveEditor()\n */\n\n(function () {\n // Get the currently editing block's Froala editor instance\n // Uses EditorManager.getFroalaEditor() which is the authoritative source\n const getActiveFroalaEditor = () => {\n const manager = window.EditorManager;\n if (!manager || !manager.getFroalaEditor) return null;\n return manager.getFroalaEditor();\n };\n\n const applyStyleCommand = (commandName, ...args) => {\n const editor = getActiveFroalaEditor();\n if (!editor || !editor.commands) {\n console.warn(`FroalaStyleHandler: No active editor for command ${commandName}`);\n return false;\n }\n\n try {\n editor.commands.exec(commandName, args);\n return true;\n } catch (e) {\n console.error(`FroalaStyleHandler: Error executing ${commandName}:`, e);\n return false;\n }\n };\n\n window.FroalaStyleHandler = {\n /**\n * Apply text color via Froala color command\n * @param {string} hexColor - hex color code like '#FF0000'\n */\n applyColor(hexColor) {\n return applyStyleCommand('textColor', hexColor);\n },\n\n /**\n * Apply background color via Froala backgroundColor command\n * @param {string} hexColor - hex color code like '#FFFF00'\n */\n applyBackgroundColor(hexColor) {\n return applyStyleCommand('backgroundColor', hexColor);\n },\n\n /**\n * Apply font size via Froala fontSize command\n * @param {string} sizeWithUnit - like '16px' or '1.2rem'\n */\n applyFontSize(sizeWithUnit) {\n return applyStyleCommand('fontSize', sizeWithUnit);\n },\n\n /**\n * Apply font weight via Froala command\n * @param {string|number} weight - '400', '500', '600', '700' or 'normal', 'bold'\n */\n applyFontWeight(weight) {\n // Map numeric weights to Froala paragraph style names if needed\n const styleMap = {\n '300': 'font-weight-light',\n '400': 'normal',\n '500': 'font-weight-medium',\n '600': 'font-weight-semi-bold',\n '700': 'font-weight-bold',\n '800': 'bold'\n };\n\n const styleValue = styleMap[weight] || weight;\n\n // Apply via bold command for heavy weights\n if (weight === '700' || weight === '800') {\n return applyStyleCommand('bold');\n }\n\n // For paragraph styles, use paragraphStyle command\n if (styleValue in styleMap) {\n return applyStyleCommand('paragraphStyle', styleValue);\n }\n\n return false;\n },\n\n /**\n * Apply text alignment\n * @param {string} align - 'left', 'center', 'right', 'justify'\n */\n applyTextAlign(align) {\n return applyStyleCommand('align', align);\n },\n\n /**\n * Apply bold formatting\n */\n applyBold() {\n return applyStyleCommand('bold');\n },\n\n /**\n * Apply italic formatting\n */\n applyItalic() {\n return applyStyleCommand('italic');\n },\n\n /**\n * Apply underline formatting\n */\n applyUnderline() {\n return applyStyleCommand('underline');\n },\n\n /**\n * Remove all formatting\n */\n removeFormat() {\n return applyStyleCommand('removeFormat');\n },\n\n /**\n * Check if there's an active Froala editor\n */\n hasActiveEditor() {\n return !!getActiveFroalaEditor();\n },\n\n /**\n * Get the active Froala editor instance (for advanced usage)\n */\n getActiveEditor() {\n return getActiveFroalaEditor();\n },\n\n /**\n * Debug: Print current editor state\n */\n debug() {\n const editor = getActiveFroalaEditor();\n const manager = window.EditorManager;\n console.log('FroalaStyleHandler Debug:', {\n hasEditor: !!editor,\n hasEditorManager: !!manager,\n editingBlock: manager?.getEditing?.(),\n froalaEditor: editor ? 'Active' : 'Inactive'\n });\n }\n };\n\n console.log('froala-style-handler: initialized');\n})();\n\n<\/script>\n <script data-src=\"./js/block-creator.js\">\n/**\n * @fileoverview Block creator for custom form editor\n * Generates DOM elements matching the TextBlocks.js pattern from /var/www/html/cse3/\n */\n\nclass BlockCreator {\n constructor() {\n // this.utils = new Utils();\n }\n\n /**\n * Creates a unique hash for element IDs\n */\n generateHash() {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n // Fallback for environments without crypto.randomUUID\n return Math.random().toString(16).slice(2) + '-' + Math.random().toString(16).slice(2);\n }\n\n /**\n * Base wrapper element (cs_block_s)\n * @param {string} blockType - The block type (data attribute value, e.g., 'Title')\n * @param {string} additionalClasses - Extra CSS classes\n * @returns {HTMLElement}\n */\n getCsBlockSmall(blockType, additionalClasses = '') {\n const element = document.createElement('div');\n const classList = `cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block ${additionalClasses}`;\n\n element.setAttribute('class', classList);\n element.setAttribute('data', blockType);\n element.setAttribute('custom-name', blockType);\n element.setAttribute('id', 'block_' + this.generateHash());\n if (blockType == 'Title' || blockType == 'Textarea') {\n element.style.setProperty('padding-left', '10px');\n }\n\n return element;\n }\n\n /**\n * Wraps content in a cs_block_s wrapper\n * @param {HTMLElement|HTMLElement[]} contentElement\n * @param {string} blockType\n * @param {string} additionalClasses\n * @returns {HTMLElement}\n */\n addElementToCSBlock(contentElement, blockType, additionalClasses = '') {\n const block = this.getCsBlockSmall(blockType, additionalClasses);\n\n if (Array.isArray(contentElement)) {\n block.append(...contentElement);\n } else {\n block.append(contentElement);\n }\n\n return block;\n }\n\n /**\n * Creates an editable heading/title element\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createEditableHeading(options = {}) {\n const {\n text = 'Heading 2',\n className = 'add-heading-two',\n fontSize = '32px',\n // fontWeight = 100,\n placeholder = null\n } = options;\n\n const wrapper = document.createElement('div');\n wrapper.className = `edit_me ${className}`;\n wrapper.id = `dynamic_${this.generateHash()}`;\n wrapper.setAttribute('placeholder', placeholder);\n wrapper.setAttribute('default-style-id', '');\n wrapper.style.fontSize = fontSize;\n // wrapper.style.fontWeight = fontWeight;\n wrapper.style.borderColor = 'rgb(89, 91, 101)';\n\n return wrapper;\n }\n\n /**\n * Creates a body text paragraph element\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createBodyParagraph(options = {}) {\n const {\n text = '',\n fontSize = '14px',\n // fontWeight = 400,\n placeholder = 'Enter text here...'\n } = options;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'edit_me fr-element fr-view resize';\n wrapper.id = `dynamic_${this.generateHash()}`;\n wrapper.setAttribute('placeholder', placeholder);\n wrapper.style.fontSize = fontSize;\n // wrapper.style.fontWeight = fontWeight;\n if (text) {\n wrapper.innerHTML = text;\n }\n\n return wrapper;\n }\n\n /**\n * Creates a complete Title block (heading)\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createTitleBlock(options = {}) {\n const {\n text = 'Heading 2',\n className = 'add-heading-two',\n fontSize = '32px',\n // fontWeight = 100,\n position = { left: '96px', top: '75px' },\n width = 'auto',\n maxWidth = '692px'\n } = options;\n\n const content = this.createEditableHeading({\n text,\n className,\n fontSize,\n // fontWeight,\n placeholder: text\n });\n\n const block = this.addElementToCSBlock(content, 'Title');\n\n // Apply positioning and sizing\n block.style.position = 'absolute';\n block.style.left = position.left;\n block.style.top = position.top;\n if (width !== 'auto') {\n block.style.width = width;\n }\n block.style.maxWidth = maxWidth;\n\n return block;\n }\n\n /**\n * Creates a complete Body Text block (textarea/paragraph)\n * @param {Object} options\n * @returns {HTMLElement}\n */\n createBodyTextBlock(options = {}) {\n const {\n text = '',\n fontSize = '14px',\n // fontWeight = 400,\n position = { left: '96px', top: '140px' },\n width = 'auto',\n maxWidth = '692px'\n } = options;\n\n const content = this.createBodyParagraph({\n text,\n fontSize,\n // fontWeight,\n placeholder: 'Enter text here...'\n });\n\n const block = this.addElementToCSBlock(content, 'Textarea');\n\n // Apply positioning and sizing\n block.style.position = 'absolute';\n block.style.left = position.left;\n block.style.top = position.top;\n if (width !== 'auto') {\n block.style.width = width;\n }\n block.style.maxWidth = maxWidth;\n\n return block;\n }\n\n createTableBase({ headerBg = '#F8F9F9', headerColor = '#000', wrapperClass = 'edit_me fr-element fr-view' } = {}) {\n const editWrapper = document.createElement('div');\n editWrapper.className = wrapperClass;\n editWrapper.id = `dynamic_${this.generateHash()}`;\n\n const tableContainer = document.createElement('div');\n tableContainer.className = 'fr-element fr-view froala-table normal-table-width editor-table-container';\n\n const table = document.createElement('table');\n\n const thead = document.createElement('thead');\n const headerRow = document.createElement('tr');\n Object.assign(headerRow.style, {\n color: headerColor,\n });\n\n ['', '', '', ''].forEach(() => {\n const th = document.createElement('th');\n th.textContent = '';\n headerRow.appendChild(th);\n });\n thead.appendChild(headerRow);\n\n const tbody = document.createElement('tbody');\n const rows = [\n ['', '', '', ''],\n ['', '', '', ''],\n ['', '', '', '']\n ];\n\n rows.forEach((rowData) => {\n const tr = document.createElement('tr');\n rowData.forEach(() => {\n const td = document.createElement('td');\n td.textContent = '';\n tr.appendChild(td);\n });\n tbody.appendChild(tr);\n });\n\n table.appendChild(thead);\n table.appendChild(tbody);\n tableContainer.appendChild(table);\n editWrapper.appendChild(tableContainer);\n\n return editWrapper;\n }\n\n createWhiteHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#F8F9F9', headerColor: '#000' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createBlueHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#3883C1', headerColor: '#FFF' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createLightBlueHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#6493B5', headerColor: '#FFF' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createGrayHeaderTableBlock() {\n const table = this.createTableBase({ headerBg: '#6B7A85', headerColor: '#FFF' });\n return this.addElementToCSBlock(table, 'Table');\n }\n\n createSectionContainerBlock(options = {}) {\n const {\n title = 'Section heading goes here',\n body = 'Drop more blocks around this area to visually frame content groups.',\n titleFontSize = '24px',\n bodyFontSize = '14px',\n width = 'auto',\n maxWidth = '760px'\n } = options;\n\n const content = document.createElement('div');\n content.className = 'section-container-content';\n content.id = `dynamic_${this.generateHash()}`;\n\n\n const block = this.addElementToCSBlock(content, 'Section Container');\n block.style.width = width !== 'auto' ? width : '';\n block.style.maxWidth = maxWidth;\n block.style.position = 'absolute';\n return block;\n }\n\n /**\n * Creates both a title and body text block together\n * @param {Object} options\n * @returns {Object} { titleBlock, bodyBlock }\n */\n createTitleAndBodyBlock(options = {}) {\n const {\n titleText = 'Heading 2',\n titleClass = 'add-heading-two',\n titleFontSize = '32px',\n bodyText = 'Body text goes here',\n bodyFontSize = '14px',\n titlePosition = { left: '96px', top: '75px' },\n bodyPosition = { left: '96px', top: '140px' },\n spacing = 65 // gap between title and body\n } = options;\n\n const titleBlock = this.createTitleBlock({\n text: titleText,\n className: titleClass,\n fontSize: titleFontSize,\n // fontWeight: 700,\n position: titlePosition,\n maxWidth: '692px'\n });\n\n const bodyBlock = this.createBodyTextBlock({\n text: bodyText,\n fontSize: bodyFontSize,\n // fontWeight: 400,\n position: {\n left: bodyPosition.left,\n top: bodyPosition.top\n },\n maxWidth: '692px'\n });\n\n return { titleBlock, bodyBlock };\n }\n\n /* ===============================\n IMAGE / VIDEO\n =============================== */\n\n createImageWrapper(dynamicClass) {\n const el = document.createElement('div');\n el.className = `${dynamicClass} image-container`;\n el.id = `image_${this.generateHash()}`;\n return el;\n }\n\n createImageButton(type = '') {\n const btn = document.createElement('div');\n btn.className = 'img-btn resize';\n btn.id = type === 'image' ? `image_1` : `video_1`;\n\n const icongroup = document.createElement('div');\n icongroup.className = 'icon-group';\n\n const iconLayer = document.createElement('div');\n iconLayer.className = 'icon-layer';\n const icon = document.createElement('i');\n if (type === 'image') {\n icon.className = 'fa-regular fa-image plus-img-icon';\n } else {\n icon.className = 'fa-brands fa-youtube plus-img-icon';\n }\n iconLayer.appendChild(icon);\n\n const iconTitle = document.createElement('div');\n iconTitle.className = 'img-btn-txt';\n iconTitle.textContent = type === 'image' ? 'Click to select image' : 'Click to select video';\n\n icongroup.append(iconLayer, iconTitle);\n btn.appendChild(icongroup);\n\n return btn;\n }\n\n createSquareImageBlock() {\n const imageWrapper = this.createImageWrapper('square-image');\n const imageButton = this.createImageButton('image');\n imageWrapper.appendChild(imageButton);\n const block = this.addElementToCSBlock(imageWrapper, 'Image', 'cs-image-block');\n imageWrapper.style.setProperty('height', '100px', 'important');\n imageWrapper.style.setProperty('aspect-ratio', 'auto', 'important');\n return block;\n }\n\n createVideoBlock() {\n const iframe = this.createImageButton('video');\n const block = this.addElementToCSBlock(iframe, 'Video', 'cs-video-block');\n iframe.style.setProperty('height', '100px', 'important');\n return block;\n }\n}\n\n// Export or attach to window\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = BlockCreator;\n} else {\n window.BlockCreator = BlockCreator;\n}\n\n<\/script>\n <script data-src=\"./js/canvas-config.js\">\n/**\n * @fileoverview Canvas-level configuration.\n *\n * Centralized settings for the flow canvas. Tune these to change page size,\n * column behavior, drop-zone sensitivity, etc. without touching the canvas\n * logic. Loaded BEFORE flow-canvas.js so module code can read window.CanvasConfig.\n */\n(function () {\n // Page-size catalog. Keys are the IDs used everywhere (editor dropdown,\n // pdfSettings.pageSize, PDF_PAGE_SIZE env var). Width / height are in\n // CSS px at 96 dpi — these are the physical paper dimensions, so the\n // canvas .cs_margin matches what the printed PDF page will be.\n const PageSizes = {\n 'A4': { label: 'A4 Portrait', width: 794, height: 1123, format: 'A4', landscape: false },\n 'A4-Landscape': { label: 'A4 Landscape', width: 1123, height: 794, format: 'A4', landscape: true },\n 'Letter': { label: 'Letter Portrait', width: 816, height: 1056, format: 'Letter', landscape: false },\n 'Letter-Landscape': { label: 'Letter Landscape', width: 1056, height: 816, format: 'Letter', landscape: true },\n };\n\n const DEFAULT_PAGE_KEY = 'A4';\n\n const Config = {\n /** Page dimensions — controls the visible .cs_margin box. */\n page: {\n sizeKey: DEFAULT_PAGE_KEY,\n width: PageSizes[DEFAULT_PAGE_KEY].width,\n minHeight: PageSizes[DEFAULT_PAGE_KEY].height,\n paddingTop: 16,\n paddingRight: 16,\n paddingBottom: 16,\n paddingLeft: 16,\n background: '#ffffff',\n backgroundImage: '',\n borderColor: '#cfd4f6',\n borderWidth: 1,\n borderRadius: 4,\n shadow: '0 4px 20px rgba(0, 0, 0, 0.08)'\n },\n\n /** Row defaults. */\n row: {\n gap: 0, // px between columns (excluding divider)\n marginBottom: 8, // px between consecutive rows\n minHeight: 40 // px — empty row visual height\n },\n\n /** Column defaults. */\n column: {\n minWidth: 60, // px — smallest a column can shrink to during resize\n minHeight: 40, // px — empty column visual height\n padding: 4 // px — interior padding\n },\n\n /** Section container (mini-canvas inside a column). */\n section: {\n minHeight: 160, // px — default min-height after first child drop\n background: '#e5e7e7',\n defaultWidth: null // null = fill column. Set a number to cap.\n },\n\n /** Flexible (free-form) block — and its absolutely-positioned children. */\n flexible: {\n defaultHeight: 80, // px — height of a freshly dropped empty flexible block\n minHeight: 20, // px — smallest height it can be resized to\n minWidth: 20 // px — smallest width a free-form block can be resized to\n },\n\n /** Drop-zone detection sensitivity. */\n dropZone: {\n rowEdgeGap: 12, // px — distance from row edge to count as \"between rows\"\n colEdgeGap: 24 // px — distance from col edge to count as \"new column\"\n },\n\n /** Visual indicator while dragging. */\n indicator: {\n color: '#5c5cff',\n thickness: 3,\n glowAlpha: 0.25\n },\n\n /** Inline \"+\" insert control. */\n inlineInsert: {\n enabled: true\n },\n\n /**\n * Editor engine switch — flip this one boolean to swap the whole editor.\n * useFroala: false → NEW custom logic (CustomRichEditor for text blocks +\n * the custom static Table block). The default.\n * useFroala: true → LEGACY Froala editor for text; the custom Table\n * engine is turned off (Froala-era behaviour).\n */\n editor: {\n useFroala: false,\n // Placement of the CustomRichEditor (useFroala:false) toolbar while a text\n // block is being edited:\n // dockRichToolbar: false → INLINE — bar floats above the active block\n // (default; follows the caret's block).\n // dockRichToolbar: true → DOCKED — bar pins to the top of the canvas\n // viewport as a full-width sticky strip.\n // Toggled live from the Angular \"Page Settings\" panel (Inline text\n // toolbar switch) via the 'rich-toolbar:dock' postMessage.\n dockRichToolbar: false\n }\n };\n\n // Make available on window so flow-canvas.js and CSS-via-JS can read it.\n window.CanvasConfig = Config;\n window.CanvasPageSizes = PageSizes;\n\n // Convenience accessor used by inline-editor.js / table-block.js to decide\n // which editor engine to run. Reads the live config each call.\n window.isFroalaEditor = () => !!(window.CanvasConfig && window.CanvasConfig.editor && window.CanvasConfig.editor.useFroala);\n\n // True when the CustomRichEditor toolbar should dock to the top of the canvas\n // (vs. float inline above the active block). Read live by rich-text-editor.js.\n window.isRichToolbarDocked = () => !!(window.CanvasConfig && window.CanvasConfig.editor && window.CanvasConfig.editor.dockRichToolbar);\n\n // Flip the toolbar placement at runtime and notify any open editor so the live\n // toolbar re-positions immediately (without needing to re-open the block).\n window.setRichToolbarDocked = function (docked) {\n if (!window.CanvasConfig || !window.CanvasConfig.editor) return;\n window.CanvasConfig.editor.dockRichToolbar = !!docked;\n document.dispatchEvent(new CustomEvent('canvas:rich-toolbar-mode', { detail: { docked: !!docked } }));\n };\n\n // Switch the editor canvas to a different paper size at runtime.\n // Re-applies the CSS vars so every .cs_margin updates in place. Existing\n // block widths (set inline by the user) are preserved on purpose.\n window.setCanvasPageSize = function (sizeKey) {\n const size = PageSizes[sizeKey];\n if (!size) {\n console.warn('[CanvasConfig] unknown page size:', sizeKey);\n return false;\n }\n Config.page.sizeKey = sizeKey;\n Config.page.width = size.width;\n Config.page.minHeight = size.height;\n applyPageVars();\n // Notify listeners (overflow indicator, etc.) so they can recompute.\n document.dispatchEvent(new CustomEvent('canvas:page-size-changed', {\n detail: { sizeKey, width: size.width, height: size.height }\n }));\n return true;\n };\n\n // Set the page background image. Accepts a URL or base64 data URL.\n window.setCanvasPageBackground = function (imageUrl) {\n Config.page.backgroundImage = imageUrl || '';\n applyPageVars();\n };\n\n // Apply page styles to .cs_margin as CSS custom properties so the stylesheet\n // can pick them up without hardcoding values.\n const applyPageVars = () => {\n const root = document.documentElement;\n root.style.setProperty('--cs-page-width', `${Config.page.width}px`);\n root.style.setProperty('--cs-page-min-height', `${Config.page.minHeight}px`);\n root.style.setProperty('--cs-page-padding',\n `${Config.page.paddingTop}px ${Config.page.paddingRight}px ${Config.page.paddingBottom}px ${Config.page.paddingLeft}px`);\n root.style.setProperty('--cs-page-bg', Config.page.background);\n root.style.setProperty('--cs-page-bg-image', Config.page.backgroundImage ? `url(\"${Config.page.backgroundImage}\")` : 'none');\n root.style.setProperty('--cs-page-border', `${Config.page.borderWidth}px solid ${Config.page.borderColor}`);\n root.style.setProperty('--cs-page-radius', `${Config.page.borderRadius}px`);\n root.style.setProperty('--cs-page-shadow', Config.page.shadow);\n root.style.setProperty('--row-item-margin-bottom', `${Config.row.marginBottom}px`);\n root.style.setProperty('--row-item-min-height', `${Config.row.minHeight}px`);\n root.style.setProperty('--col-item-min-width', `${Config.column.minWidth}px`);\n root.style.setProperty('--col-item-min-height', `${Config.column.minHeight}px`);\n root.style.setProperty('--col-item-padding', `${Config.column.padding}px`);\n root.style.setProperty('--cs-section-min-height', `${Config.section.minHeight}px`);\n root.style.setProperty('--cs-section-bg', Config.section.background);\n root.style.setProperty('--cs-indicator-color', Config.indicator.color);\n root.style.setProperty('--cs-indicator-thickness', `${Config.indicator.thickness}px`);\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', applyPageVars);\n } else {\n applyPageVars();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/custom-form.js\">\nconst dragStoreKey = '__BROCHURE_FLOW_DRAG__';\nconst dropSurface = document.querySelector('.custom-form-design');\nconst emptyState = null; // No empty state in new structure\nconst blockCreator = new BlockCreator(); // Initialize BlockCreator\n\nconst blockPresets = {\n 'hero-section': {\n label: 'Hero Section',\n width: 640,\n html: `\n <div class=\"block-card block-card--hero\">\n <span class=\"canvas-block__tag\">Hero</span>\n <h2>Build brochure sections visually</h2>\n <p>Combine content blocks, reposition them freely, and prepare a polished export layout without leaving the canvas.</p>\n <div class=\"block-actions\">\n <span class=\"block-pill block-pill--primary\">Primary CTA</span>\n <span class=\"block-pill\">Secondary CTA</span>\n </div>\n </div>\n `\n },\n 'multi-column': {\n label: 'Multi-Column',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Columns</span>\n <div class=\"block-columns\">\n <div class=\"block-column\">\n <strong>Left Column</strong>\n <p class=\"block-paragraph\">Use this space for supporting brochure copy or highlights.</p>\n </div>\n <div class=\"block-column\">\n <strong>Right Column</strong>\n <p class=\"block-paragraph\">Drop other elements nearby and arrange the layout visually.</p>\n </div>\n </div>\n </div>\n `\n },\n 'image-text': {\n label: 'Image + Text',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Media</span>\n <div class=\"block-media\">\n <div class=\"block-image\"></div>\n <div class=\"block-copy\">\n <h3>Image with supporting content</h3>\n <p class=\"block-paragraph\">Pair visuals with concise descriptive text for product, service, or campaign sections.</p>\n </div>\n </div>\n </div>\n `\n },\n 'pricing-block': {\n label: 'Pricing Block',\n width: 650,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Pricing</span>\n <div class=\"block-pricing\">\n <div class=\"block-price-card\">\n <h3>Starter</h3>\n <strong>$19</strong>\n <p class=\"block-paragraph\">Simple intro package.</p>\n </div>\n <div class=\"block-price-card\">\n <h3>Growth</h3>\n <strong>$49</strong>\n <p class=\"block-paragraph\">Popular brochure option.</p>\n </div>\n <div class=\"block-price-card\">\n <h3>Scale</h3>\n <strong>$99</strong>\n <p class=\"block-paragraph\">Advanced presentation tier.</p>\n </div>\n </div>\n </div>\n `\n },\n footer: {\n label: 'Footer',\n width: 640,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Footer</span>\n <div class=\"block-footer\">\n <strong>BrochureFlow</strong>\n <div class=\"block-footer__links\">\n <span>About</span>\n <span>Contact</span>\n <span>Support</span>\n </div>\n </div>\n </div>\n `\n },\n heading: {\n label: 'Heading',\n width: 420,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Heading</span>\n <h2 class=\"block-heading\">Section heading goes here</h2>\n </div>\n `\n },\n 'body-text': {\n label: 'Body Text',\n width: 420,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Paragraph</span>\n <p class=\"block-paragraph\">Use this body text block for descriptive copy, feature explanations, or brochure summaries.</p>\n </div>\n `\n },\n 'label-tag': {\n label: 'Label / Tag',\n width: 220,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Label</span>\n <span class=\"block-label\">Featured</span>\n </div>\n `\n },\n image: {\n label: 'Image',\n width: 320,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Image</span>\n <div class=\"block-image\"></div>\n </div>\n `\n },\n button: {\n label: 'Button',\n width: 220,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Button</span>\n <span class=\"block-button\">Call to Action</span>\n </div>\n `\n },\n divider: {\n label: 'Divider',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Divider</span>\n <div class=\"block-divider\"></div>\n </div>\n `\n },\n spacer: {\n label: 'Spacer',\n width: 420,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Spacer</span>\n <div class=\"block-spacer\"></div>\n </div>\n `\n },\n 'section-container': {\n label: 'Section Container',\n width: 640,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Container</span>\n <div class=\"block-container\">\n <strong>Reusable section container</strong>\n <p class=\"block-paragraph\">Drop more blocks around this area to visually frame content groups.</p>\n </div>\n </div>\n `\n },\n 'table-repeater': {\n label: 'Table Repeater',\n width: 620,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Table Repeater</span>\n <div class=\"block-table\">\n <div class=\"block-table__row\">\n <span>Item Name</span>\n <span>Qty</span>\n <span>Price</span>\n </div>\n <div class=\"block-table__row\">\n <span>Dynamic Row</span>\n <span>1</span>\n <span>$24</span>\n </div>\n </div>\n </div>\n `\n },\n 'data-field': {\n label: 'Data Field',\n width: 300,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Data Field</span>\n <div class=\"block-input\">{{ customer.name }}</div>\n </div>\n `\n },\n 'list-repeater': {\n label: 'List Repeater',\n width: 340,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">List Repeater</span>\n <ul class=\"block-list\">\n <li>Dynamic list item one</li>\n <li>Dynamic list item two</li>\n <li>Dynamic list item three</li>\n </ul>\n </div>\n `\n },\n rectangle: {\n label: 'Rectangle',\n width: 320,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Rectangle</span>\n <div class=\"block-shape block-shape--rectangle\"></div>\n </div>\n `\n },\n circle: {\n label: 'Circle',\n width: 220,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Circle</span>\n <div class=\"block-shape block-shape--circle\"></div>\n </div>\n `\n },\n 'icon-badge': {\n label: 'Icon Badge',\n width: 160,\n html: `\n <div class=\"block-card\">\n <span class=\"canvas-block__tag\">Icon Badge</span>\n <div class=\"block-icon-badge\">★</div>\n </div>\n `\n }\n};\n\nlet selectedBlock = null;\nlet activeMove = null;\n\nconst clamp = (value, min, max) => Math.min(Math.max(value, min), max);\n\nconst getParentPayload = () => {\n try {\n return window.parent?.[dragStoreKey] ?? null;\n } catch (error) {\n return null;\n }\n};\n\nconst parsePayload = (value) => {\n if (!value) {\n return null;\n }\n\n try {\n return JSON.parse(value);\n } catch (error) {\n return null;\n }\n};\n\nconst getDragPayload = (event) => {\n const directPayload =\n parsePayload(event.dataTransfer?.getData('application/x-brochure-block')) ||\n parsePayload(event.dataTransfer?.getData('text/plain'));\n\n if (directPayload?.blockType) {\n console.log('custom-form: direct payload', directPayload);\n return directPayload;\n }\n\n const fallbackPayload = getParentPayload();\n console.log('custom-form: fallback payload', fallbackPayload);\n return fallbackPayload?.blockType ? fallbackPayload : null;\n};\n\nconst getParentBindingData = () => {\n try {\n // First, try to use the getter function if available\n const getter = window.parent?.__BROCHURE_FLOW_GET_BINDING_DATA__;\n if (typeof getter === 'function') {\n const data = getter();\n console.log('custom-form: Got binding data from getter:', data);\n return data;\n }\n\n // Fallback to direct property access\n const data = window.parent?.__BROCHURE_FLOW_BINDING_DATA__;\n if (data) {\n console.log('custom-form: Got binding data from property:', data);\n return data;\n }\n\n console.warn('custom-form: Binding data not found on parent window');\n console.log('custom-form: Parent window keys:', Object.keys(window.parent || {}));\n return null;\n } catch (error) {\n console.error('custom-form: Failed to get binding data:', error);\n return null;\n }\n};\n\n// Walk the binding data and collect array paths. When topLevelOnly is true we\n// stop the moment we find an array — nested arrays inside it stay hidden and\n// only surface later when the user drops a child block inside the configured\n// section (scoped arrays via computeScopedArrays).\nconst buildBindingArrays = (data, prefix = '', topLevelOnly = false) => {\n const arrays = [];\n\n if (Array.isArray(data)) {\n const preview = data.length && data[0] && typeof data[0] === 'object'\n ? Object.keys(data[0]).slice(0, 3).join(', ')\n : String(data[0] ?? '');\n\n if (prefix) {\n arrays.push({\n path: prefix,\n count: data.length,\n preview,\n scope: 'root'\n });\n }\n\n if (topLevelOnly) return arrays;\n\n if (data.length && data[0] && typeof data[0] === 'object') {\n arrays.push(...buildBindingArrays(data[0], prefix, topLevelOnly));\n }\n return arrays;\n }\n\n if (data && typeof data === 'object') {\n Object.keys(data).forEach((key) => {\n const nextPrefix = prefix ? `${prefix}.${key}` : key;\n arrays.push(...buildBindingArrays(data[key], nextPrefix, topLevelOnly));\n });\n }\n\n return arrays;\n};\n\nlet sectionBindingModal = null;\nlet sectionBindingTarget = null;\nlet sectionBindingSelection = null;\nlet sectionBindingAlias = 'section';\nlet sectionBindingSelectCallback = null;\n\nconst populateSectionBindingList = (modal, items) => {\n const listElement = modal.querySelector('.section-binding-list');\n if (!listElement) {\n return;\n }\n\n listElement.innerHTML = '';\n if (!items.length) {\n const empty = document.createElement('div');\n empty.className = 'section-binding-empty';\n empty.textContent = 'No arrays could be detected from the current JSON binding source.';\n listElement.appendChild(empty);\n return;\n }\n\n items.forEach((item) => {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'section-binding-array-item';\n button.dataset.path = item.path;\n button.innerHTML = `\n <div class=\"section-binding-array-item__row\">\n <span class=\"section-binding-array-item__path\">${item.path}</span>\n <span class=\"section-binding-array-item__count\">${item.count} items</span>\n </div>\n <div class=\"section-binding-array-item__preview\">${item.preview}</div>\n `;\n button.addEventListener('click', () => {\n sectionBindingSelectCallback?.(item);\n });\n listElement.appendChild(button);\n });\n};\n\nconst hideSectionBindingModal = () => {\n if (!sectionBindingModal) {\n return;\n }\n sectionBindingModal.hidden = true;\n sectionBindingTarget = null;\n sectionBindingSelection = null;\n};\n\nconst createSectionBindingModal = () => {\n if (sectionBindingModal) {\n return sectionBindingModal;\n }\n\n const modal = document.createElement('div');\n modal.className = 'section-binding-modal';\n modal.hidden = true;\n modal.innerHTML = `\n <div class=\"section-binding-backdrop\"></div>\n <div class=\"section-binding-card\">\n <header class=\"section-binding-header\">\n <div>\n <div class=\"section-binding-title\">Bind Section Loop</div>\n <div class=\"section-binding-subtitle\">Choose which JSON array this section should repeat over</div>\n </div>\n <button type=\"button\" class=\"section-binding-close\" aria-label=\"Close\">×</button>\n </header>\n <div class=\"section-binding-grid\">\n <div class=\"section-binding-list-card\">\n <div class=\"section-binding-list-title\">Detected arrays <span class=\"section-binding-badge\"></span></div>\n <div class=\"section-binding-list\"></div>\n </div>\n <div class=\"section-binding-config-card\">\n <div class=\"section-binding-field-label\">Selected array path</div>\n <div class=\"section-binding-field section-binding-field--readonly\" data-selected-path>← Select an array on the left</div>\n <label class=\"section-binding-field-label\">Loop variable name (alias)</label>\n <input type=\"text\" class=\"section-binding-input\" value=\"section\" />\n <div class=\"section-binding-generated-code\">\n <div class=\"section-binding-code-title\">Generated Twig</div>\n <pre class=\"section-binding-code\">Select an array to see generated code</pre>\n </div>\n </div>\n </div>\n <div class=\"section-binding-footer\">\n <button type=\"button\" class=\"section-binding-skip\">Skip — I’ll configure later</button>\n <button type=\"button\" class=\"section-binding-apply\" disabled>Apply Binding</button>\n </div>\n </div>\n `;\n\n document.body.appendChild(modal);\n\n const listElement = modal.querySelector('.section-binding-list');\n const selectedPathElement = modal.querySelector('[data-selected-path]');\n const aliasInput = modal.querySelector('.section-binding-input');\n const codeElement = modal.querySelector('.section-binding-code');\n const badgeElement = modal.querySelector('.section-binding-badge');\n const applyButton = modal.querySelector('.section-binding-apply');\n const closeButton = modal.querySelector('.section-binding-close');\n const skipButton = modal.querySelector('.section-binding-skip');\n const backdrop = modal.querySelector('.section-binding-backdrop');\n\n const renderCodePreview = () => {\n if (!sectionBindingSelection) {\n codeElement.textContent = 'Select an array to see generated code';\n return;\n }\n\n codeElement.textContent = `\\{% for ${sectionBindingAlias} in ${sectionBindingSelection.path} %}\\n {{ ${sectionBindingAlias}.field }}\\n\\{% endfor %}`;\n };\n\n const updateSelection = (item) => {\n sectionBindingSelection = item;\n selectedPathElement.textContent = item.path;\n applyButton.disabled = false;\n renderCodePreview();\n sectionBindingModal.querySelectorAll('.section-binding-array-item').forEach((button) => {\n button.classList.toggle('section-binding-array-item--selected', button.dataset.path === item.path);\n });\n };\n\n sectionBindingSelectCallback = updateSelection;\n\n aliasInput.addEventListener('input', () => {\n sectionBindingAlias = aliasInput.value.trim() || 'section';\n renderCodePreview();\n });\n\n applyButton.addEventListener('click', (event) => {\n event.preventDefault();\n event.stopPropagation();\n\n if (!sectionBindingTarget || !sectionBindingSelection) {\n return;\n }\n\n sectionBindingTarget.dataset.repeatPath = sectionBindingSelection.path;\n sectionBindingTarget.dataset.repeatAlias = sectionBindingAlias;\n sectionBindingTarget.dataset.repeatLabel = sectionBindingSelection.path;\n\n let info = sectionBindingTarget.querySelector('.section-binding-info');\n if (!info) {\n info = document.createElement('div');\n info.className = 'section-binding-info';\n sectionBindingTarget.appendChild(info);\n }\n info.textContent = `Repeats ${sectionBindingSelection.path}`;\n handleClose();\n });\n\n const handleClose = (event) => {\n if (event) {\n event.preventDefault();\n event.stopPropagation();\n }\n console.log('custom-form: Modal close triggered');\n hideSectionBindingModal();\n };\n\n closeButton.addEventListener('click', handleClose, true);\n skipButton.addEventListener('click', handleClose, true);\n backdrop.addEventListener('click', handleClose, true);\n\n modal.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') {\n handleClose(event);\n }\n });\n\n sectionBindingModal = modal;\n return sectionBindingModal;\n};\n\n// Find every block in the SAME scope as the freshly-dropped block that already\n// has a repeat binding. Root drop → search whole doc. Inside a section → search\n// within that section only. We use this to dim already-bound paths in the modal\n// so the user can't bind two siblings to the same array.\nconst collectSiblingBoundPaths = (block) => {\n const paths = new Set();\n if (!block) return paths;\n\n let searchRoot = null;\n let cur = block.parentElement || null;\n while (cur) {\n if (cur.dataset?.repeatPath) { searchRoot = cur; break; }\n if (cur.classList?.contains('cs_margin') || cur.tagName === 'BODY') {\n searchRoot = cur;\n break;\n }\n cur = cur.parentElement;\n }\n if (!searchRoot) searchRoot = document.querySelector('.cs_margin') || document.body;\n\n searchRoot.querySelectorAll('[data-repeat-path]').forEach((el) => {\n if (el === block) return;\n const path = el.dataset.repeatPath;\n if (path) paths.add(path);\n });\n return paths;\n};\n\nconst showSectionBindingModal = (block) => {\n // Modal UI lives in the parent Angular app so it can cover the full page\n // with a backdrop. We just send a message identifying which block needs a\n // binding, plus the list of detectable arrays for the user to pick from.\n if (!block.id) {\n block.id = 'block_' + Math.random().toString(36).substr(2, 9);\n }\n\n const bindingData = getParentBindingData();\n\n // Tree-aware modal:\n // - Block has ancestor repeater → arrays = full nested tree under the\n // innermost ancestor's iteration (each row carries the for-loop chain\n // needed to reach it).\n // - Root canvas drop → arrays = full nested tree from root (top-level\n // arrays + every nested array inside them).\n let scopedArrays = [];\n let ancestorAlias = '';\n if (window.FlowCanvas?.computeScopedArrays) {\n const scoped = window.FlowCanvas.computeScopedArrays(block, bindingData);\n if (scoped) {\n scopedArrays = scoped.arrays || [];\n ancestorAlias = scoped.alias || '';\n }\n }\n\n let arrays;\n if (ancestorAlias) {\n arrays = scopedArrays;\n } else if (window.FlowCanvas?.buildRootArrayTree) {\n arrays = window.FlowCanvas.buildRootArrayTree(bindingData);\n } else {\n arrays = bindingData ? buildBindingArrays(bindingData, '', true) : [];\n }\n\n const disabledPaths = [];\n\n const blockType = block.dataset.blockType ||\n block.getAttribute('data') ||\n 'block';\n\n // No arrays available → modal skip pannidu, block mattum drop aagattum.\n if (!arrays.length) {\n console.log('custom-form: no arrays available for binding, skipping modal');\n return;\n }\n\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'binding-modal:open',\n data: {\n blockId: block.id,\n blockType,\n arrays,\n ancestorAlias,\n disabledPaths\n }\n }, '*');\n } catch (e) { console.warn('Failed to open parent binding modal', e); }\n};\n\nconst updateModalWithArrays = (modal, bindingData) => {\n const arrays = bindingData ? buildBindingArrays(bindingData) : [];\n\n console.log('updateModalWithArrays: arrays =', arrays, 'count =', arrays.length);\n\n const badge = modal.querySelector('.section-binding-badge');\n badge.textContent = `${arrays.length} found`;\n const aliasInput = modal.querySelector('.section-binding-input');\n const selectedPathElement = modal.querySelector('[data-selected-path]');\n const codeElement = modal.querySelector('.section-binding-code');\n const applyButton = modal.querySelector('.section-binding-apply');\n\n aliasInput.value = 'section';\n selectedPathElement.textContent = '← Select an array on the left';\n codeElement.textContent = 'Select an array to see generated code';\n applyButton.disabled = true;\n populateSectionBindingList(modal, arrays);\n};\n\nconst setEmptyStateVisibility = () => {\n if (!emptyState || !dropSurface) {\n return;\n }\n\n emptyState.hidden = dropSurface.querySelectorAll('.canvas-block').length > 0;\n};\n\nconst clearSelection = () => {\n if (selectedBlock) {\n selectedBlock.classList.remove('canvas-block--selected');\n selectedBlock = null;\n }\n};\n\nconst selectBlock = (block) => {\n if (selectedBlock === block) {\n return;\n }\n\n clearSelection();\n selectedBlock = block;\n selectedBlock.classList.add('canvas-block--selected');\n};\n\nconst constrainBlockToSurface = (block, intendedLeft, intendedTop) => {\n const width = block.offsetWidth;\n const height = block.offsetHeight;\n const maxLeft = Math.max(0, dropSurface.clientWidth - width);\n const maxTop = Math.max(0, dropSurface.clientHeight - height);\n\n // For BlockCreator blocks, ensure they have position: absolute\n if (block.classList.contains('cs_block_s')) {\n block.style.position = 'absolute';\n }\n\n block.style.left = `${clamp(intendedLeft, 0, maxLeft)}px`;\n block.style.top = `${clamp(intendedTop, 0, maxTop)}px`;\n};\n\nconst createBlockElement = (payload) => {\n // Use BlockCreator for Title and Textarea blocks\n if (payload.blockType === 'heading' || payload.blockType === 'heading-two') {\n return blockCreator.createTitleBlock({\n text: 'New Heading',\n className: 'add-heading-two',\n fontSize: '14px'\n });\n }\n\n if (payload.blockType === 'body-text') {\n return blockCreator.createBodyTextBlock({\n text: 'Enter your text here',\n fontSize: '14px'\n });\n }\n\n const preset = blockPresets[payload.blockType] || blockPresets['body-text'];\n const contentWidth = Math.min(preset.width, dropSurface.clientWidth - 32);\n\n // For section/table blocks, use the cs_block_s editor-aware wrapper.\n const useCsBlock = payload.blockType === 'section-container' || payload.blockType === 'table-repeater';\n\n if (payload.blockType === 'table-repeater') {\n const tableBlock = blockCreator.createWhiteHeaderTableBlock();\n tableBlock.dataset.blockType = payload.blockType;\n tableBlock.style.width = `${contentWidth}px`;\n return tableBlock;\n }\n\n if (payload.blockType === 'section-container') {\n const sectionBlock = blockCreator.createSectionContainerBlock();\n sectionBlock.dataset.blockType = payload.blockType;\n sectionBlock.style.width = `${contentWidth}px`;\n return sectionBlock;\n }\n\n if (payload.blockType === 'image') {\n const imageBlock = blockCreator.createSquareImageBlock();\n imageBlock.dataset.blockType = payload.blockType;\n return imageBlock;\n }\n\n if (payload.blockType === 'video') {\n const videoBlock = blockCreator.createVideoBlock();\n videoBlock.dataset.blockType = payload.blockType;\n return videoBlock;\n }\n\n if (useCsBlock) {\n const block = blockCreator.getCsBlockSmall(payload.blockType);\n block.setAttribute('custom-name', preset.label || payload.blockType);\n block.dataset.blockType = payload.blockType;\n block.innerHTML = `<div class=\"canvas-block__content\">${preset.html}</div>`;\n block.style.width = `${contentWidth}px`;\n return block;\n }\n\n const block = document.createElement('article');\n block.className = 'canvas-block';\n block.dataset.blockType = payload.blockType;\n block.innerHTML = `\n <div class=\"canvas-block__inner\">\n <button class=\"canvas-block__remove\" type=\"button\" aria-label=\"Remove block\">×</button>\n <div class=\"canvas-block__content\">${preset.html}</div>\n </div>\n `;\n block.style.width = `${contentWidth}px`;\n\n return block;\n};\n\nconst addBlockAtPosition = (payload, clientX, clientY, targetElement) => {\n const block = createBlockElement(payload);\n\n // Find if we are dropping inside a section container\n const containerContent = targetElement ? targetElement.closest('.section-container-content') : null;\n const targetParent = containerContent || dropSurface;\n const surfaceRect = targetParent.getBoundingClientRect();\n\n targetParent.appendChild(block);\n\n // Calculate position relative to drop surface\n const left = clientX - surfaceRect.left - (block.offsetWidth / 2);\n const top = clientY - surfaceRect.top - 36;\n\n constrainBlockToSurface(block, left, top);\n\n // Legacy canvas-block selection only — cs_block_s blocks are handled by inline-editor.js\n if (block.classList.contains('canvas-block') && !block.classList.contains('cs_block_s')) {\n selectBlock(block);\n }\n\n setEmptyStateVisibility();\n\n if (payload.blockType === 'section-container') {\n showSectionBindingModal(block);\n }\n\n return block;\n};\n\nconst beginMove = (event, block) => {\n const blockRect = block.getBoundingClientRect();\n\n activeMove = {\n block,\n offsetX: event.clientX - blockRect.left,\n offsetY: event.clientY - blockRect.top\n };\n\n selectBlock(block);\n block.setPointerCapture?.(event.pointerId);\n};\n\nconst handlePointerMove = (event) => {\n if (!activeMove) {\n return;\n }\n\n const surfaceRect = dropSurface.getBoundingClientRect();\n const left = event.clientX - surfaceRect.left - activeMove.offsetX;\n const top = event.clientY - surfaceRect.top - activeMove.offsetY;\n\n constrainBlockToSurface(activeMove.block, left, top);\n};\n\nconst finishMove = (event) => {\n if (!activeMove) {\n return;\n }\n\n activeMove.block.releasePointerCapture?.(event.pointerId);\n activeMove = null;\n};\n\nconst setupCanvasEvents = () => {\n console.log('custom-form: setting up events on', dropSurface);\n dropSurface.addEventListener('click', (event) => {\n if (!event.target.closest('.canvas-block, .cs_block_s')) {\n clearSelection();\n }\n });\n\n dropSurface.addEventListener('dragenter', (event) => {\n console.log('custom-form: dragenter', event);\n if (getDragPayload(event)) {\n event.preventDefault();\n dropSurface.classList.add('drop-surface--active');\n }\n });\n\n dropSurface.addEventListener('dragover', (event) => {\n console.log('custom-form: dragover', event);\n if (getDragPayload(event)) {\n event.preventDefault();\n event.dataTransfer.dropEffect = 'copy';\n dropSurface.classList.add('drop-surface--active');\n }\n });\n\n dropSurface.addEventListener('dragleave', (event) => {\n console.log('custom-form: dragleave', event);\n if (!dropSurface.contains(event.relatedTarget)) {\n dropSurface.classList.remove('drop-surface--active');\n }\n });\n\n dropSurface.addEventListener('drop', (event) => {\n console.log('custom-form: drop event', event);\n const payload = getDragPayload(event);\n\n if (!payload) {\n console.log('custom-form: no payload');\n return;\n }\n\n // Page Break is fully handled by flow-canvas.js (splits the page);\n // we must not also drop a legacy overlay block for it.\n if (payload.blockType === 'page-break') {\n event.preventDefault();\n dropSurface.classList.remove('drop-surface--active');\n return;\n }\n\n console.log('custom-form: drop payload', payload);\n event.preventDefault();\n dropSurface.classList.remove('drop-surface--active');\n addBlockAtPosition(payload, event.clientX, event.clientY, event.target);\n });\n\n dropSurface.addEventListener('pointerdown', (event) => {\n const removeButton = event.target.closest('.canvas-block__remove');\n\n if (removeButton) {\n const block = removeButton.closest('.canvas-block, .cs_block_s');\n block?.remove();\n if (selectedBlock === block) {\n selectedBlock = null;\n }\n setEmptyStateVisibility();\n return;\n }\n\n if (event.target.closest('[contenteditable=\"true\"], .fr-element, .inline-editing, [data-cs-chrome]')) {\n return;\n }\n\n // cs_block_s blocks are moved via the badge handle (owned by inline-editor.js).\n // Only the legacy .canvas-block flow uses whole-block drag here.\n const block = event.target.closest('.canvas-block');\n\n if (!block || block.classList.contains('cs_block_s') || event.button !== 0) {\n return;\n }\n\n event.preventDefault();\n beginMove(event, block);\n });\n\n dropSurface.addEventListener('pointermove', handlePointerMove);\n dropSurface.addEventListener('pointerup', finishMove);\n dropSurface.addEventListener('pointercancel', finishMove);\n};\n\ndocument.documentElement.dataset.previewReady = 'true';\n// Old absolute drag-and-drop is disabled when flow-canvas.js is loaded.\n// Flow canvas owns drop handling; we keep this file loaded only for the\n// section-binding modal (showSectionBindingModal) which other code may invoke.\nconst FLOW_CANVAS_OWNS_DRAG = true;\nif (!FLOW_CANVAS_OWNS_DRAG) {\n setupCanvasEvents();\n}\nsetEmptyStateVisibility();\n\n// Expose the modal opener so flow-canvas.js (and other modules) can trigger it.\nwindow.showSectionBindingModal = showSectionBindingModal;\n\n// Listen for messages from parent to open binding modal for a specific block\nwindow.addEventListener('message', (event) => {\n if (event.data?.target !== 'custom-form-twig' || event.data?.type !== 'open-binding-modal-for-block') {\n return;\n }\n const blockId = event.data?.blockId;\n if (!blockId) return;\n const block = document.getElementById(blockId);\n if (block) {\n showSectionBindingModal(block);\n }\n});\n\n<\/script>\n\n <!-- Manager-facing feature flags (must load before the registry). -->\n <script data-src=\"./js/feature-flags.js\">\n/**\n * @fileoverview Editor feature flags — MANAGER-FACING ON/OFF SWITCHES.\n *\n * Flip any flag to `false` to completely hide that feature from the editor\n * (its palette entries, panels, and UI won't appear). Loaded in BOTH runtime\n * contexts (the Angular shell via src/index.html, and the iframe canvas via\n * custom-form.html) BEFORE block-registry.js, so every consumer can read it.\n *\n * Read it as `window.EditorFeatures.<flag>` (defaults to enabled if missing).\n */\n(function () {\n const FEATURES = {\n rulersGuides: true, // Rulers + draggable alignment guides\n };\n\n const g = (typeof window !== 'undefined') ? window : globalThis;\n // Keep any flags an embedder set earlier; our defaults fill the rest.\n g.EditorFeatures = Object.assign({}, FEATURES, g.EditorFeatures || {});\n if (typeof globalThis !== 'undefined') globalThis.EditorFeatures = g.EditorFeatures;\n})();\n\n<\/script>\n <!-- Single source of truth for block types (must load before factory/menus) -->\n <script data-src=\"./js/block-registry.js\">\n/**\n * @fileoverview SINGLE SOURCE OF TRUTH for every block type.\n *\n * Add / edit / remove a block in ONE place here and it automatically flows to:\n * - the sidebar palette (src/app/app.ts → librarySections)\n * - the inline \"+\" insert menu (flow/inline-insert.js → INLINE_LIBRARY)\n * - the style-properties panel (src/app/app.ts → blockStyleConfig)\n * - repeater / flexible rules (flow-canvas.js, row-col-builder.js)\n * - repeater alias defaults (src/app/app.ts → defaultAliasFor)\n *\n * The actual DOM construction for each type still lives in flow/block-factory.js\n * (keyed by `type`), because that needs imperative builder code — but every\n * `type` listed here must have a matching builder there.\n *\n * Loaded as a plain script in BOTH runtime contexts (each gets its own copy of\n * the same data):\n * - the iframe canvas → public/custom-form/custom-form.html\n * - the Angular parent → src/index.html\n *\n * Per-block fields\n * ----------------\n * type kebab-case id used everywhere (dataset.blockType, payloads)\n * label human label shown in palettes\n * icon glyph shown in palettes\n * category palette grouping title (null = never shown in palettes)\n * inSidebar show in the left sidebar palette\n * inInlineMenu show in the inline \"+\" insert menu\n * isRepeater opens the binding modal; iterates over bound data\n * restrictInFlexible cannot be dropped inside a flexible container\n * alias default loop alias for repeaters (for {% for alias in ... %})\n * styleProps style controls shown in the right properties panel\n * legacyKeys old label-based blockType keys that still map to this block\n */\n(function () {\n // ---- shared style-prop presets (keep these matching app.ts history) -------\n const STD_TEXT = ['backgroundColor', 'textColor', 'fontSize', 'fontWeight', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const BOX_RADIUS = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const BOX_NO_RADIUS = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const TABLE = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'tableBorder', 'tableBorderColor', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const TABLE_RADIUS = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius', 'tableBorder', 'tableBorderColor', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'boxShadow', 'width', 'height'];\n const SPACER = ['backgroundColor', 'width', 'height', 'opacity'];\n const VIDEO = ['width', 'height', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'boxShadow'];\n const ICON = ['textColor', 'fontSize', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'width', 'height'];\n\n // ---- the block catalog ----------------------------------------------------\n const BLOCKS = [\n // ---- Basic Elements ----\n { type: 'heading', label: 'Heading', icon: 'H', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Title'] },\n { type: 'body-text', label: 'Body Text', icon: '¶', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Textarea'] },\n { type: 'aiden', label: 'AI Writer', icon: '✦', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT },\n { type: 'label-tag', label: 'Label / Tag', icon: 'A', category: 'Basic Elements', inSidebar: true, inInlineMenu: false, styleProps: STD_TEXT },\n { type: 'image', label: 'Image', icon: '▨', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: BOX_RADIUS, legacyKeys: ['Image'] },\n { type: 'video', label: 'Video', icon: '▶', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: VIDEO, legacyKeys: ['Video'] },\n { type: 'pen-shape', label: 'Pen Shape', icon: '✒', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'opacity', 'width', 'height'] },\n { type: 'button', label: 'Button', icon: '⬡', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Button'] },\n { type: 'divider', label: 'Divider', icon: '─', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: BOX_NO_RADIUS },\n { type: 'spacer', label: 'Spacer', icon: '⋮', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: SPACER, legacyKeys: ['Spacer'] },\n { type: 'table', label: 'Table', icon: '▦', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: TABLE_RADIUS },\n { type: 'page-break', label: 'Page Break', icon: '⤵', category: 'Basic Elements', inSidebar: true, inInlineMenu: true, styleProps: [] },\n\n // ---- Data Elements ----\n { type: 'section-container', label: 'Section Container', icon: '⬢', category: 'Data Elements', inSidebar: true, inInlineMenu: true, isRepeater: true, restrictInFlexible: true, alias: 'section', styleProps: BOX_RADIUS, legacyKeys: ['Section Container'] },\n { type: 'flexible', label: 'Flexible', icon: '⬡', category: 'Data Elements', inSidebar: true, inInlineMenu: true, styleProps: BOX_RADIUS },\n { type: 'table-repeater', label: 'Table Repeater', icon: '⊟', category: 'Data Elements', inSidebar: true, inInlineMenu: true, isRepeater: true, restrictInFlexible: false, alias: 'row', styleProps: TABLE, legacyKeys: [{ key: 'Table', styleProps: TABLE_RADIUS }] },\n { type: 'data-field', label: 'Data Field', icon: '{{}}', category: 'Data Elements', inSidebar: true, inInlineMenu: true, styleProps: STD_TEXT, legacyKeys: ['Data Field'] },\n // { type: 'list-repeater', label: 'List Repeater', icon: '≡', category: 'Data Elements', inSidebar: true, inInlineMenu: true, isRepeater: true, restrictInFlexible: true, alias: 'item', styleProps: BOX_NO_RADIUS, legacyKeys: ['List Repeater'] },\n { type: 'sync-list', label: 'List', icon: '▥', category: 'Data Elements', inSidebar: true, inInlineMenu: true, restrictInFlexible: true, styleProps: BOX_RADIUS },\n\n // ---- Builder-only (created programmatically, never shown in palettes) ----\n { type: 'heading-two', label: 'Heading', icon: 'H', category: null, inSidebar: false, inInlineMenu: false, styleProps: STD_TEXT },\n { type: 'fa-icon', label: 'Icon', icon: '★', category: null, inSidebar: false, inInlineMenu: false, styleProps: ICON },\n ];\n\n // A block whose `feature` flag is switched off is hidden from every palette.\n const featureOn = (b) => {\n if (!b.feature) return true;\n const flags = (typeof window !== 'undefined' && window.EditorFeatures) ? window.EditorFeatures\n : (typeof globalThis !== 'undefined' ? globalThis.EditorFeatures : null);\n return !flags || flags[b.feature] !== false;\n };\n\n // ---- helper accessors -----------------------------------------------------\n const byType = (type) => BLOCKS.find((b) => b.type === type) || null;\n\n // Group blocks (filtered by a boolean flag, e.g. 'inSidebar' / 'inInlineMenu')\n // into palette sections, preserving first-seen category order.\n const sections = (flag) => {\n const order = [];\n const map = new Map();\n BLOCKS.forEach((b) => {\n if (!b[flag] || !b.category || !featureOn(b)) return;\n if (!map.has(b.category)) {\n map.set(b.category, []);\n order.push(b.category);\n }\n map.get(b.category).push({ type: b.type, label: b.label, icon: b.icon });\n });\n return order.map((title) => ({ title, items: map.get(title) }));\n };\n\n const repeaterTypes = () => BLOCKS.filter((b) => b.isRepeater).map((b) => b.type);\n const restrictedInFlexibleTypes = () => BLOCKS.filter((b) => b.restrictInFlexible).map((b) => b.type);\n\n const aliasFor = (type) => {\n const b = byType(type);\n return (b && b.alias) || 'item';\n };\n\n // Build the { blockType: [styleProps] } map, including legacy label keys.\n // A legacy key may be a plain string (reuses the block's styleProps) or an\n // object { key, styleProps } when the old key needs a different prop set.\n const styleConfig = () => {\n const cfg = {};\n BLOCKS.forEach((b) => {\n cfg[b.type] = b.styleProps;\n (b.legacyKeys || []).forEach((k) => {\n if (typeof k === 'string') cfg[k] = b.styleProps;\n else if (k && k.key) cfg[k.key] = k.styleProps || b.styleProps;\n });\n });\n return cfg;\n };\n\n const api = {\n blocks: BLOCKS,\n byType,\n sections,\n repeaterTypes,\n restrictedInFlexibleTypes,\n aliasFor,\n styleConfig,\n };\n\n // Expose on whichever global object is available (window in both contexts).\n if (typeof window !== 'undefined') window.FormBlockRegistry = api;\n if (typeof globalThis !== 'undefined') globalThis.FormBlockRegistry = api;\n})();\n\n<\/script>\n\n <!-- Flow canvas feature modules (must load before flow-canvas.js entry point) -->\n <script data-src=\"./js/flow/template-data.js\">\n/**\n * @fileoverview Template HTML data for predefined invoice templates.\n *\n * This file contains all HTML templates used by the block factory.\n * Each template is identified by a numeric key (1, 2, 3, etc.).\n *\n * To add a new template:\n * 1. Add a new key-value pair to TEMPLATE_HTML below\n * 2. No other code changes needed — the factory dispatcher handles it automatically\n *\n * Usage: window.FlowCanvas.TEMPLATE_HTML[n] returns the HTML string for template n\n */\n\nwindow.FlowCanvas = window.FlowCanvas || {};\n\nwindow.FlowCanvas.TEMPLATE_HTML = {\n 1: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d1\" data-cs-page-region=\"header\" style=\"padding: 0px; min-height: 90px; border: 0px; background: linear-gradient(90deg, #f97316 0%, #f97316 100%); display: flex; align-items: center;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; padding: 20px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Header\" id=\"block_header_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_d1\" placeholder=\"\" style=\"font-size: 24px; font-weight: bold; color: #ffffff; margin: 0;\">\n <div style=\"display: flex; align-items: center; gap: 12px;\">\n <div style=\"width: 45px; height: 45px; background: #ffffff; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #f97316; font-weight: bold; font-size: 20px;\">F</div>\n <div>\n <div style=\"color: #ffffff; font-size: 20px; font-weight: bold;\">FLEX CORP</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Main Content -->\n <div class=\"body-main-content\" style=\"flex: 1 1 0%; display: flex; flex-direction: column; gap: 0px; padding: 25px;\">\n\n <!-- Invoice Title -->\n <div class=\"row-item\" id=\"row_title_d1\" style=\"margin-bottom: 20px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Title\" id=\"block_title_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_title_d1\" placeholder=\"\" style=\"font-size: 28px; font-weight: bold; color: #f97316; margin: 0;\">INVOICE</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Details Section -->\n <div class=\"row-item invoice-row invoice-row--intro\" id=\"row_intro_d1\" style=\"margin-bottom: 25px; min-height: 0px; gap: 20px;\">\n <!-- Invoice To -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Bill To\" id=\"block_bill_to_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_bill_to_d1\" placeholder=\"\" style=\"font-size: 13px; color: #333;\">\n <div style=\"font-weight: bold; color: #f97316; margin-bottom: 8px; font-size: 11px; text-transform: uppercase;\">Bill To:</div>\n <div style=\"font-weight: bold; font-size: 14px; color: #333;\">{{customer_name}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{address_line1}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{address_line2}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{city}}, {{state}} {{zip_code}}</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Meta Info -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Details\" id=\"block_details_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #fff8f0; padding: 15px; border-left: 4px solid #f97316; border-radius: 2px;\">\n <div class=\"edit_me resize\" id=\"dynamic_details_d1\" placeholder=\"\" style=\"font-size: 12px; color: #333; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 80px 1fr; gap: 8px; margin-bottom: 8px;\">\n <div style=\"font-weight: bold; color: #333;\">Invoice #:</div>\n <div>{{invoice_number}}</div>\n <div style=\"font-weight: bold; color: #333;\">Date:</div>\n <div>{{Invoice_Date}}</div>\n <div style=\"font-weight: bold; color: #333;\">Due Date:</div>\n <div>{{due_date}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item invoice-row invoice-row--items\" id=\"row_items_d1\" style=\"margin-bottom: 25px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Items Table\" id=\"block_items_d1\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d1\" placeholder=\"\" style=\"font-size: 12px; width: 100%; padding: 0px; margin: 0px; color: #333; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #f97316; color: white;\">\n <th style=\"padding: 10px 12px; text-align: left; font-weight: bold; border: 0;\">Item Description</th>\n <th style=\"padding: 10px 12px; text-align: center; font-weight: bold; border: 0; width: 70px;\">Qty</th>\n <th style=\"padding: 10px 12px; text-align: right; font-weight: bold; border: 0; width: 90px;\">Unit Price</th>\n <th style=\"padding: 10px 12px; text-align: right; font-weight: bold; border: 0; width: 90px;\">Total</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #fafafa;\">\n <td style=\"padding: 10px 12px; border: 0;\">Professional Design Services</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$600.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$600.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #ffffff;\">\n <td style=\"padding: 10px 12px; border: 0;\">Web Development (40 hours)</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">40</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$75.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$3,000.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #fafafa;\">\n <td style=\"padding: 10px 12px; border: 0;\">UI/UX Testing & Revisions</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">2</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$250.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #ffffff;\">\n <td style=\"padding: 10px 12px; border: 0;\">Content Writing (20 pages)</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">20</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$50.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$1,000.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #fafafa;\">\n <td style=\"padding: 10px 12px; border: 0;\">SEO Optimization Services</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$400.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$400.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0; background: #ffffff;\">\n <td style=\"padding: 10px 12px; border: 0;\">Project Management & Support</td>\n <td style=\"padding: 10px 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$300.00</td>\n <td style=\"padding: 10px 12px; text-align: right; border: 0;\">$300.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Summary Section -->\n <div class=\"row-item invoice-row invoice-row--summary\" id=\"row_summary_d1\" style=\"margin-bottom: 25px; min-height: 0px; gap: 20px;\">\n <!-- Notes -->\n <div class=\"col-item\" style=\"flex: 1 1 55%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Notes\" id=\"block_notes_d1\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_notes_d1\" placeholder=\"\" style=\"font-size: 12px; color: #333;\">\n <div style=\"margin-bottom: 15px;\">\n <div style=\"font-weight: bold; color: #333; margin-bottom: 8px;\">Terms & Conditions:</div>\n <div style=\"font-size: 11px; line-height: 1.6; color: #666;\">\n Payment is due within 30 days of invoice date. Please reference the invoice number with your payment. Late payments will incur 1.5% monthly interest charges.\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Totals -->\n <div class=\"col-item\" style=\"flex: 0 0 45%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Totals\" id=\"block_totals_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #fff8f0; padding: 15px; border: 1px solid #f97316; border-radius: 2px;\">\n <div class=\"edit_me resize\" id=\"dynamic_totals_d1\" placeholder=\"\" style=\"font-size: 12px; color: #333; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 100px 90px; gap: 8px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #ffc699;\">\n <div style=\"text-align: right; font-weight: 500;\">Subtotal:</div>\n <div style=\"text-align: right;\">$5,800.00</div>\n <div style=\"text-align: right; font-weight: 500;\">Tax (0%):</div>\n <div style=\"text-align: right;\">$0.00</div>\n <div style=\"text-align: right; font-weight: 500;\">Shipping:</div>\n <div style=\"text-align: right;\">$0.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 100px 90px; gap: 8px; font-size: 14px;\">\n <div style=\"text-align: right; font-weight: bold; color: #f97316;\">TOTAL:</div>\n <div style=\"text-align: right; font-weight: bold; color: #f97316;\">$5,800.00</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer -->\n <div class=\"row-item\" id=\"row_footer_d1\" style=\"margin-top: 30px; padding-top: 20px; border-top: 1px solid #e0e0e0; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Footer\" id=\"block_footer_d1\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_footer_d1\" placeholder=\"\" style=\"font-size: 10px; color: #999; text-align: center;\">\n <div>Thank you for choosing FLEX CORP! | support@flexcorp.com | (555) 987-6543</div>\n <div style=\"margin-top: 5px;\">© 2024 FLEX CORP. All Rights Reserved.</div>\n </div>\n </div>\n </div>\n </div>\n\n </div>\n `,\n 2: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d2\" data-cs-page-region=\"header\" style=\"padding: 0px; min-height: 100px; border: 0px; display: flex;\">\n <!-- Dark Left Side -->\n <div class=\"col-item\" style=\"flex: 0 0 40%; max-width: 100%; background: #1a1a1a; padding: 20px; display: flex; align-items: center;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Info\" id=\"block_header_left_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_left_d2\" placeholder=\"\" style=\"font-size: 22px; font-weight: bold; color: #ffffff; margin: 0;\">\n <div style=\"color: #f97316; font-size: 26px; margin-bottom: 4px;\">●●●</div>\n <div>MODERN</div>\n <div style=\"font-size: 12px; color: #f97316; font-weight: normal;\">Creative Agency</div>\n </div>\n </div>\n </div>\n <!-- Orange Right Side -->\n <div class=\"col-item\" style=\"flex: 0 0 60%; max-width: 100%; background: #f97316; padding: 20px; display: flex; align-items: center; justify-content: flex-end;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Header Title\" id=\"block_header_right_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_right_d2\" placeholder=\"\" style=\"font-size: 32px; font-weight: bold; color: #ffffff; margin: 0; text-align: right;\">INVOICE</div>\n </div>\n </div>\n </div>\n\n <!-- Main Content -->\n <div class=\"body-main-content\" style=\"flex: 1 1 0%; display: flex; flex-direction: column; gap: 0px; padding: 30px;\">\n\n <!-- Invoice Details -->\n <div class=\"row-item invoice-row invoice-row--intro\" id=\"row_intro_d2\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Invoice To -->\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Bill To\" id=\"block_bill_to_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_bill_to_d2\" placeholder=\"\" style=\"font-size: 13px; color: #333;\">\n <div style=\"font-weight: bold; color: #f97316; margin-bottom: 10px; font-size: 12px; text-transform: uppercase;\">Invoice To:</div>\n <div style=\"font-weight: bold; font-size: 16px; color: #1a1a1a; margin-bottom: 8px;\">{{customer_name}}</div>\n <div style=\"color: #666; font-size: 12px; line-height: 1.6;\">{{address_line1}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{address_line2}}</div>\n <div style=\"color: #666; font-size: 12px;\">{{city}}, {{state}} {{zip_code}}</div>\n <div style=\"color: #666; font-size: 12px; margin-top: 8px;\">{{customer_email}}</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Meta -->\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Meta\" id=\"block_meta_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f5f5f5; padding: 15px; border-radius: 4px;\">\n <div class=\"edit_me resize\" id=\"dynamic_meta_d2\" placeholder=\"\" style=\"font-size: 13px; color: #333; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 100px 1fr; gap: 12px;\">\n <div style=\"font-weight: bold; color: #333;\">Invoice #:</div>\n <div>{{invoice_number}}</div>\n <div style=\"font-weight: bold; color: #333;\">Date:</div>\n <div>{{Invoice_Date}}</div>\n <div style=\"font-weight: bold; color: #333;\">Due Date:</div>\n <div>{{due_date}}</div>\n <div style=\"font-weight: bold; color: #333;\">PO #:</div>\n <div>{{po_number}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item invoice-row invoice-row--items\" id=\"row_items_d2\" style=\"margin-bottom: 30px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Items Table\" id=\"block_items_d2\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d2\" placeholder=\"\" style=\"font-size: 12px; width: 100%; padding: 0px; margin: 0px; color: #333; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #1a1a1a; color: white;\">\n <th style=\"padding: 12px; text-align: left; font-weight: bold; border: 0;\">Description</th>\n <th style=\"padding: 12px; text-align: center; font-weight: bold; border: 0; width: 80px;\">Qty</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Rate</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Amount</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Brand Identity Design</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$800.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$800.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Website Design & Development</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$2,500.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$2,500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Social Media Graphics (20 assets)</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">20</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$75.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$1,500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Marketing Collateral Design</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$600.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$600.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Video Production & Editing</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">3</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$400.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$1,200.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Copywriting & Content Strategy</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Summary Section -->\n <div class=\"row-item invoice-row invoice-row--summary\" id=\"row_summary_d2\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Notes -->\n <div class=\"col-item\" style=\"flex: 1 1 50%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Notes\" id=\"block_notes_d2\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_notes_d2\" placeholder=\"\" style=\"font-size: 12px; color: #333;\">\n <div style=\"margin-bottom: 15px;\">\n <div style=\"font-weight: bold; color: #333; margin-bottom: 8px; font-size: 13px;\">Special Notes:</div>\n <div style=\"font-size: 11px; line-height: 1.6; color: #666;\">\n Thank you for your project request! Upon project completion, all files and assets will be delivered in the agreed formats. Payment due upon invoice date as per our agreement.\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Payment & Totals -->\n <div class=\"col-item\" style=\"flex: 0 0 50%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Payment Info\" id=\"block_payment_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f97316; padding: 20px; border-radius: 4px; color: white;\">\n <div class=\"edit_me resize\" id=\"dynamic_payment_d2\" placeholder=\"\" style=\"font-size: 12px; color: #ffffff; margin: 0;\">\n <div style=\"margin-bottom: 15px;\">\n <div style=\"font-weight: bold; margin-bottom: 8px; font-size: 13px;\">Payment Details:</div>\n <div style=\"font-size: 11px; line-height: 1.8;\">\n <div><strong>Bank:</strong> {{bank_name}}</div>\n <div><strong>Account:</strong> {{bank_account}}</div>\n <div><strong>Routing:</strong> {{routing_number}}</div>\n </div>\n </div>\n <div style=\"border-top: 1px solid rgba(255,255,255,0.3); padding-top: 15px;\">\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; margin-bottom: 8px;\">\n <div style=\"text-align: right;\">Subtotal:</div>\n <div style=\"text-align: right;\">$6,750.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; margin-bottom: 12px;\">\n <div style=\"text-align: right;\">Tax:</div>\n <div style=\"text-align: right;\">$0.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; font-size: 14px; font-weight: bold;\">\n <div style=\"text-align: right;\">Total Due:</div>\n <div style=\"text-align: right;\">$6,750.00</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer -->\n <div class=\"row-item\" id=\"row_footer_d2\" style=\"margin-top: 30px; padding-top: 20px; border-top: 2px solid #f97316; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Footer\" id=\"block_footer_d2\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_footer_d2\" placeholder=\"\" style=\"font-size: 11px; color: #999; text-align: center;\">\n <div>Modern Creative Agency | hello@modernagency.com | 1-800-MODERN-1</div>\n <div style=\"margin-top: 6px;\">© 2024 Modern Agency. All Rights Reserved. www.modernagency.com</div>\n </div>\n </div>\n </div>\n </div>\n\n </div>`,\n 3: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d3\" data-cs-page-region=\"header\" style=\"padding: 0px; min-height: 100px; border: 0px; background: linear-gradient(135deg, #1a2a47 0%, #1a2a47 100%); display: flex; align-items: center;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; padding: 20px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Header\" id=\"block_header_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_header_d3\" placeholder=\"\" style=\"font-size: 28px; font-weight: bold; color: #ffffff; margin: 0;\">\n <div style=\"display: flex; align-items: center; gap: 15px;\">\n <div style=\"width: 50px; height: 50px; background: #c41e3a; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 24px;\">A</div>\n <div>\n <div style=\"color: #ffffff; font-size: 24px; font-weight: bold;\">ACME Corp</div>\n <div style=\"color: #c41e3a; font-size: 12px; font-weight: normal;\">Cloud & IT Solutions</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Main Content -->\n <div class=\"body-main-content\" style=\"flex: 1 1 0%; display: flex; flex-direction: column; gap: 0px; padding: 30px;\">\n\n <!-- Invoice Title -->\n <div class=\"row-item\" id=\"row_title_d3\" style=\"margin-bottom: 20px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Title\" id=\"block_title_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_title_d3\" placeholder=\"\" style=\"font-size: 32px; font-weight: bold; color: #1a2a47; margin: 0;\">INVOICE</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice To / From Section -->\n <div class=\"row-item invoice-row invoice-row--intro\" id=\"row_intro_d3\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Invoice To -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice To\" id=\"block_invoice_to_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_invoice_to_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47;\">\n <div style=\"font-weight: bold; color: #c41e3a; margin-bottom: 8px; font-size: 12px; text-transform: uppercase;\">Bill To:</div>\n <div style=\"font-weight: bold; font-size: 15px;\">{{customer_name}}</div>\n <div>{{address_line1}}</div>\n <div>{{address_line2}}</div>\n <div>{{city}}, {{state}} {{zip_code}}</div>\n <div style=\"margin-top: 8px;\">{{customer_email}}</div>\n </div>\n </div>\n </div>\n\n <!-- Invoice Meta Info -->\n <div class=\"col-item\" style=\"flex: 0 0 48%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Info\" id=\"block_invoice_info_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f5f5f5; padding: 15px; border-radius: 4px; border-left: 4px solid #c41e3a;\">\n <div class=\"edit_me resize\" id=\"dynamic_invoice_info_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 100px 1fr; gap: 10px;\">\n <div style=\"font-weight: bold; color: #1a2a47;\">Invoice #:</div>\n <div>{{invoice_number}}</div>\n <div style=\"font-weight: bold; color: #1a2a47;\">Date:</div>\n <div>{{Invoice_Date}}</div>\n <div style=\"font-weight: bold; color: #1a2a47;\">Due Date:</div>\n <div>{{due_date}}</div>\n <div style=\"font-weight: bold; color: #1a2a47;\">PO Number:</div>\n <div>{{po_number}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item invoice-row invoice-row--items\" id=\"row_items_d3\" style=\"margin-bottom: 30px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Items Table\" id=\"block_items_d3\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d3\" placeholder=\"\" style=\"font-size: 13px; width: 100%; padding: 0px; margin: 0px; color: #1a2a47; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #1a2a47; color: white;\">\n <th style=\"padding: 12px; text-align: left; font-weight: bold; border: 0;\">Item</th>\n <th style=\"padding: 12px; text-align: center; font-weight: bold; border: 0; width: 80px;\">Qty</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Unit Price</th>\n <th style=\"padding: 12px; text-align: right; font-weight: bold; border: 0; width: 100px;\">Total</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Cloud Hosting Setup</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$500.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Monthly Support & Maintenance</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">3</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$300.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$900.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Security Audit & Compliance</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$400.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$400.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">API Integration Development</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">2</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$250.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$500.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Database Optimization</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$350.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #e0e0e0;\">\n <td style=\"padding: 12px; border: 0;\">Deployment & Go-Live Support</td>\n <td style=\"padding: 12px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$200.00</td>\n <td style=\"padding: 12px; text-align: right; border: 0;\">$200.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Summary Section -->\n <div class=\"row-item invoice-row invoice-row--summary\" id=\"row_summary_d3\" style=\"margin-bottom: 30px; min-height: 0px; gap: 30px;\">\n <!-- Notes Section -->\n <div class=\"col-item\" style=\"flex: 1 1 60%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Notes & Payment\" id=\"block_notes_d3\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_notes_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47;\">\n <div style=\"margin-bottom: 20px;\">\n <div style=\"font-weight: bold; color: #1a2a47; margin-bottom: 8px; font-size: 14px;\">Payment Methods:</div>\n <div style=\"background: #f5f5f5; padding: 12px; border-radius: 4px;\">\n <div style=\"margin-bottom: 8px;\"><strong>Bank Transfer:</strong></div>\n <div style=\"margin-left: 15px; font-size: 12px;\">Account: {{bank_account}}</div>\n <div style=\"margin-left: 15px; font-size: 12px;\">Routing: {{routing_number}}</div>\n <div style=\"margin-bottom: 8px; margin-top: 8px;\"><strong>Credit Card:</strong> Accepted via PaymentGateway</div>\n </div>\n </div>\n\n <div style=\"margin-bottom: 20px;\">\n <div style=\"font-weight: bold; color: #1a2a47; margin-bottom: 8px; font-size: 14px;\">Terms & Conditions:</div>\n <div style=\"font-size: 12px; line-height: 1.6;\">\n Payment is due within 30 days of invoice date. Late payments subject to 1.5% monthly interest. Please reference invoice number in payment communication.\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Totals Section -->\n <div class=\"col-item\" style=\"flex: 0 0 40%; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Totals\" id=\"block_totals_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: #f5f5f5; padding: 15px; border-radius: 4px; box-shadow: 0 2px 4px rgba(196, 30, 58, 0.1);\">\n <div class=\"edit_me resize\" id=\"dynamic_totals_d3\" placeholder=\"\" style=\"font-size: 13px; color: #1a2a47; margin: 0;\">\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #ddd;\">\n <div style=\"text-align: right;\">Subtotal:</div>\n <div style=\"text-align: right; font-weight: bold;\">$2,850.00</div>\n <div style=\"text-align: right;\">Tax (0%):</div>\n <div style=\"text-align: right; font-weight: bold;\">$0.00</div>\n <div style=\"text-align: right;\">Discount:</div>\n <div style=\"text-align: right; font-weight: bold;\">$0.00</div>\n </div>\n <div style=\"display: grid; grid-template-columns: 120px 80px; gap: 10px; font-size: 16px;\">\n <div style=\"text-align: right; font-weight: bold; color: #c41e3a;\">Total Due:</div>\n <div style=\"text-align: right; font-weight: bold; color: #c41e3a;\">$2,850.00</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer Section -->\n <div class=\"row-item\" id=\"row_footer_d3\" style=\"margin-top: 30px; padding-top: 30px; border-top: 2px solid #1a2a47; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Footer\" id=\"block_footer_d3\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_footer_d3\" placeholder=\"\" style=\"font-size: 11px; color: #666; text-align: center;\">\n <div>Thank you for your business! For questions, contact support@acmecorp.com | Phone: (555) 123-4567</div>\n <div style=\"margin-top: 8px;\">© 2024 ACME Corp. All rights reserved.</div>\n </div>\n </div>\n </div>\n </div>\n\n </div>`,\n 4: `\n\n <div class=\"row-item cs-page-header\" id=\"row_header_d4\" data-cs-page-region=\"header\" style=\"padding: 30px 36px; border: 0px; background: #1a2649; display: flex; justify-content: space-between; align-items: flex-start; position: relative;\">\n <div class=\"col-item\" style=\"flex: 0 0 auto; max-width: 50%;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Company Logo & Info\" id=\"block_logo_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_logo_d4\" placeholder=\"\" style=\"color: #ffffff;\">\n <div style=\"display: flex; align-items: center; gap: 12px; margin-bottom: 12px;\">\n <div style=\"width: 52px; height: 44px; background: #f5c100; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-weight: 900; color: #1a2649; font-size: 28px;\">A</div>\n <div>\n <div style=\"font-size: 18px; font-weight: 800; color: #ffffff;\">Salford &amp; Co.</div>\n </div>\n </div>\n <div style=\"font-size: 12px; color: #a0aec0; font-style: italic; margin-bottom: 8px;\">Invoice To:</div>\n <div style=\"font-size: 16px; font-weight: 700; color: #ffffff; margin-bottom: 2px;\">{{client_name}}</div>\n <div style=\"font-size: 12px; color: #a0aec0;\">{{client_role}}</div>\n </div>\n </div>\n </div>\n\n <div class=\"col-item\" style=\"flex: 0 0 auto; max-width: 50%; text-align: right;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Invoice Meta\" id=\"block_meta_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_meta_d4\" placeholder=\"\" style=\"color: #ffffff;\">\n <div style=\"font-size: 52px; font-weight: 900; letter-spacing: 2px; color: #f5c100; line-height: 1; margin-bottom: 18px;\">INVOICE</div>\n <div style=\"display: grid; grid-template-columns: auto auto; gap: 4px 24px; font-size: 13px;\">\n <div style=\"color: #a0aec0;\">Invoice No:</div>\n <div style=\"color: #ffffff; font-weight: 600; text-align: right;\">{{invoice_number}}</div>\n <div style=\"color: #a0aec0;\">Due Date:</div>\n <div style=\"color: #ffffff; font-weight: 600; text-align: right;\">{{due_date}}</div>\n <div style=\"color: #a0aec0;\">Invoice Date:</div>\n <div style=\"color: #ffffff; font-weight: 600; text-align: right;\">{{invoice_date}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Address Banner -->\n <div class=\"row-item\" id=\"row_address_d4\" style=\"background: #f5c100; padding: 13px 36px; display: flex; align-items: center; gap: 10px; position: relative;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Address\" id=\"block_address_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none; flex: 1;\">\n <div class=\"edit_me resize\" id=\"dynamic_address_d4\" placeholder=\"\" style=\"color: #1a2649; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 10px;\">\n <span style=\"display: inline-block; width: 28px; height: 28px; background: #1a2649; border-radius: 50%; text-align: center; line-height: 28px; color: #f5c100; font-size: 12px; flex-shrink: 0;\">📍</span>\n {{company_address}}\n </div>\n </div>\n </div>\n\n <!-- Contact + Payment Info -->\n <div class=\"row-item\" id=\"row_info_d4\" style=\"display: flex; padding: 28px 36px; gap: 40px; border-bottom: 1px solid #f0f0f0; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Contact Info\" id=\"block_contact_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_contact_d4\" placeholder=\"\" style=\"font-size: 13px; color: #444;\">\n <div style=\"margin-bottom: 6px;\"><span style=\"color: #666; width: 60px; display: inline-block;\">Phone:</span> {{phone}}</div>\n <div style=\"margin-bottom: 6px;\"><span style=\"color: #666; width: 60px; display: inline-block;\">Email:</span> {{email}}</div>\n <div><span style=\"color: #666; width: 60px; display: inline-block;\">Address:</span> {{address}}</div>\n </div>\n </div>\n </div>\n\n <div class=\"col-item\" style=\"flex: 1; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Payment Method\" id=\"block_payment_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_payment_d4\" placeholder=\"\" style=\"font-size: 13px; color: #444;\">\n <div style=\"font-size: 13px; font-weight: 800; color: #1a2649; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;\">Payment Method</div>\n <div style=\"margin-bottom: 5px; display: flex; justify-content: space-between;\"><span style=\"color: #666;\">Account No:</span> <span style=\"font-weight: 500;\">{{account_number}}</span></div>\n <div style=\"margin-bottom: 5px; display: flex; justify-content: space-between;\"><span style=\"color: #666;\">Account Name:</span> <span style=\"font-weight: 500;\">{{account_name}}</span></div>\n <div style=\"display: flex; justify-content: space-between;\"><span style=\"color: #666;\">Branch:</span> <span style=\"font-weight: 500;\">{{branch_name}}</span></div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Items Table -->\n <div class=\"row-item\" id=\"row_items_d4\" style=\"padding: 0 36px 24px; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1 1 0px; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Items Table\" id=\"block_items_d4\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me fr-element fr-view resize\" id=\"dynamic_items_d4\" placeholder=\"\" style=\"font-size: 13px; width: 100%; padding: 0px; margin: 0px; color: #333; overflow: visible;\">\n <table style=\"width: 100%; border-collapse: collapse;\">\n <thead>\n <tr style=\"background: #1a2649; color: white;\">\n <th style=\"padding: 12px 16px; text-align: left; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0;\">Description</th>\n <th style=\"padding: 12px 16px; text-align: center; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0; width: 100px;\">Subtotal</th>\n <th style=\"padding: 12px 16px; text-align: center; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0; width: 60px;\">QTY</th>\n <th style=\"padding: 12px 16px; text-align: right; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; border: 0; width: 80px;\">Subtotal</th>\n </tr>\n </thead>\n <tbody>\n <tr style=\"border-bottom: 1px solid #f0f0f0;\">\n <td style=\"padding: 13px 16px; border: 0;\">Brand Consultation</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0; background: #f9fafb;\">\n <td style=\"padding: 13px 16px; border: 0;\">Logo Design</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0;\">\n <td style=\"padding: 13px 16px; border: 0;\">Website Design</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0; background: #f9fafb;\">\n <td style=\"padding: 13px 16px; border: 0;\">Social Media Template</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$100</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">1</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$100.00</td>\n </tr>\n <tr style=\"border-bottom: 1px solid #f0f0f0;\">\n <td style=\"padding: 13px 16px; border: 0;\">Flyer</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">$50</td>\n <td style=\"padding: 13px 16px; text-align: center; border: 0;\">6</td>\n <td style=\"padding: 13px 16px; text-align: right; border: 0; font-weight: 600;\">$300.00</td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Footer: Terms + Totals -->\n <div class=\"row-item\" id=\"row_footer_d4\" style=\"display: flex; padding: 16px 36px 28px; gap: 40px; align-items: flex-start; min-height: 0px;\">\n <div class=\"col-item\" style=\"flex: 1.2; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Terms\" id=\"block_terms_d4\" style=\"width: 100%; max-width: 100%; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_terms_d4\" placeholder=\"\" style=\"font-size: 12px; color: #555; line-height: 1.6;\">\n <div style=\"font-size: 13px; font-weight: 800; color: #1a2649; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;\">Terms and Conditions</div>\n <div style=\"text-align: justify; margin-bottom: 20px;\">Please send payment within 30 days of receiving this invoice. There will be a 10% interest charge per month on late invoice.</div>\n\n <div style=\"font-size: 13px; font-weight: 800; color: #1a2649; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px;\">Thank You For Your Business</div>\n\n <div style=\"display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px;\">\n <div style=\"width: 22px; height: 22px; background: #1a2649; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #f5c100; font-size: 10px;\">📞</div>\n <span>{{phone_footer}}</span>\n </div>\n\n <div style=\"display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 12px;\">\n <div style=\"width: 22px; height: 22px; background: #1a2649; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #f5c100; font-size: 10px;\">🌐</div>\n <span>{{website}}</span>\n </div>\n\n <div style=\"display: flex; align-items: center; gap: 8px; font-size: 12px;\">\n <div style=\"width: 22px; height: 22px; background: #1a2649; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #f5c100; font-size: 10px;\">📍</div>\n <span>{{address_footer}}</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"col-item\" style=\"flex: 0.8; max-width: 100%; min-height: 0px;\">\n <div class=\"cs_block_s cs_dp_allow content-block cs-sales-editor drop_elem canvas-block\" data=\"Textarea\" custom-name=\"Totals\" id=\"block_totals_d4\" style=\"width: 100%; max-width: none; border: 0px; margin: 0px; background: transparent; box-shadow: none;\">\n <div class=\"edit_me resize\" id=\"dynamic_totals_d4\" placeholder=\"\" style=\"font-size: 13px; color: #555;\">\n <div style=\"display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0;\">\n <span style=\"font-weight: 600; color: #333;\">Sub-total:</span>\n <span>$700.00</span>\n </div>\n <div style=\"display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0;\">\n <span style=\"font-weight: 600; color: #333;\">Discount:</span>\n <span>$0.00</span>\n </div>\n <div style=\"display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0;\">\n <span style=\"font-weight: 600; color: #333;\">Tax (10%):</span>\n <span>$50.00</span>\n </div>\n <div style=\"display: flex; justify-content: space-between; padding: 11px 14px; background: #1a2649; border-radius: 3px; margin-top: 4px;\">\n <span style=\"color: #ffffff; font-size: 15px; font-weight: 800;\">Total:</span>\n <span style=\"color: #ffffff; font-size: 15px; font-weight: 800;\">$750.00</span>\n </div>\n\n <div style=\"text-align: right; margin-top: 28px;\">\n <div style=\"border-top: 1.5px solid #444; width: 140px; margin-left: auto; margin-bottom: 6px;\"></div>\n <div style=\"font-size: 13px; font-weight: 700; color: #333;\">{{signature_name}}</div>\n <div style=\"font-size: 12px; color: #777;\">{{signature_role}}</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Bottom Footer Bar -->\n <div class=\"row-item\" id=\"row_bottom_d4\" style=\"background: #f5c100; height: 22px; position: relative; overflow: hidden; min-height: 0px;\"></div>\n `,\n};\n\n<\/script>\n <script data-src=\"./js/flow/block-factory.js\">\n/**\n * @fileoverview Block factory for flow canvas.\n *\n * Delegates to BlockCreator (block-creator.js) where possible, otherwise\n * constructs lightweight blocks for the simpler types (Divider, Spacer,\n * Button, Label/Tag, Data Field, List Repeater).\n *\n * Exposes: window.FlowCanvas.createBlock(blockType) → HTMLElement | null\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const blockCreator = (typeof BlockCreator !== 'undefined') ? new BlockCreator() : null;\n\n // ---------------------------------------------------------------------------\n // Lightweight block builders for types that BlockCreator doesn't handle.\n // Each returns a `.cs_block_s` element so it integrates with inline-editor.js\n // selection / editing chrome.\n // ---------------------------------------------------------------------------\n\n const makeCsBlock = (label, blockType, extraClass = '') => {\n if (!blockCreator) {\n const el = document.createElement('div');\n el.className = `cs_block_s ${extraClass}`.trim();\n el.setAttribute('data', label);\n el.setAttribute('custom-name', label);\n el.dataset.blockType = blockType;\n return el;\n }\n const el = blockCreator.getCsBlockSmall(label, extraClass);\n el.setAttribute('custom-name', label);\n el.dataset.blockType = blockType;\n return el;\n };\n\n const hash = () => {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();\n return Math.random().toString(16).slice(2);\n };\n\n const createLabelTagBlock = () => {\n const block = makeCsBlock('Label', 'label-tag', 'cs-label-block');\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-label-tag';\n inner.id = `dynamic_${hash()}`;\n inner.setAttribute('placeholder', 'Featured');\n inner.style.fontSize = '12px';\n inner.style.fontWeight = '600';\n inner.style.display = 'inline-block';\n inner.style.padding = '4px 10px';\n inner.style.borderRadius = '999px';\n inner.style.background = '#eef0ff';\n inner.style.color = '#5c5cff';\n inner.textContent = 'Featured';\n block.appendChild(inner);\n return block;\n };\n\n const createButtonBlock = () => {\n const block = makeCsBlock('Button', 'button', 'cs-button-block');\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-button';\n inner.id = `dynamic_${hash()}`;\n inner.setAttribute('placeholder', 'Call to Action');\n inner.style.display = 'inline-block';\n inner.style.padding = '10px 20px';\n inner.style.background = '#5c5cff';\n inner.style.color = '#fff';\n inner.style.borderRadius = '6px';\n inner.style.fontWeight = '600';\n inner.style.fontSize = '14px';\n inner.style.textAlign = 'center';\n inner.style.cursor = 'pointer';\n inner.textContent = 'Call to Action';\n block.appendChild(inner);\n return block;\n };\n\n const createDividerBlock = () => {\n const block = makeCsBlock('Divider', 'divider', 'cs-divider-block');\n const line = document.createElement('div');\n line.className = 'cs-divider-line';\n line.style.height = '1px';\n line.style.background = '#cfd4f6';\n line.style.width = '100%';\n // line.style.margin = '14px 0';\n block.appendChild(line);\n return block;\n };\n\n const createSpacerBlock = () => {\n const block = makeCsBlock('Spacer', 'spacer', 'cs-spacer-block');\n const space = document.createElement('div');\n space.className = 'cs-spacer';\n space.style.height = '32px';\n space.style.width = '100%';\n space.style.background = 'transparent';\n block.appendChild(space);\n return block;\n };\n\n const createDataFieldBlock = () => {\n const block = makeCsBlock('Data Field', 'data-field', 'cs-data-field-block');\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-data-field';\n inner.id = `dynamic_${hash()}`;\n inner.setAttribute('placeholder', '{{ binding.path }}');\n inner.style.padding = '8px 12px';\n inner.style.border = '1px dashed #cfd4f6';\n inner.style.borderRadius = '4px';\n inner.style.fontFamily = 'monospace';\n inner.style.fontSize = '14px';\n inner.style.color = '#5c5cff';\n inner.textContent = '{{ binding.path }}';\n block.appendChild(inner);\n return block;\n };\n\n const createPageBreakBlock = () => {\n // Page Break is a visual marker. The drop handler in flow-canvas.js\n // recognises this block type and immediately splits the page; the\n // block itself is removed during the split. We still build a styled\n // element so the user sees what they're dragging.\n const block = makeCsBlock('Page Break', 'page-break', 'cs-page-break-block');\n const inner = document.createElement('div');\n inner.className = 'cs-page-break';\n inner.style.display = 'flex';\n inner.style.alignItems = 'center';\n inner.style.gap = '8px';\n inner.style.padding = '8px 12px';\n inner.style.border = '1px dashed #f97316';\n inner.style.background = '#fff7ed';\n inner.style.color = '#c2410c';\n inner.style.fontSize = '12px';\n inner.style.fontWeight = '600';\n inner.style.textTransform = 'uppercase';\n inner.style.letterSpacing = '0.06em';\n inner.style.borderRadius = '4px';\n inner.textContent = '— Page Break —';\n block.appendChild(inner);\n return block;\n };\n\n const createListRepeaterBlock = () => {\n const block = makeCsBlock('List Repeater', 'list-repeater', 'cs-list-repeater-block');\n block.dataset.repeatPath = '';\n block.dataset.repeatAlias = 'item';\n const list = document.createElement('ul');\n list.className = 'edit_me cs-list-repeater';\n list.id = `dynamic_${hash()}`;\n list.style.margin = '0';\n list.style.padding = '0 0 0 20px';\n list.style.fontSize = '14px';\n list.style.lineHeight = '1.6';\n ['Dynamic list item one', 'Dynamic list item two', 'Dynamic list item three'].forEach(text => {\n const li = document.createElement('li');\n li.textContent = text;\n list.appendChild(li);\n });\n block.appendChild(list);\n return block;\n };\n\n const createFlexibleBlock = () => {\n const block = makeCsBlock('Flexible', 'flexible', 'cs-flexible-block');\n block.dataset.blockType = 'flexible';\n block.style.position = 'relative';\n const content = document.createElement('div');\n content.className = 'cs-flexible-content';\n content.id = `dynamic_${hash()}`;\n content.style.position = 'relative';\n content.style.width = '100%';\n content.style.minHeight = `${window.CanvasConfig?.flexible?.defaultHeight ?? 80}px`;\n block.appendChild(content);\n return block;\n };\n\n const createFAIconBlock = (iconName = 'star', iconClass = 'fas fa-star') => {\n const block = makeCsBlock('Icon', 'fa-icon', 'cs-fa-icon-block');\n const container = document.createElement('div');\n container.className = 'cs-fa-icon-container';\n container.style.display = 'flex';\n container.style.alignItems = 'center';\n container.style.justifyContent = 'center';\n container.style.width = '100%';\n container.style.minHeight = '60px';\n container.style.fontSize = '40px';\n container.style.color = '#5c5cff';\n\n const icon = document.createElement('i');\n icon.className = iconClass;\n icon.id = `dynamic_${hash()}`;\n container.appendChild(icon);\n block.appendChild(container);\n\n block.dataset.iconName = iconName;\n block.dataset.iconClass = iconClass;\n return block;\n };\n\n\n\n // ---------------------------------------------------------------------------\n // Builder registry — one entry per block `type`. To add a block, register it\n // in block-registry.js (metadata) AND add a builder here (DOM construction).\n // Each builder returns a `.cs_block_s` element.\n // ---------------------------------------------------------------------------\n const BUILDERS = {\n // Delegated to BlockCreator\n 'heading': () => blockCreator.createTitleBlock({ text: 'New Heading', className: 'add-heading-two', fontSize: '14px' }),\n 'heading-two': () => blockCreator.createTitleBlock({ text: 'New Heading', className: 'add-heading-two', fontSize: '14px' }),\n 'body-text': () => blockCreator.createBodyTextBlock({ fontSize: '14px' }),\n 'section-container': () => blockCreator.createSectionContainerBlock(),\n 'table-repeater': () => blockCreator.createWhiteHeaderTableBlock(),\n 'image': () => blockCreator.createSquareImageBlock(),\n 'video': () => blockCreator.createVideoBlock(),\n\n // Lightweight builders defined above\n 'label-tag': createLabelTagBlock,\n 'button': createButtonBlock,\n 'divider': createDividerBlock,\n 'spacer': createSpacerBlock,\n 'data-field': createDataFieldBlock,\n 'list-repeater': createListRepeaterBlock,\n 'flexible': createFlexibleBlock,\n 'fa-icon': () => createFAIconBlock(),\n 'page-break': createPageBreakBlock,\n 'pen-shape': () => window.PenShape?.createBlock() || null,\n 'table': () => window.TableBlock?.createBlock() || null,\n 'sync-list': () => window.SyncList?.createBlock() || null,\n 'aiden': () => window.Aiden?.createBlock() || null,\n };\n // Expose so other modules / future plugins can register builders.\n window.FlowCanvas.BLOCK_BUILDERS = BUILDERS;\n\n // ---------------------------------------------------------------------------\n // Main factory\n // ---------------------------------------------------------------------------\n\n window.FlowCanvas.createBlock = function (blockType) {\n if (!blockCreator) {\n console.warn('flow-canvas/block-factory: BlockCreator not loaded');\n return null;\n }\n\n // Dynamic dispatch for predefined templates\n const templateMatch = blockType.match(/^predefine-template-(\\d+)$/);\n if (templateMatch) {\n const n = Number(templateMatch[1]);\n return window.FlowCanvas.TEMPLATE_HTML && window.FlowCanvas.TEMPLATE_HTML[n] !== undefined ? window.FlowCanvas.TEMPLATE_HTML[n] : null;\n }\n\n const builder = BUILDERS[blockType];\n if (builder) return builder();\n\n // Unknown type — warn if the registry knows about it (missing builder),\n // then fall back to a generic placeholder block.\n if (window.FormBlockRegistry?.byType(blockType)) {\n console.warn(`flow-canvas/block-factory: no builder for registered type \"${blockType}\"`);\n }\n const el = blockCreator.getCsBlockSmall(blockType);\n el.dataset.blockType = blockType;\n el.innerHTML = `<div class=\"canvas-block__content\"><span class=\"canvas-block__tag\">${blockType}</span></div>`;\n return el;\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/aiden.js\">\n/**\n * @fileoverview Aiden — AI writing-assistant block.\n *\n * A text block (behaves exactly like a normal Heading/Body-Text block when you\n * just click + type) that gains an AI authoring flow when you press the\n * shortcut while focused in it:\n *\n * Windows / Linux : Alt + H\n * macOS : ⌘ + H\n *\n * Empty state shows a \"Help me to write… (Alt + H)\" placeholder, exactly like\n * the title-block placeholder (CSS `:empty:before` on the `.edit_me`).\n *\n * AI flow (a floating action bar under the block drives the phases):\n * 1. prompt — type what you want; [Cancel] [Generate]\n * 2. loading — \"AI:den writing…\" spinner + [Stop] (Stop aborts the request)\n * 3. result — the generated text is written into the block; the bar shows\n * [↻ Recreate] [🎤 Adjust tone] [Cancel] [Insert]\n * - Adjust tone opens a popup (professional / casual) → Apply re-generates.\n * - Recreate re-runs the request with the same prompt.\n * - Insert keeps the generated text; Cancel reverts to the previous content.\n *\n * The actual text generation goes through a configurable seam so a real backend\n * can be wired in without touching this file:\n *\n * window.Aiden.configure({\n * generate: async ({ prompt, tones, signal }) => '<p>…</p>' // HTML or text\n * });\n *\n * Until configured, a built-in stub returns a realistic simulated response (and\n * honours `signal` so Stop works), so the whole UX is exercisable end-to-end.\n *\n * Exposes: window.Aiden.createBlock(), window.Aiden.configure(), window.Aiden.open(block)\n */\n(function () {\n window.Aiden = window.Aiden || {};\n\n const isMac = /Mac|iPhone|iPad|iPod/i.test(\n (navigator.platform || '') + ' ' + (navigator.userAgent || '')\n );\n const SHORTCUT = isMac ? '⌘ H' : 'Alt + H';\n const HINT = `✦ Help me to write…  ${SHORTCUT}`;\n const PROMPT_HINT = '✦ Tell Aiden what to write…';\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID)\n ? crypto.randomUUID() : Math.random().toString(16).slice(2);\n\n /* ----------------------------- generation seam ---------------------------- */\n\n let customGenerate = null;\n window.Aiden.configure = (opts = {}) => {\n if (typeof opts.generate === 'function') customGenerate = opts.generate;\n };\n\n // Built-in simulated writer. Returns HTML. Honours an AbortSignal so Stop\n // cancels it. Replace via window.Aiden.configure({ generate }).\n const simulate = (prompt, tones) => {\n const topic = (prompt || 'your topic').trim().replace(/\\s+/g, ' ');\n const cap = topic.charAt(0).toUpperCase() + topic.slice(1);\n let opener = `Here's a draft about ${topic}.`;\n if (tones && tones.professional) {\n opener = `${cap}: an overview. The following outlines the key considerations in a clear, professional tone.`;\n } else if (tones && tones.casual) {\n opener = `So, about ${topic} — here's the friendly, no-fuss version. 👍`;\n }\n const body = `It brings together the essentials so you can adapt the wording, trim what you don't need, and keep the parts that fit your document. Edit it inline once inserted.`;\n return `<p>${opener}</p><p>${body}</p>`;\n };\n\n const defaultGenerate = ({ prompt, tones, signal }) => new Promise((resolve, reject) => {\n const timer = setTimeout(() => resolve(simulate(prompt, tones)), 1100);\n if (signal) {\n if (signal.aborted) { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); return; }\n signal.addEventListener('abort', () => {\n clearTimeout(timer);\n reject(new DOMException('Aborted', 'AbortError'));\n }, { once: true });\n }\n });\n\n const runGenerate = (args) =>\n Promise.resolve().then(() => (customGenerate || defaultGenerate)(args));\n\n /* ------------------------------- the block -------------------------------- */\n\n window.Aiden.createBlock = () => {\n const bc = (typeof BlockCreator !== 'undefined') ? new BlockCreator() : null;\n const block = bc\n ? bc.getCsBlockSmall('AI Writer', 'cs-aiden-block')\n : Object.assign(document.createElement('div'), { className: 'cs_block_s cs-aiden-block' });\n block.dataset.blockType = 'aiden';\n block.setAttribute('custom-name', 'AI Writer');\n\n const inner = document.createElement('div');\n inner.className = 'edit_me cs-aiden-text';\n inner.id = `aiden_${hash()}`;\n inner.setAttribute('placeholder', HINT);\n inner.style.fontSize = '14px';\n block.appendChild(inner);\n return block;\n };\n\n // Register the builder so createBlock(type='aiden') / drag-drop work.\n if (window.FlowCanvas && window.FlowCanvas.BLOCK_BUILDERS) {\n window.FlowCanvas.BLOCK_BUILDERS['aiden'] = () => window.Aiden.createBlock();\n }\n\n /* ------------------------------ session state ----------------------------- */\n\n // One AI session at a time. { block, editable, phase, prompt, prevHTML,\n // controller, tones }.\n let session = null;\n\n // The action bar + tone popup are docked INSIDE the block (so they sit in the\n // input box itself). They're tagged data-cs-chrome so export + surface-click\n // ignore them, and removeChrome() has an exception so chrome teardown can't\n // wipe them mid-session (see inline-editor.js).\n let bar = null;\n let tonePop = null;\n\n const editableText = () => (session ? (session.editable.textContent || '').trim() : '');\n\n /* --------------------------------- the bar -------------------------------- */\n\n const BTN = (act, label, cls) =>\n `<button type=\"button\" data-act=\"${act}\" class=\"cs-aiden-btn ${cls}\">${label}</button>`;\n\n const renderBar = () => {\n if (!bar || !session) return;\n let html = '';\n if (session.phase === 'prompt') {\n html = `<div class=\"cs-aiden-bar__sp\"></div>`\n + BTN('cancel', 'Cancel', 'cs-aiden-btn--ghost')\n + BTN('generate', 'Generate', 'cs-aiden-btn--primary');\n } else if (session.phase === 'loading') {\n html = `<span class=\"cs-aiden-status\"><span class=\"cs-aiden-spin\"></span>AI:den writing…</span>`\n + `<div class=\"cs-aiden-bar__sp\"></div>`\n + BTN('stop', 'Stop', 'cs-aiden-btn--stop');\n } else { // result\n html = BTN('recreate', '↻ Recreate', 'cs-aiden-btn--link')\n + BTN('tone', '🎤 Adjust tone', 'cs-aiden-btn--link')\n + `<div class=\"cs-aiden-bar__sp\"></div>`\n + BTN('cancel', 'Cancel', 'cs-aiden-btn--ghost')\n + BTN('insert', 'Insert', 'cs-aiden-btn--primary');\n }\n bar.innerHTML = html;\n };\n\n const ensureBar = () => {\n if (bar) return;\n bar = document.createElement('div');\n bar.className = 'cs-aiden-bar';\n bar.setAttribute('data-cs-chrome', '');\n // Keep the caret in the block when a button is pressed, and keep our clicks\n // away from inline-editor's document-level select/teardown listeners.\n bar.addEventListener('mousedown', (e) => { e.preventDefault(); }, true);\n bar.addEventListener('pointerdown', (e) => { e.stopPropagation(); }, true);\n bar.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (!act) return;\n e.preventDefault();\n e.stopPropagation();\n onAction(act);\n });\n if (session) session.block.appendChild(bar);\n };\n\n const onAction = (act) => {\n if (act === 'generate') return generate();\n if (act === 'stop') return stop();\n if (act === 'recreate') return generateCore();\n if (act === 'insert') return commit();\n if (act === 'cancel') return cancel();\n if (act === 'tone') return toggleTonePopup();\n if (act === 'apply-tone') return applyTone();\n };\n\n /* ------------------------------- tone popup ------------------------------- */\n\n const ensureTonePop = () => {\n if (tonePop) return;\n tonePop = document.createElement('div');\n tonePop.className = 'cs-aiden-pop';\n tonePop.setAttribute('data-cs-chrome', '');\n tonePop.innerHTML = `\n <div class=\"cs-aiden-pop__title\">Adjust tone</div>\n <label class=\"cs-aiden-pop__row\"><input type=\"checkbox\" data-tone=\"professional\"> Make it sound professional</label>\n <label class=\"cs-aiden-pop__row\"><input type=\"checkbox\" data-tone=\"casual\"> Make it casual</label>\n <div class=\"cs-aiden-pop__foot\">${BTN('apply-tone', 'Apply', 'cs-aiden-btn--primary')}</div>`;\n tonePop.addEventListener('mousedown', (e) => e.preventDefault(), true);\n tonePop.addEventListener('pointerdown', (e) => e.stopPropagation(), true);\n tonePop.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act) { e.preventDefault(); e.stopPropagation(); onAction(act); }\n });\n (bar || document.body).appendChild(tonePop);\n };\n\n const toggleTonePopup = () => {\n ensureTonePop();\n const open = !tonePop.classList.contains('is-open');\n if (open && session) {\n tonePop.querySelectorAll('input[data-tone]').forEach((cb) => {\n cb.checked = !!session.tones[cb.dataset.tone];\n });\n }\n tonePop.classList.toggle('is-open', open);\n };\n\n const closeTonePopup = () => { if (tonePop) tonePop.classList.remove('is-open'); };\n\n const applyTone = () => {\n if (!session || !tonePop) return;\n tonePop.querySelectorAll('input[data-tone]').forEach((cb) => {\n session.tones[cb.dataset.tone] = cb.checked;\n });\n closeTonePopup();\n generateCore();\n };\n\n /* ----------------------------- phase control ------------------------------ */\n\n const setPhase = (phase) => {\n if (!session) return;\n session.phase = phase;\n session.block.classList.toggle('cs-aiden--loading', phase === 'loading');\n session.block.setAttribute('data-aiden-phase', phase);\n renderBar();\n };\n\n const generate = () => {\n if (!session) return;\n const prompt = editableText();\n if (!prompt) { focusEditable(); return; }\n session.prompt = prompt;\n generateCore();\n };\n\n const generateCore = () => {\n if (!session) return;\n closeTonePopup();\n setPhase('loading');\n const controller = ('AbortController' in window) ? new AbortController() : null;\n session.controller = controller;\n const mine = controller;\n runGenerate({ prompt: session.prompt, tones: session.tones, signal: controller && controller.signal })\n .then((out) => {\n if (!session || session.controller !== mine) return; // superseded / closed\n session.controller = null;\n session.result = out || '';\n session.editable.innerHTML = sanitize(out);\n setPhase('result');\n })\n .catch((err) => {\n if (err && err.name === 'AbortError') return; // Stop handled it\n if (!session || session.controller !== mine) return;\n session.controller = null;\n console.warn('[Aiden] generate failed:', err);\n setPhase('prompt');\n flash('Generation failed — try again.');\n });\n };\n\n const stop = () => {\n if (!session) return;\n if (session.controller) { try { session.controller.abort(); } catch (e) { /* */ } }\n session.controller = null;\n setPhase('prompt'); // editable still shows the prompt\n focusEditable();\n };\n\n // Insert: keep the generated text as the block's content and leave AI mode.\n const commit = () => {\n if (!session) return;\n session.editable.setAttribute('placeholder', HINT);\n close();\n };\n\n // Cancel: discard everything and restore the block's previous content.\n const cancel = () => {\n if (!session) return;\n if (session.controller) { try { session.controller.abort(); } catch (e) { /* */ } }\n session.editable.innerHTML = session.prevHTML;\n session.editable.setAttribute('placeholder', HINT);\n close();\n };\n\n const close = () => {\n if (session) {\n session.block.classList.remove('cs-aiden--active', 'cs-aiden--loading');\n session.block.removeAttribute('data-aiden-phase');\n }\n closeTonePopup();\n if (tonePop) { tonePop.remove(); tonePop = null; }\n if (bar) { bar.remove(); bar = null; }\n session = null;\n };\n\n /* -------------------------------- helpers --------------------------------- */\n\n // Very small guard so a configured backend can't inject scripts. Allows basic\n // formatting tags; everything else is treated as text by the browser anyway\n // once assigned via innerHTML, so we just strip <script>.\n const sanitize = (html) => String(html == null ? '' : html).replace(/<\\s*script[^>]*>[\\s\\S]*?<\\s*\\/\\s*script\\s*>/gi, '');\n\n const focusEditable = () => {\n if (!session) return;\n try {\n session.editable.focus();\n const sel = window.getSelection();\n if (sel && session.editable.lastChild) {\n const range = document.createRange();\n range.selectNodeContents(session.editable);\n range.collapse(false);\n sel.removeAllRanges();\n sel.addRange(range);\n }\n } catch (e) { /* */ }\n };\n\n const flash = (msg) => {\n if (!bar) return;\n const n = document.createElement('span');\n n.className = 'cs-aiden-flash';\n n.textContent = msg;\n bar.insertBefore(n, bar.firstChild);\n setTimeout(() => n.remove(), 2600);\n };\n\n /* ------------------------------- open AI mode ----------------------------- */\n\n window.Aiden.open = (block) => {\n if (!block || !block.classList || !block.classList.contains('cs-aiden-block')) return;\n const editable = block.querySelector('.edit_me');\n if (!editable) return;\n if (session && session.block === block) return; // already open here\n if (session) close();\n\n session = {\n block,\n editable,\n phase: 'prompt',\n prompt: (editable.textContent || '').trim(),\n prevHTML: editable.innerHTML,\n controller: null,\n tones: { professional: false, casual: false },\n };\n block.classList.add('cs-aiden--active');\n editable.setAttribute('contenteditable', 'true');\n editable.setAttribute('placeholder', PROMPT_HINT);\n\n ensureBar();\n setPhase('prompt');\n focusEditable();\n };\n\n /* -------------------------------- shortcut -------------------------------- */\n\n // Alt+H (Win/Linux) or ⌘+H (mac) while focused in / on an Aiden block.\n const onKey = (e) => {\n if (e.code !== 'KeyH' && (e.key || '').toLowerCase() !== 'h') return;\n const combo = isMac ? (e.metaKey && !e.ctrlKey && !e.altKey) : (e.altKey && !e.ctrlKey && !e.metaKey);\n if (!combo) {\n // Escape closes an open session (acts like Cancel).\n return;\n }\n const block = e.target?.closest?.('.cs-aiden-block')\n || document.querySelector('.cs-aiden-block.cs-editing, .cs-aiden-block.cs-selected');\n if (!block) return;\n e.preventDefault();\n e.stopPropagation();\n window.Aiden.open(block);\n };\n\n const onKeyAux = (e) => {\n if (!session) return;\n if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }\n // Ctrl/Cmd+Enter generates from the prompt.\n if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && session.phase === 'prompt') {\n e.preventDefault();\n generate();\n }\n };\n\n // Clicking outside the block + bar finalises: keep the result, otherwise cancel.\n const onDocPointerDown = (e) => {\n if (!session) return;\n const t = e.target;\n if (t.closest?.('.cs-aiden-bar, .cs-aiden-pop')) return;\n if (session.block.contains(t)) return;\n if (session.phase === 'result') commit();\n else cancel();\n };\n\n const init = () => {\n document.addEventListener('keydown', onKey, true);\n document.addEventListener('keydown', onKeyAux, true);\n document.addEventListener('pointerdown', onDocPointerDown, true);\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/pen-shape.js\">\n/**\n * @fileoverview Pen Shape block — a Photoshop-style vector pen tool.\n *\n * A draggable block whose content is an SVG vector shape the user draws with a\n * pen tool. The drawing/editing only activates when the block is in EDIT mode\n * (the `.cs-editing` class added by inline-editor.js after the second click).\n *\n * click → selected (badge) – no pen UI\n * click again → editing (.cs-editing) – pen UI + anchor markers active\n * click outside / Esc → deselect – pen UI removed\n *\n * Pen behaviour (mirrors Photoshop's Pen tool):\n * - click on empty canvas → add a corner anchor\n * - click-and-drag → add a smooth anchor (drag sets the bézier handles)\n * - Alt while dragging → break the handle (independent / corner-ish)\n * - click the first anchor → close the path → switch to direct-select mode\n * - drag an anchor → move it (handles move with it)\n * - drag a handle endpoint → reshape the curve (mirrored unless Alt held)\n * - Alt-click an anchor → convert corner ↔ smooth\n * - Ctrl/Cmd+Z / Shift+Z → step-by-step undo / redo (anchor markers redraw)\n * - Delete / Backspace → remove the selected (or last) anchor\n * - Enter → close the path\n *\n * Extras: solid / gradient / image fill, rotate (whole path), and a\n * smooth/round-corners pass.\n *\n * The drawn shape is stored two ways so it survives HTML export + reload:\n * - rendered : <path d=\"…\" fill=\"…\"> inside the block's SVG (exported as-is)\n * - editable : block.dataset.penPath = JSON({paths:[{anchors,closed},…]}) so\n * re-editing can rebuild every sub-path exactly. Multiple\n * sub-paths let one block hold several separate clip-shapes.\n *\n * Exposes window.PenShape.createBlock() (called by the block factory).\n */\n(function () {\n window.PenShape = window.PenShape || {};\n\n const NS = 'http://www.w3.org/2000/svg';\n // SVG user-space the path coords live in. The SVG stretches to fill the block\n // (preserveAspectRatio=\"none\") so resizing the block scales the shape — coords\n // stay stable, which keeps editing math simple and export deterministic.\n const VB = 1000;\n const CX = VB / 2, CY = VB / 2;\n const HIT_PX = 9; // pointer pick radius in screen px\n const DEFAULT_FILL = '#248567';\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2));\n const clone = (o) => JSON.parse(JSON.stringify(o));\n const ns = (tag, attrs) => { const el = document.createElementNS(NS, tag); for (const k in attrs) el.setAttribute(k, attrs[k]); return el; };\n\n // Rotate a point by `deg` around the viewBox centre.\n const rot = (x, y, deg) => {\n if (!deg) return { x, y };\n const a = deg * Math.PI / 180, dx = x - CX, dy = y - CY;\n return { x: CX + dx * Math.cos(a) - dy * Math.sin(a), y: CY + dx * Math.sin(a) + dy * Math.cos(a) };\n };\n\n /* --------------------------- path serialisation --------------------------- */\n\n // One segment p→c: cubic bézier if either side carries a handle, else a line.\n const seg = (p, c) => {\n const hasOut = p.outX != null, hasIn = c.inX != null;\n if (hasOut || hasIn) {\n const c1x = hasOut ? p.outX : p.x, c1y = hasOut ? p.outY : p.y;\n const c2x = hasIn ? c.inX : c.x, c2y = hasIn ? c.inY : c.y;\n return ` C ${c1x} ${c1y} ${c2x} ${c2y} ${c.x} ${c.y}`;\n }\n return ` L ${c.x} ${c.y}`;\n };\n\n const buildSubD = (anchors, closed) => {\n if (!anchors.length) return '';\n let d = `M ${anchors[0].x} ${anchors[0].y}`;\n for (let i = 1; i < anchors.length; i++) d += seg(anchors[i - 1], anchors[i]);\n if (closed && anchors.length > 2) { d += seg(anchors[anchors.length - 1], anchors[0]); d += ' Z'; }\n return d;\n };\n\n // The block holds MANY independent sub-paths (state.paths), each drawn as its\n // own <path> with its own per-path style — see renderShape().\n\n /* ------------------------------ state / style ----------------------------- */\n\n const DEFAULT_STYLE = {\n fillType: 'solid', fill: DEFAULT_FILL, fillOpacity: 1,\n gradFrom: '#5c5cff', gradTo: '#a855f7', gradAngle: 90,\n gradKind: 'linear', // 'linear' | 'radial'\n gradStops: null, // optional [color, color, …] (evenly spaced); falls back to from/to\n imageSrc: '',\n stroke: '', strokeWidth: 0,\n rotate: 0,\n blend: 'normal', // CSS mix-blend-mode for layered translucent shapes\n };\n\n // The colour stops for a gradient: an explicit gradStops list (≥2) wins,\n // else the legacy from/to pair. Offsets are spread evenly across 0→100%.\n const gradStopColors = (s) => (\n Array.isArray(s.gradStops) && s.gradStops.length >= 2\n ? s.gradStops.slice()\n : [s.gradFrom || '#5c5cff', s.gradTo || '#a855f7']\n );\n\n const readStyle = (block) => { try { return Object.assign({}, DEFAULT_STYLE, JSON.parse(block.dataset.penStyle)); } catch { return Object.assign({}, DEFAULT_STYLE); } };\n const writeStyle = (block, style) => { block.dataset.penStyle = JSON.stringify(style); };\n const readState = (block) => {\n try {\n const s = JSON.parse(block.dataset.penPath);\n if (s && Array.isArray(s.paths)) return s;\n if (s && Array.isArray(s.anchors)) return { paths: [{ anchors: s.anchors, closed: !!s.closed }] }; // migrate old single-path\n } catch { /* */ }\n return { paths: [] };\n };\n const writeState = (block, state) => { block.dataset.penPath = JSON.stringify(state); };\n\n const buildGradient = (id, s) => {\n let g;\n if (s.gradKind === 'radial') {\n g = ns('radialGradient', { id, 'data-pen-def': '', cx: 0.5, cy: 0.5, r: 0.5 });\n } else {\n const a = (s.gradAngle ?? 90) * Math.PI / 180;\n g = ns('linearGradient', {\n id, 'data-pen-def': '',\n x1: (0.5 - Math.cos(a) / 2), y1: (0.5 - Math.sin(a) / 2),\n x2: (0.5 + Math.cos(a) / 2), y2: (0.5 + Math.sin(a) / 2),\n });\n }\n const stops = gradStopColors(s), n = stops.length;\n stops.forEach((c, i) => g.appendChild(ns('stop', {\n offset: `${n > 1 ? (i / (n - 1)) * 100 : 0}%`, 'stop-color': c,\n })));\n return g;\n };\n\n const buildPattern = (id, s) => {\n const p = ns('pattern', { id, 'data-pen-def': '', patternUnits: 'userSpaceOnUse', width: VB, height: VB });\n const img = ns('image', { width: VB, height: VB, preserveAspectRatio: 'xMidYMid slice', href: s.imageSrc });\n img.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', s.imageSrc); // legacy fallback\n p.appendChild(img);\n return p;\n };\n\n // A path's geometry rings. A normal shape is a single ring (its anchors); a\n // MERGED shape flattens several originals into `rings` (anchors stays empty).\n const ringsOf = (p) => (\n p && p.rings && p.rings.length ? p.rings : [{ anchors: p.anchors || [], closed: p.closed }]\n );\n\n // Resolve the effective style for one sub-path: its own per-path style when\n // present, else the block-level style (back-compat for shapes drawn before\n // per-path styling existed).\n const pathStyleOf = (p, blockStyle) => (\n p && p.style ? Object.assign({}, DEFAULT_STYLE, p.style) : blockStyle\n );\n\n // Render every sub-path as its OWN <path class=\"cs-pen-fill\" data-pi=\"i\">\n // element with its OWN fill/gradient/image/stroke/rotate. This is what lets\n // each clip-path carry a different colour/style. defs get per-path unique ids.\n const renderShape = (block) => {\n const svg = block.querySelector('.cs-pen-svg');\n if (!svg) return;\n const state = readState(block);\n const blockStyle = readStyle(block);\n const uid = (block.querySelector('.cs-pen-shape')?.id || 'pen');\n\n let defs = svg.querySelector('defs');\n if (!defs) { defs = ns('defs', {}); svg.insertBefore(defs, svg.firstChild); }\n defs.querySelectorAll('[data-pen-def]').forEach((e) => e.remove());\n svg.querySelectorAll('.cs-pen-fill').forEach((e) => e.remove());\n\n state.paths.forEach((p, i) => {\n if (p.hidden) return;\n const d = ringsOf(p).map((r) => buildSubD(r.anchors, r.closed)).filter(Boolean).join(' ');\n if (!d) return;\n const style = pathStyleOf(p, blockStyle);\n const pathEl = ns('path', { class: 'cs-pen-fill', 'data-pi': String(i) });\n\n let fill = style.fill || DEFAULT_FILL;\n if (style.fillType === 'gradient') { const id = `grad_${uid}_${i}`; defs.appendChild(buildGradient(id, style)); fill = `url(#${id})`; }\n else if (style.fillType === 'image' && style.imageSrc) { const id = `pat_${uid}_${i}`; defs.appendChild(buildPattern(id, style)); fill = `url(#${id})`; }\n\n pathEl.setAttribute('d', d);\n pathEl.setAttribute('fill', fill);\n pathEl.setAttribute('fill-opacity', style.fillOpacity ?? 1);\n if (style.stroke && (style.strokeWidth || 0) > 0) {\n pathEl.setAttribute('stroke', style.stroke);\n pathEl.setAttribute('stroke-width', style.strokeWidth);\n pathEl.setAttribute('vector-effect', 'non-scaling-stroke');\n pathEl.setAttribute('stroke-linejoin', 'round');\n }\n if (style.rotate) pathEl.setAttribute('transform', `rotate(${style.rotate} ${CX} ${CY})`);\n // Blend mode for layered translucent shapes (inline style → survives export).\n if (style.blend && style.blend !== 'normal') pathEl.style.mixBlendMode = style.blend;\n svg.appendChild(pathEl);\n });\n };\n\n /* ------------------------------ block factory ----------------------------- */\n\n // Start empty — the user draws their own shape(s) with the pen tool.\n const defaultState = () => ({ paths: [] });\n\n window.PenShape.createBlock = function () {\n const bc = (typeof BlockCreator !== 'undefined') ? new BlockCreator() : null;\n const block = bc ? bc.getCsBlockSmall('Pen Shape', 'cs-pen-shape-block')\n : Object.assign(document.createElement('div'), { className: 'cs_block_s cs-pen-shape-block' });\n block.dataset.blockType = 'pen-shape';\n block.setAttribute('custom-name', 'Pen Shape');\n\n const inner = document.createElement('div');\n inner.className = 'cs-pen-shape';\n inner.id = `pen_${hash()}`;\n\n const svg = ns('svg', { class: 'cs-pen-svg', viewBox: `0 0 ${VB} ${VB}`, preserveAspectRatio: 'none' });\n svg.appendChild(ns('path', { class: 'cs-pen-fill' }));\n inner.appendChild(svg);\n block.appendChild(inner);\n // Default height comes from CSS (.cs-pen-shape-block) so it survives\n // normalizeForFlow()'s inline-style strip on drop. A manual resize sets an\n // inline height that overrides the CSS default.\n\n writeState(block, defaultState());\n writeStyle(block, Object.assign({}, DEFAULT_STYLE));\n renderShape(block);\n return block;\n };\n\n /* ------------------------------ edit session ------------------------------ */\n // Only one block edits at a time (mirrors EditorManager). `S` holds its state.\n let S = null;\n // Module-level clip-path clipboard for copy/paste (persists across blocks).\n let penClip = null;\n\n const innerRect = () => S.inner.getBoundingClientRect();\n\n // viewBox coord → screen px (within the overlay), rotating by `deg` (defaults\n // to the active path's rotation S.rotate).\n const vbToPxR = (vx, vy, deg) => {\n const r = innerRect();\n const p = rot(vx, vy, deg);\n // Markers are drawn into the overlay SVG. In the page designer the overlay\n // is enlarged BEYOND the block (so points can be placed off-page), so its\n // top-left no longer matches the block's. Offset by that delta to keep the\n // anchor/handle markers aligned with the rendered shape. (inline blocks:\n // overlay === block → delta is 0, unchanged.)\n let ox = 0, oy = 0;\n if (S.overlay) { const o = S.overlay.getBoundingClientRect(); ox = r.left - o.left; oy = r.top - o.top; }\n return { x: ox + p.x / VB * r.width, y: oy + p.y / VB * r.height };\n };\n const vbToPx = (vx, vy) => vbToPxR(vx, vy, S.rotate);\n // client px → viewBox coord (un-rotated to the active path's model space).\n const clientToVb = (cx, cy) => {\n const r = innerRect();\n const raw = { x: (cx - r.left) / r.width * VB, y: (cy - r.top) / r.height * VB };\n return rot(raw.x, raw.y, -S.rotate);\n };\n // client px → viewBox coord WITHOUT un-rotating (true rendered position).\n // Used for picking which sub-path the pointer is over (each path may carry a\n // different rotation, so we test against each path's own rotated geometry).\n const clientToVbRaw = (cx, cy) => {\n const r = innerRect();\n return { x: (cx - r.left) / r.width * VB, y: (cy - r.top) / r.height * VB };\n };\n const hitVb = () => { const r = innerRect(); return HIT_PX / ((r.width + r.height) / 2) * VB; };\n\n const snapshot = () => { S.undo.push(clone(S.state)); if (S.undo.length > 100) S.undo.shift(); S.redo.length = 0; };\n\n const commit = () => { writeState(S.block, S.state); renderShape(S.block); drawOverlay(); renderLayers(); };\n\n // Index of the sub-path currently open (still being drawn), else the last\n // path (so edits/undo target something sensible).\n const openPathIndex = () => {\n if (!S) return -1;\n const i = S.state.paths.findIndex((p) => !p.closed);\n return i >= 0 ? i : S.state.paths.length - 1;\n };\n\n // Hit-test anchors/handles of the ACTIVE sub-path only. Selecting a different\n // sub-path is done separately (pickPath) so each clip-path is edited/styled in\n // isolation. Returns { type, p, i } (p = path index = activePath).\n const pick = (vb) => {\n const t = hitVb(), pi = S.activePath, path = S.state.paths[pi];\n if (!path) return null;\n if (S.sel && S.sel.p === pi) {\n const sp = path.anchors[S.sel.i];\n if (sp) {\n if (sp.inX != null && Math.hypot(sp.inX - vb.x, sp.inY - vb.y) <= t) return { type: 'in', p: pi, i: S.sel.i };\n if (sp.outX != null && Math.hypot(sp.outX - vb.x, sp.outY - vb.y) <= t) return { type: 'out', p: pi, i: S.sel.i };\n }\n }\n const a = path.anchors;\n for (let i = 0; i < a.length; i++) if (Math.hypot(a[i].x - vb.x, a[i].y - vb.y) <= t) return { type: 'anchor', p: pi, i };\n return null;\n };\n\n // Even-odd point-in-polygon, used to pick which sub-path the pointer is over.\n const pointInPolygon = (pt, poly) => {\n let inside = false;\n for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {\n const a = poly[i], b = poly[j];\n if (((a.y > pt.y) !== (b.y > pt.y)) &&\n (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) inside = !inside;\n }\n return inside;\n };\n\n // Is the (un-rotated) pointer inside a sub-path's filled, rendered area?\n // Samples the béziers into a polygon and applies the path's own rotation so\n // the test matches what the user actually sees.\n const pointInPath = (vbRaw, path) => {\n if (!path) return false;\n const deg = (path.style && path.style.rotate) || 0;\n return ringsOf(path).some((r) => {\n if (!r.closed || r.anchors.length < 3) return false;\n const a = r.anchors, n = a.length, poly = [];\n for (let i = 0; i < n; i++) {\n const p = a[i], c = a[(i + 1) % n];\n for (let t = 0; t < 1; t += 0.1) { const s = sampleSeg(p, c, t); poly.push(rot(s.x, s.y, deg)); }\n }\n return pointInPolygon(vbRaw, poly);\n });\n };\n\n // Topmost sub-path (last drawn paints on top) whose fill the pointer is over.\n // Skips hidden + locked layers (those are only selectable from the panel).\n const pickPath = (vbRaw) => {\n for (let i = S.state.paths.length - 1; i >= 0; i--) {\n const p = S.state.paths[i];\n if (p.hidden || p.locked) continue;\n if (pointInPath(vbRaw, p)) return i;\n }\n return -1;\n };\n\n /* --------------------------- per-path style ------------------------------- */\n\n // The active sub-path's style (its own when set, else the block default).\n const getActiveStyle = () => {\n const p = S.state.paths[S.activePath];\n if (p && p.style) return Object.assign({}, DEFAULT_STYLE, p.style);\n return readStyle(S.block);\n };\n // Write the style onto the active sub-path AND remember it as the block\n // default so the NEXT new sub-path inherits it. Persist the per-path style to\n // dataset.penPath immediately — renderShape() re-reads from there, so without\n // this the colour wouldn't show until a later action flushed the state.\n const setActiveStyle = (style) => {\n const p = S.state.paths[S.activePath];\n if (p) p.style = style;\n writeStyle(S.block, style);\n writeState(S.block, S.state);\n };\n\n // Make `pi` the active sub-path: sync rotation + the toolbar style inputs to\n // it so the next edits affect that clip-path only.\n const selectPath = (pi) => {\n if (!S || pi < 0 || pi >= S.state.paths.length) return;\n S.activePath = pi;\n S.sel = null;\n const st = getActiveStyle();\n S.rotate = st.rotate || 0;\n writeStyle(S.block, st); // new paths inherit the selected path's look\n S.applyStyleValues?.();\n syncToolbar();\n };\n\n const setSmooth = (p, ox, oy) => { p.outX = ox; p.outY = oy; p.inX = 2 * p.x - ox; p.inY = 2 * p.y - oy; };\n // Keep anchors inside the block — EXCEPT in the page designer (freeDraw), where\n // points may sit off-page (a one-page bleed margin each side) so the user can\n // design past the page edge. Off-page geometry simply clips to the page on save.\n const clampVb = (v) => (S && S.freeDraw)\n ? Math.max(-VB, Math.min(2 * VB, v))\n : Math.max(0, Math.min(VB, v));\n\n /* ------------------------------- pointer ops ------------------------------ */\n\n // Resize mode: re-show the block's resize handles (hidden during shape editing\n // so they don't cover the corner anchors) and stop the overlay from grabbing\n // pointer events meant for those handles.\n const setResizeMode = (on) => {\n if (!S) return;\n S.resizeMode = !!on;\n S.block.classList.toggle('cs-pen-resizing', S.resizeMode);\n S.sel = null;\n drawOverlay();\n };\n\n const onDown = (e) => {\n if (!S || S.resizeMode) return; // let the block resize handles work\n e.preventDefault(); e.stopPropagation();\n S.overlay.setPointerCapture?.(e.pointerId);\n const vb = clientToVb(e.clientX, e.clientY);\n // Space held → \"move whole clip-path\" override: a drag anywhere relocates the\n // active shape, even over an anchor/handle (works in pen AND edit mode).\n if (S.spaceHeld) { startShapeDrag(vb); return; }\n const hit = pick(vb);\n const alt = e.altKey;\n\n if (S.mode === 'pen') {\n const ap = S.state.paths[S.activePath];\n const open = ap && !ap.closed;\n\n if (open) {\n // --- drawing ---\n // close the active open sub-path by clicking its first anchor\n if (hit && hit.type === 'anchor' && hit.p === S.activePath && hit.i === 0 && ap.anchors.length > 2) {\n snapshot(); ap.closed = true; S.sel = null; S.penHover = null; commit(); return;\n }\n // grab an existing anchor/handle to tweak while drawing (drag = handle)\n if (hit) { if (hit.type === 'anchor') S.sel = { p: hit.p, i: hit.i }; startDrag(hit, vb); return; }\n // add the next point (smart-guide aligned)\n snapshot();\n { const s = alignSnap(snapV(vb.x), snapV(vb.y), null); ap.anchors.push({ x: clampVb(s.x), y: clampVb(s.y) }); }\n S.sel = { p: S.activePath, i: ap.anchors.length - 1 };\n S.drag = { kind: 'new', p: S.activePath, i: S.sel.i };\n commit();\n return;\n }\n\n // --- editing a COMPLETED shape with the pen ---\n // Drag a handle dot of the selected point → curve. Drag a point → MOVE it.\n // Alt-click a point → remove it (delete is Alt-gated). Click outline → ADD.\n if (ap && ap.closed) {\n // A handle (in/out) of the currently-selected anchor → reshape the curve.\n const hp = pick(vb);\n if (hp && (hp.type === 'in' || hp.type === 'out')) {\n S.sel = { p: hp.p, i: hp.i };\n startDrag(hp, vb);\n return;\n }\n const ai = hitAnchor(vb, ap);\n if (ai >= 0) {\n if (alt) {\n // Alt-click a point → remove it (so a stray click can't drop a point).\n snapshot();\n const path = S.state.paths[S.activePath];\n path.anchors.splice(ai, 1);\n if (path.anchors.length < 3) path.closed = false;\n S.sel = null; S.penHover = null; commit();\n return;\n }\n // No Alt → select the point and drag to MOVE it (a clean click just\n // selects, which reveals its handle dots — drag those to curve).\n snapshot();\n S.sel = { p: S.activePath, i: ai };\n S.penHover = null;\n S.drag = { kind: 'anchor', p: S.activePath, i: ai, ox: vb.x, oy: vb.y };\n drawOverlay();\n return;\n }\n const seg = findSegmentInsertion(vb, ap);\n if (seg) {\n snapshot();\n ap.anchors.splice(seg.i + 1, 0, { x: seg.pt.x, y: seg.pt.y });\n S.sel = { p: S.activePath, i: seg.i + 1 }; S.penHover = null; commit();\n return;\n }\n }\n // Over a DIFFERENT closed shape → select it (so its points become editable).\n const overPath = pickPath(clientToVbRaw(e.clientX, e.clientY));\n if (overPath >= 0) { S.selected?.clear(); selectPath(overPath); return; }\n // Empty space → start a BRAND-NEW shape (its own style copy).\n snapshot();\n const fp = alignSnap(snapV(vb.x), snapV(vb.y), null);\n const path = { anchors: [{ x: clampVb(fp.x), y: clampVb(fp.y) }], closed: false, name: nextPathName(), style: Object.assign({}, readStyle(S.block)) };\n S.state.paths.push(path); S.activePath = S.state.paths.length - 1;\n S.sel = { p: S.activePath, i: 0 };\n S.drag = { kind: 'new', p: S.activePath, i: 0 };\n S.penHover = null;\n commit();\n return;\n }\n\n // EDIT (direct-select) mode. A locked active layer can't be anchor-edited\n // or dragged (you can still select a DIFFERENT layer to switch away).\n const activeLocked = !!S.state.paths[S.activePath]?.locked;\n if (hit && !activeLocked) {\n if (hit.type === 'anchor' && alt) {\n snapshot();\n const p = S.state.paths[hit.p].anchors[hit.i];\n if (p.inX != null || p.outX != null) { delete p.inX; delete p.inY; delete p.outX; delete p.outY; }\n else setSmooth(p, p.x + 80, p.y);\n S.sel = { p: hit.p, i: hit.i }; commit(); return;\n }\n S.sel = { p: hit.p, i: hit.i };\n startDrag(hit, vb);\n return;\n }\n // MOVE (hand) tool — move ONLY (no point inserting; that's the pen's job):\n // • over a DIFFERENT sub-path → select it\n // • over the ACTIVE shape → drag the whole shape\n // • empty space → deselect\n const vbRaw = clientToVbRaw(e.clientX, e.clientY);\n const overPath = pickPath(vbRaw);\n if (overPath >= 0 && overPath !== S.activePath) { S.selected?.clear(); selectPath(overPath); return; }\n if (overPath === S.activePath && !activeLocked) {\n startShapeDrag(vb);\n return;\n }\n S.sel = null; drawOverlay();\n };\n\n const startDrag = (hit, vb) => { snapshot(); S.drag = { kind: hit.type, p: hit.p, i: hit.i, ox: vb.x, oy: vb.y }; drawOverlay(); };\n\n const onMove = (e) => {\n if (!S) return;\n const vb = clientToVb(e.clientX, e.clientY);\n if (!S.drag) {\n const ap = S.state.paths[S.activePath];\n S.cursor = null; S.penHover = null; S.guides = null;\n if (S.mode === 'pen' && ap) {\n if (!ap.closed && ap.anchors.length) {\n const snap = alignSnap(snapV(vb.x), snapV(vb.y), null); // smart-guide the next point\n S.cursor = { x: clampVb(snap.x), y: clampVb(snap.y) };\n } else if (ap.closed) {\n // Hovering a completed shape: over a point, show the × REMOVE marker\n // ONLY while Alt is held (delete is Alt-gated) — without Alt the point\n // is draggable to curve it. Over the outline, show the + ADD marker.\n const ai = hitAnchor(vb, ap);\n if (ai >= 0) { if (e.altKey) S.penHover = { kind: 'remove', i: ai }; }\n else { const seg = findSegmentInsertion(vb, ap); if (seg) S.penHover = { kind: 'add', x: seg.pt.x, y: seg.pt.y }; }\n }\n }\n drawOverlay();\n return;\n }\n const d = S.drag, a = S.state.paths[d.p].anchors;\n if (d.kind === 'shape') {\n // Translate every ring of the shape; snap the delta when snap is on.\n const dx = snapDelta(vb.x - d.ox), dy = snapDelta(vb.y - d.oy);\n const path = S.state.paths[d.p];\n const moved = d.orig.map((r) => ({\n closed: r.closed,\n anchors: r.anchors.map((o) => {\n const np = { x: o.x + dx, y: o.y + dy };\n if (o.inX != null) { np.inX = o.inX + dx; np.inY = o.inY + dy; }\n if (o.outX != null) { np.outX = o.outX + dx; np.outY = o.outY + dy; }\n return np;\n }),\n }));\n if (path.rings && path.rings.length) path.rings = moved;\n else { path.anchors = moved[0].anchors; path.closed = moved[0].closed; }\n } else if (d.kind === 'new') {\n const p = a[d.i];\n if (e.altKey) { p.outX = vb.x; p.outY = vb.y; } else setSmooth(p, vb.x, vb.y);\n } else if (d.kind === 'anchor') {\n const snap = alignSnap(snapV(vb.x), snapV(vb.y), { p: d.p, i: d.i });\n const p = a[d.i], nx = clampVb(snap.x), nyv = clampVb(snap.y), dx = nx - p.x, dy = nyv - p.y;\n p.x = nx; p.y = nyv;\n if (p.inX != null) { p.inX += dx; p.inY += dy; }\n if (p.outX != null) { p.outX += dx; p.outY += dy; }\n } else {\n const p = a[d.i];\n if (d.kind === 'out') { p.outX = vb.x; p.outY = vb.y; if (!e.altKey && p.inX != null) { p.inX = 2 * p.x - vb.x; p.inY = 2 * p.y - vb.y; } }\n else { p.inX = vb.x; p.inY = vb.y; if (!e.altKey && p.outX != null) { p.outX = 2 * p.x - vb.x; p.outY = 2 * p.y - vb.y; } }\n }\n writeState(S.block, S.state); renderShape(S.block); drawOverlay();\n };\n\n const onUp = () => {\n if (!S || !S.drag) return;\n // Pen-mode point delete is now immediate on Alt-click (onDown); a plain\n // click/drag here either moved the point or just selected it.\n S.drag = null;\n S.guides = null;\n commit();\n };\n\n const sampleSeg = (p, c, t) => {\n const hasOut = p.outX != null, hasIn = c.inX != null;\n if (!hasOut && !hasIn) return { x: p.x + (c.x - p.x) * t, y: p.y + (c.y - p.y) * t };\n const c1x = hasOut ? p.outX : p.x, c1y = hasOut ? p.outY : p.y;\n const c2x = hasIn ? c.inX : c.x, c2y = hasIn ? c.inY : c.y, u = 1 - t;\n return {\n x: u * u * u * p.x + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * c.x,\n y: u * u * u * p.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * c.y,\n };\n };\n\n // Index of an anchor of `path` under `vb`, or -1. Used by the pen tool's\n // hover add/remove affordances.\n const hitAnchor = (vb, path) => {\n if (!path) return -1;\n const t = hitVb();\n for (let i = 0; i < path.anchors.length; i++) {\n if (Math.hypot(path.anchors[i].x - vb.x, path.anchors[i].y - vb.y) <= t) return i;\n }\n return -1;\n };\n\n // The candidate insertion point on `path`'s outline nearest `vb` (within the\n // hit tolerance), or null. { i, pt } — insert after anchor i.\n const findSegmentInsertion = (vb, path) => {\n if (!path) return null;\n let best = null;\n const a = path.anchors, n = a.length;\n for (let i = 0; i < n; i++) {\n if (i === n - 1 && !path.closed) break;\n const p = a[i], c = a[(i + 1) % n];\n for (let t = 0.05; t < 1; t += 0.05) {\n const pt = sampleSeg(p, c, t), dist = Math.hypot(pt.x - vb.x, pt.y - vb.y);\n if (!best || dist < best.dist) best = { dist, i, pt };\n }\n }\n return (best && best.dist <= hitVb() * 1.8) ? best : null;\n };\n\n // Begin dragging the whole active shape (translate all its rings).\n const startShapeDrag = (vb) => {\n const path = S.state.paths[S.activePath];\n if (!path) return;\n snapshot();\n S.drag = { kind: 'shape', p: S.activePath, ox: vb.x, oy: vb.y, orig: clone(ringsOf(path)) };\n drawOverlay();\n };\n\n /* ------------------------------ overlay draw ------------------------------ */\n\n const drawOverlay = () => {\n if (!S) return;\n const r = innerRect(), ov = S.ovSvg;\n ov.setAttribute('width', r.width); ov.setAttribute('height', r.height);\n ov.setAttribute('viewBox', `0 0 ${r.width} ${r.height}`);\n ov.replaceChildren();\n if (S.resizeMode) return; // box-resize mode: anchors hidden, handles drive\n const paths = S.state.paths;\n const ap = paths[S.activePath];\n // Hand / Move tool: hide the clip-path selection chrome (anchor squares +\n // bézier handles) for a clean view. The Pen tool brings them back. Guides /\n // rubber-band still draw (the former only during a whole-shape snap-drag).\n const showMarks = S.mode !== 'edit';\n\n // Smart alignment guides (full-bleed dashed lines through the snapped x / y).\n // Span the FULL overlay, not just the block: in the page designer the overlay\n // extends past the page, and vbToPx places markers relative to the overlay —\n // so a line drawn only 0..blockWidth would land shifted into the bleed margin.\n if (S.guides) {\n const orect = S.overlay ? S.overlay.getBoundingClientRect() : r;\n const ow = orect.width, oh = orect.height;\n if (S.guides.gx != null) { const gx = vbToPx(S.guides.gx, 0).x; ov.appendChild(ns('line', { x1: gx, y1: 0, x2: gx, y2: oh, class: 'cs-pen-guide' })); }\n if (S.guides.gy != null) { const gy = vbToPx(0, S.guides.gy).y; ov.appendChild(ns('line', { x1: 0, y1: gy, x2: ow, y2: gy, class: 'cs-pen-guide' })); }\n }\n\n // rubber-band preview from the active open path's last anchor to the cursor\n if (S.cursor && S.mode === 'pen' && ap && !ap.closed && ap.anchors.length) {\n const last = ap.anchors[ap.anchors.length - 1], p1 = vbToPx(last.x, last.y), p2 = vbToPx(S.cursor.x, S.cursor.y);\n ov.appendChild(ns('line', { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y, class: 'cs-pen-rubber' }));\n }\n // handles for the selected anchor\n if (showMarks && S.sel && paths[S.sel.p]?.anchors[S.sel.i]) {\n const p = paths[S.sel.p].anchors[S.sel.i], apx = vbToPx(p.x, p.y);\n [[p.inX, p.inY], [p.outX, p.outY]].forEach(([hx, hy]) => {\n if (hx == null) return;\n const hp = vbToPx(hx, hy);\n ov.appendChild(ns('line', { x1: apx.x, y1: apx.y, x2: hp.x, y2: hp.y, class: 'cs-pen-handle-line' }));\n ov.appendChild(ns('circle', { cx: hp.x, cy: hp.y, r: 4, class: 'cs-pen-handle' }));\n });\n }\n // anchors for every sub-path — drawn at each path's OWN rotation. Non-active\n // paths are dimmed so it's clear which clip-path the toolbar edits.\n if (showMarks) paths.forEach((path, pi) => {\n const active = pi === S.activePath;\n if (path.hidden && !active) return; // hidden layers show no anchors\n const deg = (path.style && path.style.rotate) || 0;\n path.anchors.forEach((p, i) => {\n const pp = vbToPxR(p.x, p.y, deg), size = active ? 8 : 6;\n const isSel = active && S.sel && S.sel.p === pi && S.sel.i === i;\n const isFirst = active && !path.closed && i === 0;\n const cls = 'cs-pen-anchor'\n + (isSel ? ' is-sel' : '')\n + (isFirst ? ' is-first' : '')\n + (active ? '' : ' is-dim');\n ov.appendChild(ns('rect', { x: pp.x - size / 2, y: pp.y - size / 2, width: size, height: size, class: cls }));\n });\n });\n\n // Pen-tool hover affordances on a completed shape: + to add a point on the\n // outline, × to remove the point under the cursor.\n if (S.mode === 'pen' && S.penHover && !S.drag && ap) {\n const deg = (ap.style && ap.style.rotate) || 0;\n if (S.penHover.kind === 'add') {\n const c = vbToPxR(S.penHover.x, S.penHover.y, deg);\n ov.appendChild(ns('circle', { cx: c.x, cy: c.y, r: 8, class: 'cs-pen-add' }));\n ov.appendChild(ns('line', { x1: c.x - 4, y1: c.y, x2: c.x + 4, y2: c.y, class: 'cs-pen-add-mark' }));\n ov.appendChild(ns('line', { x1: c.x, y1: c.y - 4, x2: c.x, y2: c.y + 4, class: 'cs-pen-add-mark' }));\n } else if (S.penHover.kind === 'remove') {\n const an = ap.anchors[S.penHover.i];\n if (an) {\n const c = vbToPxR(an.x, an.y, deg);\n ov.appendChild(ns('circle', { cx: c.x, cy: c.y, r: 8, class: 'cs-pen-remove' }));\n ov.appendChild(ns('line', { x1: c.x - 4, y1: c.y - 4, x2: c.x + 4, y2: c.y + 4, class: 'cs-pen-remove-mark' }));\n ov.appendChild(ns('line', { x1: c.x - 4, y1: c.y + 4, x2: c.x + 4, y2: c.y - 4, class: 'cs-pen-remove-mark' }));\n }\n }\n }\n };\n\n /* ------------------------------- operations ------------------------------- */\n\n // --- preset geometry helpers (all in the 0..1000 viewBox, centred ~500,500) ---\n const poly = (pts) => ({ anchors: pts.map(([x, y]) => ({ x, y })), closed: true });\n // Regular n-gon. rot = angle of the first vertex (default top).\n const ngon = (n, R, rot) => {\n const cx = 500, cy = 500, a0 = (rot == null ? -Math.PI / 2 : rot), pts = [];\n for (let i = 0; i < n; i++) { const a = a0 + i * 2 * Math.PI / n; pts.push({ x: cx + R * Math.cos(a), y: cy + R * Math.sin(a) }); }\n return { anchors: pts, closed: true };\n };\n // p-pointed star alternating outer R / inner r radius.\n const starPoly = (p, R, r) => {\n const cx = 500, cy = 500, a0 = -Math.PI / 2, pts = [];\n for (let i = 0; i < p * 2; i++) { const a = a0 + i * Math.PI / p; const rad = i % 2 ? r : R; pts.push({ x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }); }\n return { anchors: pts, closed: true };\n };\n // Rounded rectangle via cubic-bézier corners.\n const roundedRect = (L, T, R, B, rr) => {\n const k = rr * 0.5523;\n return {\n closed: true, anchors: [\n { x: L + rr, y: T, inX: L + rr - k, inY: T },\n { x: R - rr, y: T, outX: R - rr + k, outY: T },\n { x: R, y: T + rr, inX: R, inY: T + rr - k },\n { x: R, y: B - rr, outX: R, outY: B - rr + k },\n { x: R - rr, y: B, inX: R - rr + k, inY: B },\n { x: L + rr, y: B, outX: L + rr - k, outY: B },\n { x: L, y: B - rr, inX: L, inY: B - rr + k },\n { x: L, y: T + rr, outX: L, outY: T + rr - k },\n ],\n };\n };\n\n const PRESETS = {\n rectangle: () => ({ anchors: [{ x: 80, y: 80 }, { x: 920, y: 80 }, { x: 920, y: 920 }, { x: 80, y: 920 }], closed: true }),\n square: () => poly([[140, 140], [860, 140], [860, 860], [140, 860]]),\n 'rounded-rect': () => roundedRect(110, 180, 890, 820, 150),\n pill: () => roundedRect(90, 360, 910, 640, 140),\n triangle: () => ({ anchors: [{ x: 500, y: 80 }, { x: 920, y: 920 }, { x: 80, y: 920 }], closed: true }),\n 'triangle-down': () => poly([[120, 120], [880, 120], [500, 880]]),\n 'right-triangle': () => poly([[150, 140], [150, 860], [870, 860]]),\n diamond: () => poly([[500, 70], [930, 500], [500, 930], [70, 500]]),\n pentagon: () => ngon(5, 440),\n hexagon: () => {\n const pts = [], cx = 500, cy = 500, R = 440;\n for (let i = 0; i < 6; i++) { const ang = -Math.PI / 2 + i * Math.PI / 3; pts.push({ x: cx + R * Math.cos(ang), y: cy + R * Math.sin(ang) }); }\n return { anchors: pts, closed: true };\n },\n heptagon: () => ngon(7, 440),\n octagon: () => ngon(8, 460, -Math.PI / 2 + Math.PI / 8),\n parallelogram: () => poly([[280, 200], [940, 200], [720, 800], [60, 800]]),\n trapezoid: () => poly([[300, 200], [700, 200], [900, 800], [100, 800]]),\n ellipse: () => {\n const k = 0.5523 * 420, cx = 500, cy = 500, rr = 420;\n return {\n anchors: [\n { x: cx, y: cy - rr, inX: cx - k, inY: cy - rr, outX: cx + k, outY: cy - rr },\n { x: cx + rr, y: cy, inX: cx + rr, inY: cy - k, outX: cx + rr, outY: cy + k },\n { x: cx, y: cy + rr, inX: cx + k, inY: cy + rr, outX: cx - k, outY: cy + rr },\n { x: cx - rr, y: cy, inX: cx - rr, inY: cy + k, outX: cx - rr, outY: cy - k },\n ], closed: true\n };\n },\n star: () => {\n const pts = [], cx = 500, cy = 510, R = 440, r = 180;\n for (let i = 0; i < 10; i++) { const ang = -Math.PI / 2 + i * Math.PI / 5, rad = i % 2 ? r : R; pts.push({ x: cx + rad * Math.cos(ang), y: cy + rad * Math.sin(ang) }); }\n return { anchors: pts, closed: true };\n },\n 'star-4': () => starPoly(4, 470, 150),\n 'star-6': () => starPoly(6, 450, 210),\n 'star-12': () => starPoly(12, 450, 330),\n burst: () => starPoly(16, 470, 380),\n 'arrow-right': () => poly([[100, 360], [560, 360], [560, 200], [920, 500], [560, 800], [560, 640], [100, 640]]),\n 'arrow-left': () => poly([[900, 360], [440, 360], [440, 200], [80, 500], [440, 800], [440, 640], [900, 640]]),\n 'arrow-up': () => poly([[360, 900], [360, 440], [200, 440], [500, 80], [800, 440], [640, 440], [640, 900]]),\n 'arrow-down': () => poly([[360, 100], [360, 560], [200, 560], [500, 920], [800, 560], [640, 560], [640, 100]]),\n 'arrow-h': () => poly([[80, 500], [300, 300], [300, 420], [700, 420], [700, 300], [920, 500], [700, 700], [700, 580], [300, 580], [300, 700]]),\n 'arrow-v': () => poly([[500, 80], [300, 300], [420, 300], [420, 700], [300, 700], [500, 920], [700, 700], [580, 700], [580, 300], [700, 300]]),\n chevron: () => poly([[120, 200], [520, 200], [900, 500], [520, 800], [120, 800], [500, 500]]),\n plus: () => poly([[380, 100], [620, 100], [620, 380], [900, 380], [900, 620], [620, 620], [620, 900], [380, 900], [380, 620], [100, 620], [100, 380], [380, 380]]),\n heart: () => ({\n closed: true, anchors: [\n { x: 500, y: 300 }, // top centre dip (cusp)\n { x: 200, y: 0, inX: 400, inY: 0, outX: 0, outY: 0 },\n { x: 0, y: 250, outX: 0, outY: 400 },\n { x: 500, y: 760, inX: 250, inY: 600, outX: 750, outY: 600 }, // bottom tip\n { x: 1000, y: 250, inX: 1000, inY: 400 },\n { x: 800, y: 0, inX: 1000, inY: 0, outX: 600, outY: 0 },\n ],\n }),\n speech: () => poly([[120, 130], [880, 130], [880, 620], [430, 620], [290, 850], [300, 620], [120, 620]]),\n banner: () => poly([[100, 300], [900, 300], [780, 510], [900, 720], [100, 720], [220, 510]]),\n cloud: () => ({\n closed: true, anchors: [\n { x: 280, y: 720, inX: 160, inY: 690 }, // bottom-left (flat bottom to next)\n { x: 720, y: 720, outX: 860, outY: 710 }, // bottom-right\n { x: 840, y: 560, inX: 870, inY: 660, outX: 870, outY: 470 }, // right bump\n { x: 660, y: 420, inX: 800, inY: 420, outX: 700, outY: 360 }, // upper-right bump\n { x: 500, y: 380, inX: 610, inY: 330, outX: 390, outY: 330 }, // top bump\n { x: 340, y: 430, inX: 300, inY: 360, outX: 200, outY: 430 }, // upper-left bump\n { x: 160, y: 560, inX: 130, inY: 470, outX: 120, outY: 680 }, // left bump → closes to A0\n ],\n }),\n // Page-background shapes that bleed to the edges (great for invoices).\n corner: () => ({ anchors: [{ x: 0, y: 0 }, { x: 540, y: 0 }, { x: 0, y: 540 }], closed: true }),\n diagonal: () => ({ anchors: [{ x: 0, y: 260 }, { x: 1000, y: 0 }, { x: 1000, y: 260 }, { x: 0, y: 520 }], closed: true }),\n header: () => ({ anchors: [{ x: 0, y: 0 }, { x: 1000, y: 0 }, { x: 1000, y: 170 }, { x: 0, y: 170 }], closed: true }),\n footer: () => ({ anchors: [{ x: 0, y: 830 }, { x: 1000, y: 830 }, { x: 1000, y: 1000 }, { x: 0, y: 1000 }], closed: true }),\n };\n\n // Basic shapes can be dropped at a chosen size; the edge-bleed background\n // presets keep their full-page layout.\n // Everything except the edge-bleed page backgrounds is sized to the W/H box.\n const SIZABLE_PRESETS = {\n rectangle: 1, square: 1, 'rounded-rect': 1, pill: 1, ellipse: 1,\n triangle: 1, 'triangle-down': 1, 'right-triangle': 1, diamond: 1,\n pentagon: 1, hexagon: 1, heptagon: 1, octagon: 1, parallelogram: 1, trapezoid: 1,\n star: 1, 'star-4': 1, 'star-6': 1, 'star-12': 1, burst: 1,\n 'arrow-right': 1, 'arrow-left': 1, 'arrow-up': 1, 'arrow-down': 1,\n 'arrow-h': 1, 'arrow-v': 1, chevron: 1, plus: 1, heart: 1, speech: 1, banner: 1, cloud: 1,\n };\n\n // Scale a ring's anchors (and handles) so its bounding box becomes w×h\n // (viewBox units), centred on the page.\n const fitAnchorsToBox = (anchors, w, h) => {\n if (!anchors.length) return;\n let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;\n anchors.forEach((a) => { minx = Math.min(minx, a.x); maxx = Math.max(maxx, a.x); miny = Math.min(miny, a.y); maxy = Math.max(maxy, a.y); });\n const bw = (maxx - minx) || 1, bh = (maxy - miny) || 1;\n const sx = w / bw, sy = h / bh, ox = CX - w / 2, oy = CY - h / 2;\n const mapX = (x) => ox + (x - minx) * sx, mapY = (y) => oy + (y - miny) * sy;\n anchors.forEach((a) => {\n if (a.inX != null) { a.inX = mapX(a.inX); a.inY = mapY(a.inY); }\n if (a.outX != null) { a.outX = mapX(a.outX); a.outY = mapY(a.outY); }\n a.x = mapX(a.x); a.y = mapY(a.y);\n });\n };\n\n // A preset ADDS a new closed sub-path (so the user can stack several shapes),\n // with its OWN copy of the current style so it can be recoloured separately.\n // opts.w / opts.h (viewBox units) drop a sizable shape at that size, centred.\n const loadPreset = (name, opts) => {\n if (!S || !PRESETS[name]) return;\n snapshot();\n const path = PRESETS[name]();\n if (opts && opts.w > 0 && opts.h > 0 && SIZABLE_PRESETS[name]) {\n fitAnchorsToBox(path.anchors, Math.min(VB, opts.w), Math.min(VB, opts.h));\n }\n path.name = nextPathName();\n path.style = Object.assign({}, readStyle(S.block));\n S.state.paths.push(path);\n S.activePath = S.state.paths.length - 1;\n S.mode = 'edit'; S.sel = null; S.selected?.clear();\n S.rotate = (path.style.rotate) || 0;\n S.applyStyleValues?.();\n commit();\n };\n\n // Clicking the Pen tool finishes the current open shape so the next click on\n // empty canvas begins a brand-new sub-path.\n const startNewPath = () => {\n if (!S) return;\n const ap = S.state.paths[S.activePath];\n if (ap && !ap.closed && ap.anchors.length > 2) { snapshot(); ap.closed = true; }\n S.sel = null; commit();\n };\n\n const clearAllPaths = () => {\n if (!S) return;\n snapshot();\n S.state.paths = []; S.activePath = -1; S.mode = 'pen'; S.sel = null; S.selected?.clear();\n commit();\n };\n\n // Flip the ACTIVE sub-path only (all its rings), in isolation.\n const flip = (axis) => {\n if (!S) return;\n const path = S.state.paths[S.activePath];\n if (!path || path.locked) return;\n snapshot();\n const f = (v) => VB - v;\n ringsOf(path).forEach((r) => r.anchors.forEach((p) => {\n if (axis === 'h') { p.x = f(p.x); if (p.inX != null) p.inX = f(p.inX); if (p.outX != null) p.outX = f(p.outX); }\n else { p.y = f(p.y); if (p.inY != null) p.inY = f(p.inY); if (p.outY != null) p.outY = f(p.outY); }\n }));\n commit();\n };\n\n // Smooth / round corners on the ACTIVE sub-path: give every anchor symmetric\n // handles tangent to its neighbours (Catmull-Rom). Open-path endpoints stay\n // corners. Repeatable.\n // Give every anchor of `path` symmetric handles tangent to its neighbours\n // (Catmull-Rom). Open-path endpoints stay corners. No snapshot/commit — the\n // caller owns those.\n const smoothAnchors = (path) => {\n const k = 0.16;\n const a = path.anchors, n = a.length, closed = path.closed, next = [];\n for (let i = 0; i < n; i++) {\n const cur = a[i];\n if (!closed && (i === 0 || i === n - 1)) { next.push({ x: cur.x, y: cur.y }); continue; }\n const prev = a[(i - 1 + n) % n], nx = a[(i + 1) % n];\n const tx = nx.x - prev.x, ty = nx.y - prev.y;\n next.push({ x: cur.x, y: cur.y, outX: cur.x + tx * k, outY: cur.y + ty * k, inX: cur.x - tx * k, inY: cur.y - ty * k });\n }\n path.anchors = next;\n };\n\n // Smooth / round corners on the ACTIVE sub-path. Repeatable.\n const smoothAll = () => {\n if (!S) return;\n const path = S.state.paths[S.activePath];\n if (!path) return;\n snapshot();\n smoothAnchors(path);\n commit();\n };\n\n const deleteSelected = () => {\n if (!S) return;\n let sel = S.sel;\n if (!sel && S.mode === 'pen') { const ap = S.state.paths[S.activePath]; if (ap && ap.anchors.length) sel = { p: S.activePath, i: ap.anchors.length - 1 }; }\n const path = sel && S.state.paths[sel.p];\n if (!path || !path.anchors[sel.i]) return;\n snapshot();\n path.anchors.splice(sel.i, 1);\n if (path.anchors.length < 3) path.closed = false;\n if (path.anchors.length === 0) { S.state.paths.splice(sel.p, 1); S.activePath = openPathIndex(); }\n S.sel = null; commit();\n };\n\n const undo = () => { if (!S || !S.undo.length) return; S.redo.push(clone(S.state)); S.state = S.undo.pop(); S.sel = null; S.activePath = openPathIndex(); commit(); };\n const redo = () => { if (!S || !S.redo.length) return; S.undo.push(clone(S.state)); S.state = S.redo.pop(); S.sel = null; S.activePath = openPathIndex(); commit(); };\n\n /* -------------------------- shape management ------------------------------ */\n\n // A fresh \"Shape N\" name (N = highest existing number + 1) so names are stable\n // and don't renumber when layers are reordered.\n const nextPathName = () => {\n let max = 0;\n (S?.state.paths || []).forEach((p) => { const m = /(\\d+)/.exec(p.name || ''); if (m) max = Math.max(max, +m[1]); });\n return `Shape ${max + 1}`;\n };\n\n // Duplicate the active sub-path (offset a little so it's visible) and select it.\n const offsetAnchors = (anchors, dx, dy) => anchors.forEach((a) => {\n a.x += dx; a.y += dy;\n if (a.inX != null) { a.inX += dx; a.inY += dy; }\n if (a.outX != null) { a.outX += dx; a.outY += dy; }\n });\n\n const duplicateActivePath = () => {\n if (!S) return;\n const p = S.state.paths[S.activePath];\n if (!p) return;\n snapshot();\n const copy = clone(p);\n copy.name = `${p.name || 'Shape'} copy`;\n offsetAnchors(copy.anchors, 40, 40);\n S.state.paths.push(copy);\n S.activePath = S.state.paths.length - 1;\n S.sel = null; S.selected?.clear(); commit();\n };\n\n // Copy / paste the ACTIVE sub-path. The clipboard is module-level, so a shape\n // copied in one block (or the page-shape designer) can be pasted into another.\n // Pasting selects the copy in edit mode so it can be moved / flipped right\n // away (e.g. copy the right-corner shape, paste, flip-H, drag to the left).\n const copyActivePath = () => {\n if (!S) return;\n const p = S.state.paths[S.activePath];\n if (p) penClip = clone(p);\n };\n\n const pastePath = () => {\n if (!S || !penClip) return;\n snapshot();\n const copy = clone(penClip);\n copy.name = `${penClip.name || 'Shape'} copy`;\n if (Array.isArray(copy.anchors)) offsetAnchors(copy.anchors, 40, 40);\n if (Array.isArray(copy.rings)) copy.rings.forEach((r) => offsetAnchors(r.anchors, 40, 40));\n S.state.paths.push(copy);\n S.activePath = S.state.paths.length - 1;\n S.mode = 'edit';\n S.sel = null; S.selected?.clear();\n commit();\n };\n\n // Delete the whole active sub-path (not just one anchor).\n const deleteActivePath = () => {\n if (!S || !S.state.paths[S.activePath]) return;\n snapshot();\n S.state.paths.splice(S.activePath, 1);\n S.activePath = Math.min(S.activePath, S.state.paths.length - 1);\n S.sel = null; S.selected?.clear(); commit();\n };\n\n // Z-order: later paths paint on top, so swap with the neighbour. dir +1 =\n // bring forward, -1 = send backward.\n const reorderActivePath = (dir) => {\n if (!S) return;\n const i = S.activePath, j = i + dir, arr = S.state.paths;\n if (i < 0 || j < 0 || j >= arr.length) return;\n snapshot();\n [arr[i], arr[j]] = [arr[j], arr[i]];\n S.activePath = j; commit();\n };\n\n /* ------------------------------ snapping ---------------------------------- */\n\n const SNAP_GRID = VB / 40; // ~25 vb units\n const SNAP_EDGE_TOL = 18; // snap-to-edge/centre tolerance\n // Snap a coordinate to the page edges / centre, else to the grid.\n const snapV = (v) => {\n if (!S || !S.snap) return v;\n for (const t of [0, CX, VB]) if (Math.abs(v - t) <= SNAP_EDGE_TOL) return t;\n return Math.round(v / SNAP_GRID) * SNAP_GRID;\n };\n // Snap a translation delta to the grid (for whole-shape moves).\n const snapDelta = (d) => (S && S.snap ? Math.round(d / SNAP_GRID) * SNAP_GRID : d);\n\n // Smart alignment guides: snap (x,y) to line up with ANY other anchor's x or\n // y (so edges come out straight and left/right points sit at the same height,\n // or share a width). Always on — it only engages within a small tolerance, so\n // free placement isn't disturbed. Records the guide lines in S.guides for the\n // overlay. `skip` = the anchor being moved (don't align to itself).\n const ALIGN_TOL = 2; // vb units — smaller = less \"sticky\" snapping to other anchors\n const alignSnap = (x, y, skip) => {\n let gx = null, gy = null, dx = ALIGN_TOL, dy = ALIGN_TOL;\n (S?.state.paths || []).forEach((path, pi) => {\n path.anchors.forEach((a, i) => {\n if (skip && skip.p === pi && skip.i === i) return;\n const ax = Math.abs(a.x - x); if (ax < dx) { dx = ax; gx = a.x; }\n const ay = Math.abs(a.y - y); if (ay < dy) { dy = ay; gy = a.y; }\n });\n });\n if (S) S.guides = (gx != null || gy != null) ? { gx, gy } : null;\n return { x: gx != null ? gx : x, y: gy != null ? gy : y };\n };\n\n /* ------------------------------ layers ------------------------------------ */\n\n const swatchOf = (st) => {\n const stops = gradStopColors(st);\n return st.fillType === 'gradient'\n ? `linear-gradient(135deg, ${stops[0]}, ${stops[stops.length - 1]})`\n : (st.fillType === 'image' ? '#9aa0ff' : (st.fill || DEFAULT_FILL));\n };\n\n // A mini SVG preview of a single sub-path (Photoshop-style layer thumbnail).\n const pathThumb = (p, st, uid) => {\n const svg = ns('svg', { viewBox: `0 0 ${VB} ${VB}`, class: 'cs-pen-layer-thumb__svg', preserveAspectRatio: 'xMidYMid meet' });\n const d = ringsOf(p).map((r) => buildSubD(r.anchors, r.closed)).filter(Boolean).join(' ');\n if (d) {\n const pe = ns('path', { d, 'fill-opacity': st.fillOpacity ?? 1 });\n if (st.fillType === 'gradient') {\n const defs = ns('defs', {}); const id = `lt_${uid}`; defs.appendChild(buildGradient(id, st)); svg.appendChild(defs);\n pe.setAttribute('fill', `url(#${id})`);\n } else { pe.setAttribute('fill', st.fillType === 'image' ? '#9aa0ff' : (st.fill || DEFAULT_FILL)); }\n if (st.rotate) pe.setAttribute('transform', `rotate(${st.rotate} ${CX} ${CY})`);\n svg.appendChild(pe);\n }\n return svg;\n };\n\n // Rebuild the compact toolbar chips AND, when a side panel is attached, the\n // full Photoshop-style layer list (top row = front-most). Drag a row to\n // reorder = change z-index.\n const renderLayers = () => {\n if (!S) return;\n // 1) Compact chips in the toolbar (used by the in-canvas block).\n if (S.layersEl) {\n S.layersEl.replaceChildren();\n S.state.paths.forEach((p, i) => {\n const chip = document.createElement('button');\n chip.type = 'button';\n chip.className = 'cs-pen-layer' + (i === S.activePath ? ' is-active' : '');\n chip.title = `Shape ${i + 1}`;\n const st = p.style ? Object.assign({}, DEFAULT_STYLE, p.style) : readStyle(S.block);\n chip.style.background = swatchOf(st);\n chip.addEventListener('click', (e) => { e.stopPropagation(); S.mode = 'edit'; selectPath(i); });\n S.layersEl.appendChild(chip);\n });\n }\n // 2) Rich side panel (used by the page-background designer modal).\n if (!S.panelEl) return;\n const panel = S.panelEl;\n panel.replaceChildren();\n const uidBase = (S.block.querySelector('.cs-pen-shape')?.id || 'pen');\n // Render front-to-back: last path paints on top → show it at the TOP.\n for (let i = S.state.paths.length - 1; i >= 0; i--) {\n const p = S.state.paths[i];\n const st = p.style ? Object.assign({}, DEFAULT_STYLE, p.style) : readStyle(S.block);\n const row = document.createElement('div');\n row.className = 'cs-pen-layer-row'\n + (i === S.activePath ? ' is-active' : '')\n + (S.selected && S.selected.has(i) ? ' is-multi' : '')\n + (p.hidden ? ' is-hidden' : '')\n + (p.locked ? ' is-locked' : '');\n row.draggable = !p.locked;\n row.dataset.pi = String(i);\n\n // const eye = document.createElement('button');\n // eye.type = 'button'; eye.className = 'cs-pen-layer-row__eye'; eye.title = 'Show / hide';\n // eye.textContent = p.hidden ? '🚫' : '👁';\n // eye.addEventListener('click', (e) => { e.stopPropagation(); snapshot(); p.hidden = !p.hidden; commit(); });\n\n const lock = document.createElement('button');\n lock.type = 'button'; lock.className = 'cs-pen-layer-row__eye'; lock.title = p.locked ? 'Unlock' : 'Lock';\n lock.textContent = p.locked ? '🔒' : '🔓';\n lock.addEventListener('click', (e) => { e.stopPropagation(); snapshot(); p.locked = !p.locked; commit(); });\n\n const thumbWrap = document.createElement('span');\n thumbWrap.className = 'cs-pen-layer-row__thumb';\n thumbWrap.appendChild(pathThumb(p, st, `${uidBase}_${i}`));\n\n const name = document.createElement('span');\n name.className = 'cs-pen-layer-row__name';\n name.textContent = p.name || `Shape ${i + 1}`;\n name.title = 'Rename (✎ or double-click)';\n\n // Inline rename. The row is draggable, which would otherwise swallow the\n // input's mousedown (can't type), so we turn drag off while editing.\n const startRename = () => {\n const input = document.createElement('input');\n input.className = 'cs-pen-layer-row__rename';\n input.value = p.name || `Shape ${i + 1}`;\n row.draggable = false;\n name.replaceWith(input);\n input.focus(); input.select();\n let done = false;\n const finish = (save) => {\n if (done) return; done = true;\n if (save) { const v = input.value.trim(); if (v) { p.name = v; writeState(S.block, S.state); } }\n renderLayers();\n };\n input.addEventListener('mousedown', (ev) => ev.stopPropagation());\n input.addEventListener('click', (ev) => ev.stopPropagation());\n input.addEventListener('blur', () => finish(true));\n input.addEventListener('keydown', (ev) => {\n ev.stopPropagation();\n if (ev.key === 'Enter') { ev.preventDefault(); finish(true); }\n else if (ev.key === 'Escape') { ev.preventDefault(); finish(false); }\n });\n };\n name.addEventListener('dblclick', (e) => { e.stopPropagation(); startRename(); });\n\n const ren = document.createElement('button');\n ren.type = 'button'; ren.className = 'cs-pen-layer-row__act'; ren.title = 'Rename'; ren.textContent = '✎';\n ren.addEventListener('click', (e) => { e.stopPropagation(); startRename(); });\n\n const up = document.createElement('button');\n up.type = 'button'; up.className = 'cs-pen-layer-row__act'; up.title = 'Bring forward (up)'; up.textContent = '▲';\n up.disabled = i === S.state.paths.length - 1;\n up.addEventListener('click', (e) => { e.stopPropagation(); moveLayer(i, 1); });\n\n const down = document.createElement('button');\n down.type = 'button'; down.className = 'cs-pen-layer-row__act'; down.title = 'Send backward (down)'; down.textContent = '▼';\n down.disabled = i === 0;\n down.addEventListener('click', (e) => { e.stopPropagation(); moveLayer(i, -1); });\n\n const dup = document.createElement('button');\n dup.type = 'button'; dup.className = 'cs-pen-layer-row__act'; dup.title = 'Duplicate'; dup.textContent = '⧉';\n dup.addEventListener('click', (e) => { e.stopPropagation(); selectPath(i); duplicateActivePath(); syncToolbar(); });\n\n const del = document.createElement('button');\n del.type = 'button'; del.className = 'cs-pen-layer-row__act'; del.title = 'Delete'; del.textContent = '🗑';\n del.addEventListener('click', (e) => { e.stopPropagation(); selectPath(i); deleteActivePath(); syncToolbar(); });\n\n //incase if you need one more block append please add lock variable before showing hide/show icon \n row.append(lock, thumbWrap, name, up, down, ren, dup, del);\n row.addEventListener('click', (e) => {\n S.mode = 'edit';\n if (e.ctrlKey || e.metaKey) {\n // Multi-select: keep the current active in the set, then toggle this.\n if (S.activePath >= 0) S.selected.add(S.activePath);\n if (S.selected.has(i)) S.selected.delete(i); else S.selected.add(i);\n } else {\n S.selected.clear();\n }\n selectPath(i);\n });\n\n // Drag-to-reorder (HTML5). dragstart stores the source path index.\n row.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', String(i)); row.classList.add('is-dragging'); });\n row.addEventListener('dragend', () => row.classList.remove('is-dragging'));\n row.addEventListener('dragover', (e) => { e.preventDefault(); row.classList.add('is-drop'); });\n row.addEventListener('dragleave', () => row.classList.remove('is-drop'));\n row.addEventListener('drop', (e) => {\n e.preventDefault(); row.classList.remove('is-drop');\n const from = Number(e.dataTransfer.getData('text/plain'));\n const to = i;\n if (Number.isNaN(from) || from === to) return;\n reorderPathTo(from, to);\n });\n\n panel.appendChild(row);\n }\n };\n\n // Move sub-path at index `from` so it sits where `to` is (z-order change).\n const reorderPathTo = (from, to) => {\n if (!S) return;\n const arr = S.state.paths;\n if (from < 0 || from >= arr.length || to < 0 || to >= arr.length || from === to) return;\n snapshot();\n const [moved] = arr.splice(from, 1);\n arr.splice(to, 0, moved);\n S.activePath = arr.indexOf(moved);\n S.selected?.clear();\n commit(); syncToolbar();\n };\n\n // Nudge a layer one step up (dir +1 = bring forward) or down (-1 = backward).\n const moveLayer = (i, dir) => {\n if (!S) return;\n reorderPathTo(i, Math.max(0, Math.min(S.state.paths.length - 1, i + dir)));\n };\n\n /* ----------------------- multi-select / merge / lock ---------------------- */\n\n // The layers the next merge/lock acts on: the explicit multi-selection, or\n // just the active layer when nothing is multi-selected.\n const selectedIndices = () => {\n const set = S.selected && S.selected.size ? [...S.selected] : (S.activePath >= 0 ? [S.activePath] : []);\n return set.filter((i) => i >= 0 && i < S.state.paths.length).sort((a, b) => a - b);\n };\n\n // Flatten the selected layers into ONE layer (Photoshop \"merge\"). The merged\n // layer keeps the bottom-most selected layer's style/name; its geometry holds\n // every original ring (so it renders identically) but is no longer\n // anchor-editable — it can still be styled / moved / reordered as one unit.\n const mergeSelected = () => {\n if (!S) return;\n const idxs = selectedIndices();\n if (idxs.length < 2) return;\n snapshot();\n const paths = S.state.paths;\n const rings = [];\n idxs.forEach((i) => ringsOf(paths[i]).forEach((r) => rings.push(clone(r))));\n const host = paths[idxs[0]];\n const merged = {\n name: `${host.name || 'Shape'} (merged)`,\n closed: true, anchors: [], rings,\n style: clone(host.style || readStyle(S.block)),\n };\n for (let k = idxs.length - 1; k >= 0; k--) paths.splice(idxs[k], 1);\n paths.splice(idxs[0], 0, merged);\n S.activePath = idxs[0];\n S.selected.clear();\n commit(); syncToolbar();\n };\n\n // Toggle lock on the selected layers (lock prevents accidental edits).\n const toggleLockSelected = () => {\n if (!S) return;\n const idxs = selectedIndices();\n if (!idxs.length) return;\n snapshot();\n const lockAll = idxs.some((i) => !S.state.paths[i].locked);\n idxs.forEach((i) => { S.state.paths[i].locked = lockAll; });\n commit(); syncToolbar();\n };\n\n /* ------------------------------- toolbar ---------------------------------- */\n\n const TOOL_HTML = `\n <div class=\"cs-pen-toolbar\">\n <div class=\"cs-pen-layers\" data-pen-layers title=\"Shapes — click to select\"></div>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"pen\" title=\"Pen — draw a shape; on a finished shape hover an edge to add a point (+) or a point to remove it (×)\">✒</button>\n <button type=\"button\" data-pen=\"edit\" title=\"Move — drag points or the whole shape\">✋</button>\n <button type=\"button\" data-pen=\"snap\" title=\"Snap to grid / page edges\">🧲</button>\n <button type=\"button\" data-pen=\"smooth\" title=\"Smooth / round corners\">∿</button>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"dup\" title=\"Duplicate shape\">⧉</button>\n <button type=\"button\" data-pen=\"del-shape\" title=\"Delete this shape\">✖</button>\n <button type=\"button\" data-pen=\"fwd\" title=\"Bring forward\">⤒</button>\n <button type=\"button\" data-pen=\"back\" title=\"Send backward\">⤓</button>\n <button type=\"button\" data-pen=\"clear\" title=\"Clear all shapes\">🗑</button>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"preset-rectangle\" title=\"Rectangle\">▭</button>\n <button type=\"button\" data-pen=\"preset-ellipse\" title=\"Ellipse\">◯</button>\n <button type=\"button\" data-pen=\"preset-triangle\" title=\"Triangle\">△</button>\n <button type=\"button\" data-pen=\"preset-star\" title=\"Star\">★</button>\n <button type=\"button\" data-pen=\"preset-hexagon\" title=\"Hexagon\">⬡</button>\n <button type=\"button\" data-pen=\"preset-corner\" title=\"Corner wedge\">◣</button>\n <button type=\"button\" data-pen=\"preset-diagonal\" title=\"Diagonal band\">▰</button>\n <button type=\"button\" data-pen=\"preset-header\" title=\"Header bar\">▀</button>\n <button type=\"button\" data-pen=\"preset-footer\" title=\"Footer bar\">▄</button>\n <span class=\"cs-pen-sep\"></span>\n <button type=\"button\" data-pen=\"delete\" title=\"Delete point (Del)\">⛔</button>\n <button type=\"button\" data-pen=\"undo\" title=\"Undo (Ctrl+Z)\">↶</button>\n <button type=\"button\" data-pen=\"redo\" title=\"Redo (Ctrl+Shift+Z)\">↷</button>\n <div class=\"cs-pen-props\" data-pen-props>\n <div class=\"cs-pen-props__group cs-pen-group--transform\">\n <span class=\"cs-pen-props__label\">Transform</span>\n <button type=\"button\" data-pen=\"flip-h\" title=\"Flip horizontal\">⇆</button>\n <button type=\"button\" data-pen=\"flip-v\" title=\"Flip vertical\">⇅</button>\n <label class=\"cs-pen-num\" title=\"Rotate\">↻<input type=\"range\" min=\"0\" max=\"360\" step=\"1\" data-pen=\"rotate\"></label>\n </div>\n <div class=\"cs-pen-props__group cs-pen-group--fill\">\n <span class=\"cs-pen-props__label\">Fill</span>\n <select data-pen=\"fill-type\" title=\"Fill type\">\n <option value=\"solid\">Solid</option>\n <option value=\"gradient\">Gradient</option>\n <option value=\"image\">Image</option>\n </select>\n <span class=\"cs-pen-fill-solid\">\n <label class=\"cs-pen-swatch\" title=\"Fill colour\"><input type=\"color\" data-pen=\"fill\"></label>\n </span>\n <span class=\"cs-pen-fill-gradient\">\n <select data-pen=\"grad-kind\" title=\"Gradient type\">\n <option value=\"linear\">Linear</option>\n <option value=\"radial\">Radial</option>\n </select>\n <span class=\"cs-pen-grad-stops\" data-pen-stops></span>\n <button type=\"button\" data-pen=\"stop-add\" title=\"Add colour stop\">+</button>\n <button type=\"button\" data-pen=\"stop-del\" title=\"Remove colour stop\">-</button>\n <label class=\"cs-pen-num\" title=\"Angle\">∠<input type=\"number\" min=\"0\" max=\"360\" step=\"15\" data-pen=\"grad-angle\"></label>\n </span>\n <span class=\"cs-pen-fill-image\">\n <button type=\"button\" data-pen=\"image\" title=\"Choose image\">🖼 Image</button>\n </span>\n </div>\n <div class=\"cs-pen-props__group cs-pen-group--opacity\">\n <span class=\"cs-pen-props__label\">Opacity</span>\n <label class=\"cs-pen-num\" title=\"Fill opacity (transparency)\">◑<input type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" data-pen=\"fill-opacity\"></label>\n <select data-pen=\"blend\" title=\"Blend mode\">\n <option value=\"normal\">Normal</option>\n <option value=\"multiply\">Multiply</option>\n <option value=\"screen\">Screen</option>\n <option value=\"overlay\">Overlay</option>\n <option value=\"darken\">Darken</option>\n <option value=\"lighten\">Lighten</option>\n </select>\n </div>\n <div class=\"cs-pen-props__group cs-pen-group--stroke\">\n <span class=\"cs-pen-props__label\">Stroke</span>\n <label class=\"cs-pen-swatch\" title=\"Stroke colour\"><input type=\"color\" data-pen=\"stroke\"></label>\n <label class=\"cs-pen-num\" title=\"Stroke width\">W<input type=\"number\" min=\"0\" max=\"40\" step=\"1\" data-pen=\"stroke-width\"></label>\n </div>\n </div>\n </div>`;\n\n const buildToolbar = () => {\n const wrap = document.createElement('div');\n wrap.innerHTML = TOOL_HTML.trim();\n const bar = wrap.firstChild;\n // The style/transform controls live in a movable container so the modal can\n // relocate them into its right-hand panel (setLayersPanel's sibling).\n const propsEl = bar.querySelector('[data-pen-props]');\n S.propsEl = propsEl;\n const q = (sel) => propsEl.querySelector(sel);\n const set = (sel, v) => { const el = q(sel); if (el) el.value = v; };\n const stopsEl = q('[data-pen-stops]');\n S.layersEl = bar.querySelector('[data-pen-layers]');\n\n // Rebuild the gradient colour-stop swatches from the active style.\n const renderStops = () => {\n const cols = gradStopColors(getActiveStyle());\n stopsEl.replaceChildren();\n cols.forEach((c) => {\n const lbl = document.createElement('label');\n lbl.className = 'cs-pen-swatch';\n const inp = document.createElement('input');\n inp.type = 'color'; inp.dataset.pen = 'grad-stop'; inp.value = c;\n lbl.appendChild(inp);\n stopsEl.appendChild(lbl);\n });\n };\n\n // Push the ACTIVE sub-path's style into the toolbar inputs. Stored on S so\n // selecting another clip-path can refresh the controls to match it.\n const applyStyleValues = () => {\n const st = getActiveStyle();\n set('[data-pen=\"fill-type\"]', st.fillType);\n set('[data-pen=\"fill\"]', st.fill || DEFAULT_FILL);\n set('[data-pen=\"grad-kind\"]', st.gradKind || 'linear');\n set('[data-pen=\"grad-angle\"]', st.gradAngle);\n set('[data-pen=\"rotate\"]', st.rotate || 0);\n set('[data-pen=\"fill-opacity\"]', st.fillOpacity ?? 1);\n set('[data-pen=\"blend\"]', st.blend || 'normal');\n set('[data-pen=\"stroke\"]', st.stroke || '#000000');\n set('[data-pen=\"stroke-width\"]', st.strokeWidth || 0);\n renderStops();\n };\n S.applyStyleValues = applyStyleValues;\n\n bar.addEventListener('pointerdown', (e) => e.stopPropagation());\n propsEl.addEventListener('pointerdown', (e) => e.stopPropagation());\n\n // Tool actions live on the floating toolbar.\n bar.addEventListener('click', (e) => {\n const btn = e.target.closest('button[data-pen]');\n if (!btn || !bar.contains(btn) || propsEl.contains(btn)) return;\n const cmd = btn.dataset.pen;\n if (cmd === 'pen') { setResizeMode(false); S.mode = 'pen'; startNewPath(); }\n else if (cmd === 'edit') { setResizeMode(false); S.mode = 'edit'; }\n else if (cmd === 'resize') setResizeMode(!S.resizeMode);\n else if (cmd === 'snap') S.snap = !S.snap;\n else if (cmd === 'smooth') smoothAll();\n else if (cmd === 'clear') clearAllPaths();\n else if (cmd === 'dup') duplicateActivePath();\n else if (cmd === 'del-shape') deleteActivePath();\n else if (cmd === 'fwd') reorderActivePath(1);\n else if (cmd === 'back') reorderActivePath(-1);\n else if (cmd.startsWith('preset-')) loadPreset(cmd.slice(7));\n else if (cmd === 'delete') deleteSelected();\n else if (cmd === 'undo') undo();\n else if (cmd === 'redo') redo();\n else return;\n syncToolbar();\n });\n\n // Style / transform actions live on the (movable) props container.\n propsEl.addEventListener('click', (e) => {\n const btn = e.target.closest('button[data-pen]');\n if (!btn) return;\n const cmd = btn.dataset.pen;\n if (cmd === 'flip-h') flip('h');\n else if (cmd === 'flip-v') flip('v');\n else if (cmd === 'image') pickImage();\n else if (cmd === 'stop-add') { const s = Object.assign({}, getActiveStyle()); const c = gradStopColors(s); c.push(c[c.length - 1]); s.gradStops = c; s.fillType = 'gradient'; setActiveStyle(s); renderShape(S.block); applyStyleValues(); }\n else if (cmd === 'stop-del') { const s = Object.assign({}, getActiveStyle()); const c = gradStopColors(s); if (c.length > 2) { c.pop(); s.gradStops = c; setActiveStyle(s); renderShape(S.block); applyStyleValues(); } }\n else return;\n syncToolbar();\n });\n\n const onStyle = () => {\n // Edit the ACTIVE sub-path's style (keeps the others untouched).\n const s = Object.assign({}, getActiveStyle());\n s.fillType = q('[data-pen=\"fill-type\"]').value;\n s.fill = q('[data-pen=\"fill\"]').value;\n s.gradKind = q('[data-pen=\"grad-kind\"]').value;\n const stops = Array.from(propsEl.querySelectorAll('[data-pen=\"grad-stop\"]')).map((i) => i.value);\n if (stops.length >= 2) { s.gradStops = stops; s.gradFrom = stops[0]; s.gradTo = stops[stops.length - 1]; }\n s.gradAngle = Number(q('[data-pen=\"grad-angle\"]').value) || 0;\n s.rotate = Number(q('[data-pen=\"rotate\"]').value) || 0;\n const fo = Number(q('[data-pen=\"fill-opacity\"]').value);\n s.fillOpacity = isNaN(fo) ? 1 : fo;\n s.blend = q('[data-pen=\"blend\"]').value;\n s.stroke = q('[data-pen=\"stroke\"]').value;\n s.strokeWidth = Number(q('[data-pen=\"stroke-width\"]').value) || 0;\n setActiveStyle(s);\n S.rotate = s.rotate;\n renderShape(S.block);\n updateFillControls();\n drawOverlay();\n renderLayers();\n };\n // Listeners on propsEl so they travel with it when moved to the side panel.\n // `input` updates live; `change` covers browsers whose colour dialog only\n // commits on close.\n propsEl.addEventListener('input', onStyle);\n propsEl.addEventListener('change', onStyle);\n\n applyStyleValues();\n renderLayers();\n return bar;\n };\n\n const updateFillControls = () => {\n if (!S || !S.propsEl) return;\n const p = S.propsEl;\n const t = p.querySelector('[data-pen=\"fill-type\"]').value;\n p.querySelector('.cs-pen-fill-solid').style.display = (t === 'solid') ? '' : 'none';\n p.querySelector('.cs-pen-fill-gradient').style.display = (t === 'gradient') ? '' : 'none';\n p.querySelector('.cs-pen-fill-image').style.display = (t === 'image') ? '' : 'none';\n // Angle only matters for a linear gradient.\n const kind = p.querySelector('[data-pen=\"grad-kind\"]').value;\n const angle = p.querySelector('[data-pen=\"grad-angle\"]');\n if (angle?.parentElement) angle.parentElement.style.display = (kind === 'radial') ? 'none' : '';\n };\n\n const pickImage = () => {\n const inp = document.createElement('input');\n inp.type = 'file'; inp.accept = 'image/*';\n inp.addEventListener('change', () => {\n const file = inp.files && inp.files[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => {\n const s = Object.assign({}, getActiveStyle());\n s.imageSrc = reader.result; s.fillType = 'image';\n setActiveStyle(s);\n const ft = S.propsEl?.querySelector('[data-pen=\"fill-type\"]'); if (ft) ft.value = 'image';\n renderShape(S.block); updateFillControls();\n };\n reader.readAsDataURL(file);\n });\n inp.click();\n };\n\n const syncToolbar = () => {\n if (!S) return;\n S.toolbar.querySelectorAll('[data-pen=\"pen\"],[data-pen=\"edit\"],[data-pen=\"resize\"],[data-pen=\"snap\"]').forEach((b) => b.classList.remove('is-active'));\n if (S.resizeMode) S.toolbar.querySelector('[data-pen=\"resize\"]')?.classList.add('is-active');\n else S.toolbar.querySelector(`[data-pen=\"${S.mode}\"]`)?.classList.add('is-active');\n if (S.snap) S.toolbar.querySelector('[data-pen=\"snap\"]')?.classList.add('is-active');\n // Keep the style controls + rotation in sync with whatever sub-path is now\n // active (e.g. after undo/redo/delete changed activePath).\n S.rotate = getActiveStyle().rotate || 0;\n S.applyStyleValues?.();\n updateFillControls();\n drawOverlay();\n renderLayers();\n };\n\n /* ------------------------------ keyboard ---------------------------------- */\n\n const onKey = (e) => {\n if (!S) return;\n // Don't hijack typing in form fields (rename input, stroke-width, etc.).\n const tag = e.target && e.target.tagName;\n if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target?.isContentEditable) return;\n // Hold Space → temporary \"move whole clip-path\" mode (drag relocates the\n // active shape). Swallow the key so the page/stage doesn't scroll.\n if (e.key === ' ' || e.code === 'Space') {\n e.preventDefault(); e.stopPropagation();\n if (!S.spaceHeld) { S.spaceHeld = true; S.overlay?.classList.add('cs-pen-pan'); }\n return;\n }\n const z = (e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z');\n const y = (e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y');\n if (z) { e.preventDefault(); e.stopPropagation(); (e.shiftKey ? redo : undo)(); syncToolbar(); return; }\n if (y) { e.preventDefault(); e.stopPropagation(); redo(); syncToolbar(); return; }\n const mod = e.ctrlKey || e.metaKey;\n if (mod && (e.key === 'c' || e.key === 'C')) { e.preventDefault(); e.stopPropagation(); copyActivePath(); return; }\n if (mod && (e.key === 'v' || e.key === 'V')) { e.preventDefault(); e.stopPropagation(); pastePath(); syncToolbar(); return; }\n if (mod && (e.key === 'd' || e.key === 'D')) { e.preventDefault(); e.stopPropagation(); duplicateActivePath(); syncToolbar(); return; }\n if (e.key === 'Delete' || e.key === 'Backspace') {\n e.preventDefault(); e.stopPropagation();\n // An anchor selected (or mid-draw) → delete just that point. Otherwise a\n // whole shape is selected → delete the entire shape (like the layer 🗑).\n if (S.sel || S.mode === 'pen') deleteSelected();\n else deleteActivePath();\n syncToolbar();\n return;\n }\n if (e.key === 'Enter' && S.mode === 'pen') {\n const ap = S.state.paths[S.activePath];\n if (ap && !ap.closed && ap.anchors.length > 2) { e.preventDefault(); e.stopPropagation(); snapshot(); ap.closed = true; commit(); syncToolbar(); }\n return;\n }\n // Arrow keys nudge the selected anchor, or the whole active shape if none\n // is selected. Shift = bigger step.\n if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {\n e.preventDefault(); e.stopPropagation();\n const horiz = e.key === 'ArrowLeft' || e.key === 'ArrowRight';\n const step = (e.shiftKey ? 20 : 4) * ((e.key === 'ArrowLeft' || e.key === 'ArrowUp') ? -1 : 1);\n const move = (a) => {\n if (horiz) { a.x += step; if (a.inX != null) a.inX += step; if (a.outX != null) a.outX += step; }\n else { a.y += step; if (a.inY != null) a.inY += step; if (a.outY != null) a.outY += step; }\n };\n snapshot();\n const selA = S.sel && S.state.paths[S.sel.p]?.anchors[S.sel.i];\n if (selA) move(selA);\n else { const p = S.state.paths[S.activePath]; if (p) p.anchors.forEach(move); }\n commit();\n }\n };\n\n // Releasing Space ends the temporary \"move clip-path\" mode.\n const onKeyUp = (e) => {\n if (!S) return;\n if (e.key === ' ' || e.code === 'Space') {\n S.spaceHeld = false;\n S.overlay?.classList.remove('cs-pen-pan');\n }\n };\n\n /* --------------------------- activate / deactivate ------------------------ */\n\n const activate = (block) => {\n if (S && S.block === block) return;\n if (S) deactivate();\n const inner = block.querySelector('.cs-pen-shape');\n if (!inner) return;\n\n const overlay = document.createElement('div');\n overlay.className = 'cs-pen-overlay';\n overlay.setAttribute('data-cs-chrome', '');\n const ovSvg = ns('svg', { class: 'cs-pen-overlay-svg' });\n overlay.appendChild(ovSvg);\n\n const state = readState(block);\n // Give any unnamed sub-path a stable name so the layers panel labels don't\n // renumber on reorder (older shapes were drawn before names existed).\n state.paths.forEach((p, i) => { if (!p.name) p.name = `Shape ${i + 1}`; });\n // Continue an open sub-path if one exists; else edit existing shapes; else\n // start fresh in pen mode.\n const openIdx = state.paths.findIndex((p) => !p.closed);\n const activePath = openIdx >= 0 ? openIdx : (state.paths.length - 1);\n const activeStyle = state.paths[activePath]?.style || readStyle(block);\n S = {\n block, inner, overlay, ovSvg, state, rotate: activeStyle.rotate || 0,\n mode: 'pen',\n // Page designer enlarges the overlay past the page → allow off-page points.\n freeDraw: block.classList.contains('cs-page-shape-block'),\n activePath,\n sel: null, drag: null, cursor: null, penHover: null, guides: null, resizeMode: false, snap: false, spaceHeld: false, layersEl: null, panelEl: null, propsEl: null, selected: new Set(), undo: [], redo: []\n };\n S.toolbar = buildToolbar();\n overlay.appendChild(S.toolbar);\n inner.appendChild(overlay);\n\n overlay.addEventListener('pointerdown', onDown);\n overlay.addEventListener('pointermove', onMove);\n overlay.addEventListener('pointerup', onUp);\n overlay.addEventListener('pointercancel', onUp);\n // Listen on the block's OWN document so shortcuts work even when the block\n // lives in the host document (the page-shape designer renders its modal at\n // the app root, outside this iframe).\n S.keyDoc = block.ownerDocument || document;\n S.keyDoc.addEventListener('keydown', onKey, true);\n S.keyDoc.addEventListener('keyup', onKeyUp, true);\n\n // inline-editor.js's attachChrome() runs removeChrome() ~2 frames after edit\n // mode starts, which deletes every [data-cs-chrome] — including our overlay.\n // Re-append it whenever it gets stripped while we're still editing.\n S.guard = new MutationObserver(() => {\n if (S && S.block === block && block.classList.contains('cs-editing') && !inner.contains(overlay)) {\n inner.appendChild(overlay);\n drawOverlay();\n }\n });\n S.guard.observe(inner, { childList: true });\n\n S.ro = new ResizeObserver(() => drawOverlay());\n S.ro.observe(inner);\n\n syncToolbar();\n };\n\n const deactivate = () => {\n if (!S) return;\n (S.keyDoc || document).removeEventListener('keydown', onKey, true);\n (S.keyDoc || document).removeEventListener('keyup', onKeyUp, true);\n S.guard?.disconnect();\n S.ro?.disconnect();\n S.overlay.remove();\n writeState(S.block, S.state);\n renderShape(S.block);\n S = null;\n };\n\n /* ------------------------- public engine surface -------------------------- */\n // Expose the reusable pen engine so other UIs (e.g. the full-page background\n // shape designer) can run the exact same drawing/editing session on any\n // block built by createBlock() — no code duplication.\n Object.assign(window.PenShape, {\n activate, // activate(block) → start the pen session + toolbar overlay\n deactivate, // deactivate() → end the session, write state, render final\n renderShape, // renderShape(block) → repaint <path>/<defs> from dataset\n readState, writeState,\n readStyle, writeStyle,\n clearAllPaths, // clearAllPaths() → wipe the active session's shapes\n loadPreset, // loadPreset(name) → add a preset shape (rectangle, corner, …)\n mergeSelected, // merge the multi-selected layers into one\n toggleLockSelected, // lock / unlock the multi-selected layers\n getActiveBlock: () => (S ? S.block : null),\n // Attach (or detach with null) an external element to host the rich,\n // Photoshop-style layers panel. The engine fills + keeps it in sync.\n setLayersPanel: (el) => {\n if (!S) return;\n S.panelEl = el || null;\n S.toolbar?.classList.toggle('cs-pen-has-panel', !!el);\n renderLayers();\n },\n // Relocate the style/transform controls into an external host (the modal's\n // right-hand panel). They keep working because their listeners + queries are\n // bound to the props container itself, not the toolbar.\n setPropsPanel: (el) => {\n if (!S || !S.propsEl) return;\n if (el) { el.appendChild(S.propsEl); S.propsEl.classList.add('cs-pen-props--panel'); }\n else if (S.toolbar) { S.toolbar.appendChild(S.propsEl); S.propsEl.classList.remove('cs-pen-props--panel'); }\n S.toolbar?.classList.toggle('cs-pen-has-props-panel', !!el);\n updateFillControls();\n },\n VIEWBOX: VB,\n });\n\n /* --------------------------------- wiring --------------------------------- */\n const init = () => {\n // Watch the whole page board, not a single .custom-form-design: each cover\n // page is its OWN .custom-form-design surface (a sibling under .cs_paper),\n // so observing only the first one missed pen-shape blocks dropped on cover\n // pages — their cs-editing class change was never seen and activate() never\n // ran. .cs_paper contains every page (content wrappers + covers).\n const surface = document.querySelector('.cs_paper')\n || document.querySelector('.custom-form-design')\n || document.body;\n if (!surface) return;\n const obs = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName !== 'class') continue;\n const el = m.target;\n if (!el.classList || el.dataset.blockType !== 'pen-shape') continue;\n if (el.classList.contains('cs-editing')) activate(el);\n else if (S && S.block === el) deactivate();\n }\n });\n obs.observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/table-block.js\">\n/**\n * @fileoverview Static Table block — a Canva-style editable table.\n *\n * A plain <table> inside a .cs_block_s. Unlike the data-bound Table Repeater,\n * this is a STATIC table the user builds by hand: type into cells, add/remove\n * rows & columns, merge/split cells, resize columns/rows, and style cells\n * (fill / border / alignment / text format).\n *\n * It exports cleanly: the <table> is plain DOM the Twig generator clones; the\n * floating toolbar lives in <body> with [data-cs-chrome] so it's never\n * exported and never starts a drag. Cell `contenteditable` is stripped on\n * deactivate.\n *\n * Editing turns on when the block enters `.cs-editing` (the same state machine\n * inline-editor.js drives for every block). Because the table has no `.edit_me`\n * target, inline-editor's text-editor init no-ops and we own all interaction.\n *\n * Internals use a rectangular ID-matrix (read() ⇄ render()) so merges,\n * inserts and deletes stay correct even with col/row spans.\n *\n * Exposes: window.TableBlock.createBlock(rows, cols)\n */\n(function () {\n window.TableBlock = window.TableBlock || {};\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2));\n\n /* ------------------------------ block markup ----------------------------- */\n\n const buildTableEl = (rows, cols) => {\n const table = document.createElement('table');\n table.className = 'cs-table';\n table.id = `dynamic_${hash()}`;\n\n const cg = document.createElement('colgroup');\n for (let c = 0; c < cols; c++) {\n const col = document.createElement('col');\n col.style.width = `${(100 / cols).toFixed(4)}%`;\n cg.appendChild(col);\n }\n table.appendChild(cg);\n\n const tbody = document.createElement('tbody');\n for (let r = 0; r < rows; r++) {\n const tr = document.createElement('tr');\n for (let c = 0; c < cols; c++) {\n const td = document.createElement('td');\n td.className = 'cs-cell' + (r === 0 ? ' cs-cell--head' : '');\n td.innerHTML = r === 0 ? `` : '';\n tr.appendChild(td);\n }\n tbody.appendChild(tr);\n }\n table.appendChild(tbody);\n return table;\n };\n\n const createBlock = (rows = 3, cols = 3) => {\n const block = document.createElement('div');\n block.className = 'cs_block_s cs-table-block';\n block.setAttribute('data', 'Table');\n block.setAttribute('custom-name', 'Table');\n block.dataset.blockType = 'table';\n block.id = `block_${hash()}`;\n\n const table = buildTableEl(rows, cols);\n\n // Froala mode: wrap the table in an `.edit_me` so inline-editor.js inits\n // Froala on it (giving Froala's built-in table cell editing) — same pattern\n // as the Table Repeater, just without data binding. Custom mode: bare table\n // so our own engine drives it.\n if ((typeof window.isFroalaEditor === 'function') && window.isFroalaEditor()) {\n const wrap = document.createElement('div');\n wrap.className = 'edit_me cs-table-edit fr-element fr-view';\n wrap.id = `dynamic_${hash()}`;\n wrap.appendChild(table);\n block.appendChild(wrap);\n } else {\n block.appendChild(table);\n }\n return block;\n };\n\n /* ------------------------- matrix read / render -------------------------- */\n\n // Build an ID-matrix M[r][c] -> cellId, plus a cells{} map of cell data.\n // Spanned slots all point at the same id; the cell's bounding rect (derived\n // at render time) yields its colspan/rowspan.\n const read = (table) => {\n const body = table.tBodies[0];\n const trs = Array.from(body ? body.rows : []);\n const M = [];\n const cells = {};\n let nextId = 1;\n trs.forEach((tr, r) => {\n M[r] = M[r] || [];\n let c = 0;\n Array.from(tr.cells).forEach((td) => {\n while (M[r][c] !== undefined) c++;\n const id = nextId++;\n cells[id] = { html: td.innerHTML, style: td.getAttribute('style') || '', head: td.classList.contains('cs-cell--head') };\n const cs = td.colSpan || 1, rs = td.rowSpan || 1;\n for (let i = 0; i < rs; i++) { M[r + i] = M[r + i] || []; for (let j = 0; j < cs; j++) M[r + i][c + j] = id; }\n c += cs;\n });\n });\n const cols = M.reduce((m, row) => Math.max(m, row.length), 0);\n // Fill ragged gaps with fresh empty cells so the matrix is rectangular.\n M.forEach((row) => { for (let c = 0; c < cols; c++) if (row[c] === undefined) { const id = nextId++; cells[id] = { html: '', style: '', head: false }; row[c] = id; } });\n return { M, cells, rows: M.length, cols };\n };\n\n const colWidthsOf = (table) => Array.from(table.querySelectorAll('colgroup > col')).map((c) => c.style.width || '');\n\n /**\n * Legacy Froala mode only: Froala's built-in \"insert column/row\" creates plain\n * <td>s that are missing our `cs-cell` class (so they get no border) and often\n * carry a junk `style=\"null; width:…\"` attribute. Re-stamp every cell so it\n * looks like a real table cell again. A freshly inserted cell mirrors the\n * header state of its row's already-stamped siblings, so a column inserted\n * into the header row stays a header. Returns true if anything changed.\n */\n const normalizeCells = (table) => {\n if (!table) return false;\n let changed = false;\n Array.from(table.rows).forEach((tr) => {\n // Captured before we stamp anything: do this row's existing cells read as\n // header cells? Column inserts should match their row.\n const rowIsHead = Array.from(tr.cells).some((c) => c.classList.contains('cs-cell--head'));\n Array.from(tr.cells).forEach((td) => {\n if (!td.classList.contains('cs-cell')) {\n td.classList.add('cs-cell');\n if (rowIsHead) td.classList.add('cs-cell--head');\n changed = true;\n }\n // Drop the literal \"null\" Froala prepends to copied style attributes.\n const style = td.getAttribute('style');\n if (style && /(^|;)\\s*null\\s*(;|$)/.test(style)) {\n const cleaned = style.replace(/(^|;)\\s*null\\s*(;|$)/g, '$1').replace(/^;+/, '').trim();\n if (cleaned) td.setAttribute('style', cleaned);\n else td.removeAttribute('style');\n changed = true;\n }\n });\n });\n return changed;\n };\n\n // Re-render <colgroup> + <tbody> from a matrix. Each cell is emitted once at\n // the top-left of its bounding rect with the right colspan/rowspan.\n const render = (table, state, colWidths) => {\n const { M, cells, rows, cols } = state;\n const rect = {};\n for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {\n const id = M[r][c];\n const b = rect[id] || (rect[id] = { r0: r, c0: c, r1: r, c1: c });\n b.r0 = Math.min(b.r0, r); b.c0 = Math.min(b.c0, c); b.r1 = Math.max(b.r1, r); b.c1 = Math.max(b.c1, c);\n }\n const tbody = document.createElement('tbody');\n for (let r = 0; r < rows; r++) {\n const tr = document.createElement('tr');\n for (let c = 0; c < cols; c++) {\n const id = M[r][c];\n const b = rect[id];\n if (b.r0 !== r || b.c0 !== c) continue; // skip non top-left slots\n const td = document.createElement('td');\n td.className = 'cs-cell' + (cells[id].head ? ' cs-cell--head' : '');\n if (cells[id].style) td.setAttribute('style', cells[id].style);\n const cspan = b.c1 - b.c0 + 1, rspan = b.r1 - b.r0 + 1;\n if (cspan > 1) td.colSpan = cspan;\n if (rspan > 1) td.rowSpan = rspan;\n td.innerHTML = cells[id].html || '';\n tr.appendChild(td);\n }\n tbody.appendChild(tr);\n }\n const oldCg = table.querySelector('colgroup');\n if (oldCg) oldCg.remove();\n const cg = document.createElement('colgroup');\n for (let c = 0; c < cols; c++) {\n const col = document.createElement('col');\n const w = (colWidths && colWidths[c]) || `${(100 / cols).toFixed(4)}%`;\n col.style.width = w;\n cg.appendChild(col);\n }\n if (table.tBodies[0]) { table.insertBefore(cg, table.tBodies[0]); table.tBodies[0].replaceWith(tbody); }\n else { table.appendChild(cg); table.appendChild(tbody); }\n };\n\n /* ------------------------------ coordinates ------------------------------ */\n\n // The matrix top-left (r,c) of a rendered <td>. A row's DOM cells are exactly\n // the cells whose top-left is on that row (rowspan cells from above don't\n // appear in the row), so we map the td's DOM index to the n-th column that\n // starts a cell on this row.\n const cellRect = (table, td) => {\n const state = read(table);\n const tr = td.parentElement;\n const r0 = Array.from(table.tBodies[0].rows).indexOf(tr);\n const idx = Array.from(tr.cells).indexOf(td);\n const starts = [];\n for (let c = 0; c < state.cols; c++) {\n const id = state.M[r0][c];\n const topLeftHere = (r0 === 0 || state.M[r0 - 1][c] !== id) && (c === 0 || state.M[r0][c - 1] !== id);\n if (topLeftHere) starts.push(c);\n }\n const c0 = starts[idx] != null ? starts[idx] : 0;\n return { r: r0, c: c0, state };\n };\n\n /* --------------------------------- engine -------------------------------- */\n\n let S = null; // { block, table, toolbar, selected:Set<td>, anchor, ... }\n\n const getColWidths = () => colWidthsOf(S.table);\n\n // The rendered <td> that owns matrix slot (r,c) — used to re-find the active\n // cell after a re-render so follow-up ops still target the right cell.\n const tdAt = (r, c) => {\n const state = read(S.table);\n if (!state.M[r] || state.M[r][c] == null) return null;\n const id = state.M[r][c];\n let found = null;\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n if (found) return;\n const rc = cellRect(S.table, td);\n if (state.M[rc.r][rc.c] === id) found = td;\n });\n return found;\n };\n\n // Run a structural op: read → mutate(state) → render → re-wire cells. The\n // active cell is re-resolved by coordinate so the next op keeps targeting it.\n const apply = (mutate) => {\n const ac = (S.activeCell && S.table.contains(S.activeCell)) ? cellRect(S.table, S.activeCell) : null;\n const state = read(S.table);\n const widths = getColWidths();\n const next = mutate(state, widths) || {};\n render(S.table, state, next.widths || widths);\n wireCells();\n clearSelection();\n if (ac) {\n const d = read(S.table);\n S.activeCell = tdAt(Math.min(ac.r, d.rows - 1), Math.min(ac.c, d.cols - 1));\n S.anchorCell = S.activeCell;\n } else { S.activeCell = null; S.anchorCell = null; }\n updateOverlay();\n emitChange();\n };\n\n const activeCoord = () => {\n const td = S.activeCell && S.table.contains(S.activeCell) ? S.activeCell : S.table.querySelector('.cs-cell');\n if (!td) return { r: 0, c: 0 };\n return cellRect(S.table, td);\n };\n\n /* structural operations -------------------------------------------------- */\n\n const insertRow = (where) => apply((st) => {\n const { r } = activeCoord();\n const at = where === 'above' ? r : r + 1;\n const row = [];\n for (let c = 0; c < st.cols; c++) {\n // If a vertical span crosses the insert boundary, extend it.\n if (at > 0 && at < st.rows && st.M[at - 1][c] === st.M[at][c]) row[c] = st.M[at][c];\n else { const id = freshId(st); row[c] = id; }\n }\n st.M.splice(at, 0, row);\n st.rows++;\n });\n\n const insertCol = (where) => apply((st, widths) => {\n const { c } = activeCoord();\n const at = where === 'left' ? c : c + 1;\n for (let r = 0; r < st.rows; r++) {\n if (at > 0 && at < st.cols && st.M[r][at - 1] === st.M[r][at]) st.M[r].splice(at, 0, st.M[r][at]); // inside a horizontal span\n else st.M[r].splice(at, 0, freshId(st, r === 0));\n }\n st.cols++;\n const nw = widths.slice(); nw.splice(at, 0, `${(100 / st.cols).toFixed(4)}%`);\n return { widths: nw };\n });\n\n const deleteRow = () => apply((st) => {\n if (st.rows <= 1) return;\n const { r } = activeCoord();\n st.M.splice(r, 1); st.rows--;\n });\n\n const deleteCol = () => apply((st, widths) => {\n if (st.cols <= 1) return;\n const { c } = activeCoord();\n st.M.forEach((row) => row.splice(c, 1)); st.cols--;\n const nw = widths.slice(); nw.splice(c, 1);\n return { widths: nw };\n });\n\n // A fresh empty cell id added to a state mid-mutation.\n const freshId = (st, head = false) => {\n const id = (st._next || (st._next = Object.keys(st.cells).length + 1000)) + 1;\n st._next = id;\n st.cells[id] = { html: '', style: '', head };\n return id;\n };\n\n /* merge / split ---------------------------------------------------------- */\n\n // Bounding rect of the current selection + whether it COMPLETELY fills that\n // rect (no gaps). Merge is allowed only for a full rectangle of ≥2 cells —\n // otherwise a diagonal/sparse pick would swallow unselected cells.\n const selectionRectInfo = () => {\n const tds = S.selected.size ? Array.from(S.selected) : (S.activeCell ? [S.activeCell] : []);\n if (!tds.length) return null;\n const state = read(S.table);\n const ids = new Set();\n tds.forEach((td) => { const rc = cellRect(S.table, td); ids.add(state.M[rc.r][rc.c]); });\n let r0 = Infinity, c0 = Infinity, r1 = -1, c1 = -1, slots = 0;\n for (let r = 0; r < state.rows; r++) for (let c = 0; c < state.cols; c++) {\n if (ids.has(state.M[r][c])) { slots++; r0 = Math.min(r0, r); c0 = Math.min(c0, c); r1 = Math.max(r1, r); c1 = Math.max(c1, c); }\n }\n const area = (r1 - r0 + 1) * (c1 - c0 + 1);\n return { r0, c0, r1, c1, filled: slots === area, cellCount: ids.size };\n };\n\n // Merge offered only when the picked cells form a complete rectangle (≥2).\n const canMerge = () => { const i = selectionRectInfo(); return !!i && i.filled && i.cellCount > 1; };\n // Split offered only for an already-merged cell.\n const canSplit = () => { const td = S.activeCell; return !!td && ((td.colSpan || 1) > 1 || (td.rowSpan || 1) > 1); };\n\n const mergeCells = () => {\n const info = selectionRectInfo();\n if (!info || !info.filled || info.cellCount < 2) return;\n const { r0, c0, r1, c1 } = info;\n apply((st) => {\n const keep = st.M[r0][c0];\n const parts = [];\n const done = new Set();\n for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) {\n const id = st.M[r][c];\n if (!done.has(id)) { done.add(id); const h = (st.cells[id].html || '').trim(); if (h && id !== keep) parts.push(h); }\n st.M[r][c] = keep;\n }\n if (parts.length) st.cells[keep].html = [(st.cells[keep].html || '').trim(), ...parts].filter(Boolean).join(' ');\n });\n };\n\n const splitCell = () => {\n if (!canSplit()) return;\n const { r, c } = activeCoord();\n apply((st) => {\n const id = st.M[r][c];\n let first = true;\n for (let rr = 0; rr < st.rows; rr++) for (let cc = 0; cc < st.cols; cc++) {\n if (st.M[rr][cc] === id) {\n if (first) { first = false; } // keep master slot as-is\n else st.M[rr][cc] = freshId(st, st.cells[id].head);\n }\n }\n });\n };\n\n /* cell styling ----------------------------------------------------------- */\n\n const eachSelected = (fn) => {\n const tds = S.selected.size ? Array.from(S.selected) : (S.activeCell ? [S.activeCell] : []);\n tds.forEach(fn);\n emitChange();\n };\n\n const setCellStyle = (prop, value) => eachSelected((td) => { td.style[prop] = value; });\n const toggleHeader = () => eachSelected((td) => td.classList.toggle('cs-cell--head'));\n\n const setBorder = (color, on) => eachSelected((td) => {\n if (on === false) { td.style.border = 'none'; return; }\n td.style.border = `1px solid ${color || '#d0d5e2'}`;\n });\n\n // Text format inside the focused cell via execCommand.\n const textCmd = (cmd, val) => {\n if (S.activeCell) S.activeCell.focus();\n try { document.execCommand('styleWithCSS', false, true); } catch (e) { /* */ }\n try { document.execCommand(cmd, false, val == null ? null : val); } catch (e) { /* */ }\n emitChange();\n };\n\n /* ------------------------------- selection ------------------------------- */\n\n const clearSelection = () => {\n if (!S) return;\n S.table.querySelectorAll('.cs-cell--selected').forEach((td) => td.classList.remove('cs-cell--selected'));\n S.selected.clear();\n updateOverlay();\n };\n\n const selectRange = (a, b) => {\n clearSelection();\n const ra = cellRect(S.table, a), rb = cellRect(S.table, b);\n const r0 = Math.min(ra.r, rb.r), r1 = Math.max(ra.r, rb.r);\n const c0 = Math.min(ra.c, rb.c), c1 = Math.max(ra.c, rb.c);\n const state = read(S.table);\n const ids = new Set();\n for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) ids.add(state.M[r][c]);\n // map ids back to rendered tds\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n const rc = cellRect(S.table, td);\n if (ids.has(state.M[rc.r][rc.c])) { td.classList.add('cs-cell--selected'); S.selected.add(td); }\n });\n updateOverlay();\n };\n\n // Single Canva-style rectangle drawn over the union of the selected cells\n // (a body-level fixed box so it isn't clipped and needs no block positioning).\n const updateOverlay = () => {\n if (!S) return;\n if (!S.overlay) { S.overlay = document.createElement('div'); S.overlay.className = 'cs-tbl-selrect'; S.overlay.setAttribute('data-cs-chrome', ''); document.body.appendChild(S.overlay); }\n if (!S.selected.size) { S.overlay.style.display = 'none'; return; }\n let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;\n S.selected.forEach((td) => { const q = td.getBoundingClientRect(); l = Math.min(l, q.left); t = Math.min(t, q.top); r = Math.max(r, q.right); b = Math.max(b, q.bottom); });\n S.overlay.style.display = 'block';\n S.overlay.style.left = `${l}px`;\n S.overlay.style.top = `${t}px`;\n S.overlay.style.width = `${r - l}px`;\n S.overlay.style.height = `${b - t}px`;\n };\n\n /* ------------------------------- toolbar --------------------------------- */\n\n // Self-explanatory inline-SVG icons so each table tool reads at a glance:\n // a green band + \"+\" means INSERT a row/column on that side, a red band + \"✕\"\n // means DELETE, etc. Tooltips (title=) still spell every button out.\n const svg = (inner, vb = '0 0 18 18') =>\n `<svg width=\"15\" height=\"15\" viewBox=\"${vb}\" fill=\"none\" aria-hidden=\"true\">${inner}</svg>`;\n const PLUS = (cx, cy) =>\n `<path d=\"M${cx} ${cy - 1.15}V${cy + 1.15}M${cx - 1.15} ${cy}H${cx + 1.15}\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>`;\n const CROSS = (cx, cy) =>\n `<path d=\"M${cx - 1.1} ${cy - 1.1}L${cx + 1.1} ${cy + 1.1}M${cx + 1.1} ${cy - 1.1}L${cx - 1.1} ${cy + 1.1}\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>`;\n\n const ICON = {\n // existing table outlined in the current colour + a green \"new\" band w/ +\n rowAbove: svg(`<rect x=\"2.5\" y=\"8\" width=\"13\" height=\"7.5\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"8\" x2=\"9\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"2.5\" y=\"2\" width=\"13\" height=\"4.4\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(9, 4.2)}`),\n rowBelow: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"7.5\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"2.5\" x2=\"9\" y2=\"10\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"2.5\" y=\"11.6\" width=\"13\" height=\"4.4\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(9, 13.8)}`),\n colLeft: svg(`<rect x=\"8\" y=\"2.5\" width=\"7.5\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"8\" y1=\"9\" x2=\"15.5\" y2=\"9\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"2\" y=\"2.5\" width=\"4.4\" height=\"13\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(4.2, 9)}`),\n colRight: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"7.5\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"2.5\" y1=\"9\" x2=\"10\" y2=\"9\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"11.6\" y=\"2.5\" width=\"4.4\" height=\"13\" rx=\"1.2\" fill=\"#34c759\"/>${PLUS(13.8, 9)}`),\n // full table + the doomed row/column tinted red w/ ✕\n delRow: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"2.5\" y1=\"7.2\" x2=\"15.5\" y2=\"7.2\" stroke=\"currentColor\" stroke-width=\"1\"/><line x1=\"2.5\" y1=\"10.8\" x2=\"15.5\" y2=\"10.8\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"3\" y=\"7.4\" width=\"12\" height=\"3.2\" fill=\"#ff5a5a\"/>${CROSS(9, 9)}`),\n delCol: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"7.2\" y1=\"2.5\" x2=\"7.2\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><line x1=\"10.8\" y1=\"2.5\" x2=\"10.8\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><rect x=\"7.4\" y=\"3\" width=\"3.2\" height=\"12\" fill=\"#ff5a5a\"/>${CROSS(9, 9)}`),\n // two cells → one (arrows in) / one cell → two (arrows out)\n merge: svg(`<rect x=\"2.5\" y=\"4.5\" width=\"13\" height=\"9\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"4.5\" x2=\"9\" y2=\"13.5\" stroke=\"currentColor\" stroke-width=\"1\" stroke-dasharray=\"1.6 1.6\"/><path d=\"M5.4 7.6 L7.8 9 L5.4 10.4 Z\" fill=\"currentColor\"/><path d=\"M12.6 7.6 L10.2 9 L12.6 10.4 Z\" fill=\"currentColor\"/>`),\n split: svg(`<rect x=\"2.5\" y=\"4.5\" width=\"13\" height=\"9\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><line x1=\"9\" y1=\"4.5\" x2=\"9\" y2=\"13.5\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M7.4 7.6 L5 9 L7.4 10.4 Z\" fill=\"currentColor\"/><path d=\"M10.6 7.6 L13 9 L10.6 10.4 Z\" fill=\"currentColor\"/>`),\n // table with a filled top row = header\n header: svg(`<rect x=\"2.5\" y=\"2.5\" width=\"13\" height=\"13\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><rect x=\"3\" y=\"3\" width=\"12\" height=\"3.4\" fill=\"currentColor\"/><line x1=\"9\" y1=\"6.4\" x2=\"9\" y2=\"15.5\" stroke=\"currentColor\" stroke-width=\"1\"/><line x1=\"2.5\" y1=\"11\" x2=\"15.5\" y2=\"11\" stroke=\"currentColor\" stroke-width=\"1\"/>`),\n // filled square = fill, outline square = border, dashed+slash = no border\n fill: svg(`<rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.6\" fill=\"currentColor\" opacity=\"0.55\"/><rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.2\"/>`),\n border: svg(`<rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.4\" stroke=\"currentColor\" stroke-width=\"1.8\"/>`),\n borderOff: svg(`<rect x=\"2.7\" y=\"2.7\" width=\"12.6\" height=\"12.6\" rx=\"1.4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-dasharray=\"2.2 1.8\"/><line x1=\"3.5\" y1=\"14.5\" x2=\"14.5\" y2=\"3.5\" stroke=\"#ff5a5a\" stroke-width=\"1.4\" stroke-linecap=\"round\"/>`),\n };\n\n // The table-ONLY controls. Appended to the END of the shared text toolbar so a\n // table shows \"[every text-block option] + [these]\", while a plain text block\n // shows just the text options. Insert/delete row-col · merge/split/header ·\n // cell border. (Cell fill = the shared \"highlight\" colour; text colour = the\n // shared \"A\".) Same green-band-+ / red-band-✕ icons as before.\n const tableGroupHTML = () => `\n <div class=\"cre-group\">\n <button type=\"button\" data-op=\"row-above\" title=\"Insert row above\">${ICON.rowAbove}</button>\n <button type=\"button\" data-op=\"row-below\" title=\"Insert row below\">${ICON.rowBelow}</button>\n <button type=\"button\" data-op=\"col-left\" title=\"Insert column left\">${ICON.colLeft}</button>\n <button type=\"button\" data-op=\"col-right\" title=\"Insert column right\">${ICON.colRight}</button>\n <button type=\"button\" data-op=\"del-row\" title=\"Delete row\">${ICON.delRow}</button>\n <button type=\"button\" data-op=\"del-col\" title=\"Delete column\">${ICON.delCol}</button>\n </div>\n <div class=\"cre-group\">\n <button type=\"button\" data-op=\"merge\" title=\"Merge selected cells\">${ICON.merge}</button>\n <button type=\"button\" data-op=\"split\" title=\"Split cell\">${ICON.split}</button>\n <button type=\"button\" data-op=\"header\" title=\"Toggle header row\">${ICON.header}</button>\n <label class=\"cre-color\" title=\"Cell border colour\">${ICON.border}<input type=\"color\" data-border value=\"#d0d5e2\"></label>\n <button type=\"button\" data-op=\"border-off\" title=\"Remove cell border\">${ICON.borderOff}</button>\n </div>`;\n\n // Heading at cell level = size + bold; \"Normal\" clears both back to default.\n const HEADING_PX = { h1: '32px', h2: '24px', h3: '19px', h4: '16px', h5: '13px', h6: '11px' };\n const applyCellHeading = (level) => eachSelected((td) => {\n if (HEADING_PX[level]) { td.style.fontSize = HEADING_PX[level]; td.style.fontWeight = '700'; }\n else { td.style.fontSize = ''; td.style.fontWeight = ''; }\n });\n\n // Text case via CSS text-transform (non-destructive).\n const applyCellCase = (value) => { if (value) setCellStyle('textTransform', value); };\n\n const buildToolbar = () => {\n const tb = document.createElement('div');\n // Same class as the text-block bar → identical look + placement + docked\n // behaviour. The extra `cre-toolbar--table` marker lets the click-away guard\n // recognise our bar. `is-visible` shows it (cre-toolbar is hidden by default).\n tb.className = 'cre-toolbar cre-toolbar--table is-visible';\n tb.setAttribute('data-cs-chrome', '');\n const richHTML = (window.CustomRichEditor && window.CustomRichEditor.toolbarInnerHTML)\n ? window.CustomRichEditor.toolbarInnerHTML(window.FROALA_FONTS || null, null)\n : '';\n tb.innerHTML = richHTML + tableGroupHTML();\n\n // Keep cell focus/selection when pressing a control (selects + colour inputs\n // need focus to open, so don't preventDefault those).\n tb.addEventListener('mousedown', (e) => { if (!e.target.closest('input, select')) e.preventDefault(); });\n tb.addEventListener('click', onToolbarClick);\n tb.addEventListener('change', onToolbarChange);\n tb.addEventListener('input', onToolbarInput);\n document.body.appendChild(tb);\n return tb;\n };\n\n // Route the SHARED text toolbar's controls to the table's cell operations, so\n // the same bar drives both. (data-cmd/-act/-sel/-color come from the rich\n // markup; data-op/-border are our appended table group.)\n const ALIGN_CMD = { justifyLeft: 'left', justifyCenter: 'center', justifyRight: 'right', justifyFull: 'justify' };\n const onToolbarClick = (e) => {\n const opBtn = e.target.closest('[data-op]');\n if (opBtn) { e.preventDefault(); return runOp(opBtn.dataset.op); }\n const actBtn = e.target.closest('[data-act]');\n if (actBtn) {\n e.preventDefault();\n if (actBtn.dataset.act === 'link') { const u = window.prompt('Link URL:', 'https://'); if (u) textCmd('createLink', u); }\n return;\n }\n const cmdBtn = e.target.closest('[data-cmd]');\n if (cmdBtn) {\n e.preventDefault();\n const cmd = cmdBtn.dataset.cmd;\n if (ALIGN_CMD[cmd]) return setCellStyle('textAlign', ALIGN_CMD[cmd]);\n return textCmd(cmd);\n }\n };\n const onToolbarChange = (e) => {\n const sel = e.target.closest('[data-sel]');\n if (!sel) return;\n const v = sel.value;\n switch (sel.dataset.sel) {\n case 'format': return applyCellHeading(v);\n case 'font': return v && setCellStyle('fontFamily', v);\n case 'size': return v && setCellStyle('fontSize', /px|em|rem|%/.test(v) ? v : v + 'px');\n case 'lineheight': return v && setCellStyle('lineHeight', v);\n case 'letterspacing': return v && setCellStyle('letterSpacing', v);\n case 'textcase': return v && applyCellCase(v);\n }\n };\n const onToolbarInput = (e) => {\n const t = e.target;\n if (t.matches('[data-color=\"fore\"]')) return setCellStyle('color', t.value);\n if (t.matches('[data-color=\"back\"]')) return setCellStyle('backgroundColor', t.value); // highlight → cell fill\n if (t.matches('[data-border]')) return setBorder(t.value, true);\n };\n\n const runOp = (op) => {\n switch (op) {\n case 'row-above': return insertRow('above');\n case 'row-below': return insertRow('below');\n case 'col-left': return insertCol('left');\n case 'col-right': return insertCol('right');\n case 'del-row': return deleteRow();\n case 'del-col': return deleteCol();\n case 'merge': return mergeCells();\n case 'split': return splitCell();\n case 'header': return toggleHeader();\n case 'border-off': return setBorder(null, false);\n case 'link': {\n const url = window.prompt('Link URL:', 'https://');\n if (url) textCmd('createLink', url);\n return;\n }\n case 'rows-equal': return rowsEqual();\n case 'cols-equal': return colsEqual();\n case 'rows-content': return rowsContent();\n case 'cols-content': return colsContent();\n case 'mv-row-up': return moveRow('up');\n case 'mv-row-down': return moveRow('down');\n case 'mv-col-left': return moveCol('left');\n case 'mv-col-right': return moveCol('right');\n case 'del-table': { const b = S.block; deactivate(); try { window.EditorManager?.clearAll?.(); } catch (e) { /* */ } b.remove(); return; }\n }\n };\n\n // Make every row the same height (= the current tallest row).\n const rowsEqual = () => {\n const rows = Array.from(S.table.tBodies[0].rows);\n let max = 0;\n rows.forEach((tr) => { max = Math.max(max, tr.getBoundingClientRect().height); });\n rows.forEach((tr) => { tr.style.height = `${Math.round(max)}px`; });\n emitChange();\n };\n\n // Make every column an equal share of the table width.\n const colsEqual = () => {\n const cols = Array.from(S.table.querySelectorAll('colgroup > col'));\n const w = `${(100 / cols.length).toFixed(4)}%`;\n cols.forEach((c) => { c.style.width = w; });\n emitChange();\n };\n\n // Let every row shrink to its content height.\n const rowsContent = () => {\n Array.from(S.table.tBodies[0].rows).forEach((tr) => { tr.style.height = ''; });\n emitChange();\n };\n\n // Fit every column to its widest cell (measured under auto layout — fixed\n // layout would just report the set width).\n const colsContent = () => {\n const cols = Array.from(S.table.querySelectorAll('colgroup > col'));\n const prevLayout = S.table.style.tableLayout;\n S.table.style.tableLayout = 'auto';\n cols.forEach((c) => { c.style.width = ''; });\n const widths = cols.map((_, ci) => {\n let m = 24;\n Array.from(S.table.tBodies[0].rows).forEach((tr) => Array.from(tr.cells).forEach((cell) => {\n if ((cell.colSpan || 1) === 1 && cellRect(S.table, cell).c === ci) m = Math.max(m, cell.getBoundingClientRect().width);\n }));\n return m;\n });\n S.table.style.tableLayout = prevLayout || '';\n const tableW = S.table.getBoundingClientRect().width || 1;\n cols.forEach((c, ci) => { c.style.width = `${(Math.ceil(widths[ci] + 8) / tableW * 100).toFixed(2)}%`; });\n emitChange();\n };\n\n // Move the active row up/down (swaps adjacent matrix rows).\n const moveRow = (dir) => apply((st) => {\n const { r } = activeCoord();\n const to = r + (dir === 'down' ? 1 : -1);\n if (to < 0 || to >= st.rows) return;\n const tmp = st.M[r]; st.M[r] = st.M[to]; st.M[to] = tmp;\n });\n\n // Move the active column left/right (swaps adjacent matrix columns + widths).\n const moveCol = (dir) => apply((st, widths) => {\n const { c } = activeCoord();\n const to = c + (dir === 'right' ? 1 : -1);\n if (to < 0 || to >= st.cols) return;\n st.M.forEach((row) => { const t = row[c]; row[c] = row[to]; row[to] = t; });\n const nw = widths.slice(); const t = nw[c]; nw[c] = nw[to]; nw[to] = t;\n return { widths: nw };\n });\n\n /* --------------------------- right-click menu ---------------------------- */\n\n let cmenu = null;\n // Built fresh each open so Merge/Split only appear when they apply.\n const menuItems = () => {\n const items = [\n { op: 'row-above', label: '+ Add row above' },\n { op: 'row-below', label: '+ Add row below' },\n { op: 'col-left', label: '+ Add column left' },\n { op: 'col-right', label: '+ Add column right' },\n { sep: true },\n ];\n if (canMerge()) items.push({ op: 'merge', label: '⊞ Merge cells' }, { sep: true });\n if (canSplit()) items.push({ op: 'split', label: '⤲ Unmerge cell' }, { sep: true });\n items.push(\n { op: 'rows-equal', label: '▤ Size rows equally' },\n { op: 'cols-equal', label: '▥ Size columns equally' },\n { op: 'rows-content', label: '↕ Size rows to content' },\n { op: 'cols-content', label: '↔ Size columns to content' },\n { sep: true },\n );\n\n // Move items appear only in the directions the active cell can actually\n // move (no \"up\" on the first row, no \"left\" on the first column, etc.).\n const pos = (() => {\n if (!S.activeCell || !S.table.contains(S.activeCell)) return null;\n const rc = cellRect(S.table, S.activeCell);\n const d = read(S.table);\n return { r: rc.r, c: rc.c, rows: d.rows, cols: d.cols };\n })();\n if (pos) {\n const mv = [];\n if (pos.r > 0) mv.push({ op: 'mv-row-up', label: '↑ Move row up' });\n if (pos.r < pos.rows - 1) mv.push({ op: 'mv-row-down', label: '↓ Move row down' });\n if (pos.c > 0) mv.push({ op: 'mv-col-left', label: '← Move column left' });\n if (pos.c < pos.cols - 1) mv.push({ op: 'mv-col-right', label: '→ Move column right' });\n if (mv.length) items.push(...mv, { sep: true });\n }\n\n items.push(\n { op: 'del-row', label: '🗑 Delete row', danger: true },\n { op: 'del-col', label: '🗑 Delete column', danger: true },\n { op: 'del-table', label: '🗑 Delete table', danger: true },\n );\n return items;\n };\n\n const clearPreview = () => {\n if (!S) return;\n S.table.querySelectorAll('.cs-cell--danger').forEach((td) => td.classList.remove('cs-cell--danger'));\n };\n\n // Mark every rendered cell whose matrix area satisfies `pred(r,c)` red.\n const markCells = (pred) => {\n if (!S) return;\n const state = read(S.table);\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n const rc = cellRect(S.table, td);\n const id = state.M[rc.r][rc.c];\n let hit = false;\n for (let r = 0; r < state.rows && !hit; r++) for (let c = 0; c < state.cols; c++) { if (state.M[r][c] === id && pred(r, c)) { hit = true; break; } }\n if (hit) td.classList.add('cs-cell--danger');\n });\n };\n\n // Hover-preview of what a delete item will remove (Canva-style red highlight).\n const previewOp = (op) => {\n clearPreview();\n if (!S) return;\n if (op === 'del-table') { S.table.querySelectorAll('td.cs-cell').forEach((td) => td.classList.add('cs-cell--danger')); return; }\n if (op === 'del-row') { const { r } = activeCoord(); markCells((rr) => rr === r); return; }\n if (op === 'del-col') { const { c } = activeCoord(); markCells((rr, cc) => cc === c); return; }\n };\n\n const hideContextMenu = () => {\n clearPreview();\n if (cmenu) { cmenu.remove(); cmenu = null; }\n };\n\n const showContextMenu = (x, y) => {\n hideContextMenu();\n const m = document.createElement('div');\n m.className = 'cs-tbl-menu';\n m.setAttribute('data-cs-chrome', '');\n menuItems().forEach((it) => {\n if (it.sep) { const s = document.createElement('div'); s.className = 'cs-tbl-menu__sep'; m.appendChild(s); return; }\n const b = document.createElement('button');\n b.type = 'button';\n b.className = 'cs-tbl-menu__item' + (it.danger ? ' cs-tbl-menu__item--danger' : '');\n b.dataset.op = it.op;\n b.textContent = it.label;\n if (/^del-/.test(it.op)) {\n b.addEventListener('mouseenter', () => previewOp(it.op));\n b.addEventListener('mouseleave', clearPreview);\n }\n m.appendChild(b);\n });\n m.addEventListener('mousedown', (e) => e.preventDefault());\n m.addEventListener('click', (e) => {\n const op = e.target.closest('[data-op]')?.dataset.op;\n if (op) { clearPreview(); runOp(op); hideContextMenu(); }\n });\n document.body.appendChild(m);\n const mw = m.offsetWidth, mh = m.offsetHeight;\n let left = x, top = y;\n if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;\n if (top + mh > window.innerHeight - 8) top = window.innerHeight - mh - 8;\n m.style.left = `${Math.max(8, left)}px`;\n m.style.top = `${Math.max(8, top)}px`;\n cmenu = m;\n };\n\n const positionToolbar = () => {\n if (!S || !S.toolbar) return;\n const tb = S.toolbar;\n // Docked mode (Page Settings → \"Inline text toolbar\" OFF): pin the table\n // bar to the top of the canvas, full-width — the same place the rich-text\n // bar docks — so a single bar shows instead of the placeholder + a floating\n // one. CSS owns the placement; clear any leftover inline coords.\n const docked = (typeof window.isRichToolbarDocked === 'function') ? window.isRichToolbarDocked() : false;\n tb.classList.toggle('cre-toolbar--docked', docked);\n if (docked) {\n // Follow the host scroll (the iframe grows + host scrolls, so a fixed bar\n // would scroll off-screen). Same tracker the text bar uses.\n tb.style.left = '';\n window.CustomRichEditor?.trackDockedBar?.(tb);\n return;\n }\n window.CustomRichEditor?.untrackDockedBar?.(tb);\n\n const rect = S.block.getBoundingClientRect();\n const tw = tb.offsetWidth, th = tb.offsetHeight;\n let top = rect.top - th - 8;\n if (top < 8) top = rect.bottom + 8;\n let left = rect.left;\n if (left + tw > window.innerWidth - 8) left = window.innerWidth - tw - 8;\n if (left < 8) left = 8;\n tb.style.top = `${top}px`;\n tb.style.left = `${left}px`;\n };\n\n /* ------------------------------ cell wiring ------------------------------ */\n\n const wireCells = () => {\n if (!S) return;\n S.table.querySelectorAll('td.cs-cell').forEach((td) => {\n td.setAttribute('contenteditable', 'true');\n });\n };\n\n const unwireCells = (table) => {\n table.querySelectorAll('td.cs-cell').forEach((td) => {\n td.removeAttribute('contenteditable');\n td.classList.remove('cs-cell--selected');\n });\n };\n\n const emitChange = () => {\n try { S.block.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) { /* */ }\n };\n\n // Put the caret at the end of a cell (used by Tab navigation).\n const focusCell = (td) => {\n if (!td) return;\n td.focus();\n const range = document.createRange();\n range.selectNodeContents(td); range.collapse(false);\n const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);\n clearSelection();\n S.activeCell = td; S.anchorCell = td;\n };\n\n // Tab / Shift+Tab move between cells (Tab on the last cell adds a row).\n const onTableKey = (e) => {\n if (e.key !== 'Tab') return;\n const td = e.target.closest && e.target.closest('td.cs-cell');\n if (!td) return;\n e.preventDefault();\n const all = Array.from(S.table.querySelectorAll('td.cs-cell'));\n const i = all.indexOf(td);\n if (e.shiftKey) { if (i > 0) focusCell(all[i - 1]); return; }\n if (i < all.length - 1) { focusCell(all[i + 1]); return; }\n // Last cell → grow the table, then land in the new row's first cell.\n S.activeCell = td;\n insertRow('below');\n const d = read(S.table);\n focusCell(tdAt(d.rows - 1, 0));\n };\n\n // Paste as PLAIN TEXT so cells don't inherit messy external markup.\n const onPaste = (e) => {\n const td = e.target.closest && e.target.closest('td.cs-cell');\n if (!td) return;\n e.preventDefault();\n const text = ((e.clipboardData || window.clipboardData)?.getData('text/plain') || '').replace(/\\r/g, '');\n try { document.execCommand('insertText', false, text); } catch (err) { /* */ }\n };\n\n /* ------------------------------ activate --------------------------------- */\n\n const onTablePointerDown = (e) => {\n if (!S) return;\n // Right-click is handled by the contextmenu listener — DON'T touch the\n // selection here (otherwise the multi-cell pick is wiped before the menu).\n if (e.button === 2) return;\n hideContextMenu();\n const td = e.target.closest('td.cs-cell');\n if (!td) return;\n // Column / row resize when grabbing a cell edge.\n const edge = edgeAt(td, e);\n if (edge) { startResize(edge, td, e); return; }\n\n // Ctrl/Cmd-click → toggle this cell in the multi-selection (no caret).\n if (e.metaKey || e.ctrlKey) {\n e.preventDefault();\n const sel = window.getSelection(); if (sel) sel.removeAllRanges();\n if (S.selected.has(td)) { S.selected.delete(td); td.classList.remove('cs-cell--selected'); }\n else { S.selected.add(td); td.classList.add('cs-cell--selected'); }\n S.activeCell = td; S.anchorCell = td;\n updateOverlay();\n return;\n }\n // Shift-click → rectangular range from the anchor cell.\n if (e.shiftKey && S.anchorCell) {\n e.preventDefault();\n const sel = window.getSelection(); if (sel) sel.removeAllRanges();\n selectRange(S.anchorCell, td);\n S.activeCell = td;\n return;\n }\n // Plain press → caret for typing; clear any multi-selection; arm drag-select.\n clearSelection();\n S.activeCell = td;\n S.anchorCell = td;\n S.dragStart = td;\n };\n\n const onTablePointerMove = (e) => {\n if (!S || !S.dragStart) return;\n const td = e.target.closest('td.cs-cell');\n if (!td || td === S.dragStart) {\n if (td === S.dragStart && S.selected.size) { /* keep */ }\n return;\n }\n // Dragged onto a different cell → range-select (and stop caret text-select).\n e.preventDefault();\n const sel = window.getSelection(); if (sel) sel.removeAllRanges();\n selectRange(S.dragStart, td);\n };\n\n const onTablePointerUp = () => { if (S) S.dragStart = null; };\n\n // Detect if the pointer is near a cell's right (col) or bottom (row) edge.\n const edgeAt = (td, e) => {\n const r = td.getBoundingClientRect();\n if (Math.abs(e.clientX - r.right) <= 5) return 'col';\n if (Math.abs(e.clientY - r.bottom) <= 5) return 'row';\n return null;\n };\n\n const startResize = (kind, td, e) => {\n e.preventDefault();\n const rc = cellRect(S.table, td);\n const startX = e.clientX, startY = e.clientY;\n if (kind === 'col') {\n const cols = Array.from(S.table.querySelectorAll('colgroup > col'));\n // The boundary being dragged sits at the RIGHT of the cell's last spanned\n // column. Widen that column and shrink the NEXT one by the same amount so\n // the table's total width stays fixed (Canva-style boundary drag).\n const i = rc.c + ((td.colSpan || 1) - 1);\n const tableW = S.table.getBoundingClientRect().width || 1;\n const startWi = cols[i] ? cols[i].getBoundingClientRect().width : 80;\n const hasNext = i + 1 < cols.length;\n const startWn = hasNext ? cols[i + 1].getBoundingClientRect().width : 0;\n const MINW = 24;\n const move = (ev) => {\n let d = ev.clientX - startX;\n if (hasNext) {\n d = Math.max(-(startWi - MINW), Math.min(d, startWn - MINW));\n cols[i].style.width = `${((startWi + d) / tableW * 100).toFixed(3)}%`;\n cols[i + 1].style.width = `${((startWn - d) / tableW * 100).toFixed(3)}%`;\n } else if (cols[i]) {\n cols[i].style.width = `${(Math.max(MINW, startWi + d) / tableW * 100).toFixed(3)}%`;\n }\n };\n const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); emitChange(); };\n document.addEventListener('pointermove', move);\n document.addEventListener('pointerup', up);\n } else {\n const tr = td.parentElement;\n const startH = tr.getBoundingClientRect().height;\n const move = (ev) => { tr.style.height = `${Math.max(18, startH + (ev.clientY - startY))}px`; };\n const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); emitChange(); };\n document.addEventListener('pointermove', move);\n document.addEventListener('pointerup', up);\n }\n };\n\n const onCursorHint = (e) => {\n const td = e.target.closest && e.target.closest('td.cs-cell');\n if (!td) return;\n const edge = edgeAt(td, e);\n td.style.cursor = edge === 'col' ? 'col-resize' : edge === 'row' ? 'row-resize' : 'text';\n };\n\n // Engine switch: in legacy Froala mode the custom table engine stays off\n // (the static Table block is a new-mode-only feature). See canvas-config.js.\n const froalaMode = () => (typeof window.isFroalaEditor === 'function') && window.isFroalaEditor();\n\n const activate = (block) => {\n if (froalaMode()) return;\n if (S && S.block === block) return;\n if (S) deactivate();\n const table = block.querySelector('table.cs-table');\n if (!table) return;\n S = { block, table, selected: new Set(), activeCell: null, anchorCell: null, dragStart: null };\n S.toolbar = buildToolbar();\n wireCells();\n\n S._pd = onTablePointerDown; S._pm = onTablePointerMove; S._pu = onTablePointerUp; S._mm = onCursorHint;\n table.addEventListener('pointerdown', S._pd);\n table.addEventListener('pointermove', S._pm);\n document.addEventListener('pointerup', S._pu);\n table.addEventListener('mousemove', S._mm);\n table.addEventListener('keydown', onTableKey);\n table.addEventListener('paste', onPaste);\n\n // Deselect when the user presses outside the table (and not on our toolbar\n // / context menu), or hits Escape. Belt-and-suspenders over inline-editor.\n S._down = (e) => {\n if (!S) return;\n const t = e.target;\n if (t.closest && (t.closest('.cre-toolbar') || t.closest('.cs-tbl-menu'))) return;\n if (t.closest && t.closest('.cs_block_s') === block) return;\n hideContextMenu();\n try { window.EditorManager?.clearAll?.(); } catch (err) { /* */ }\n deactivate();\n };\n S._key = (e) => {\n if (e.key !== 'Escape') return;\n hideContextMenu();\n // First Escape clears a multi-cell selection; second exits the table.\n if (S.selected.size) { clearSelection(); return; }\n try { window.EditorManager?.clearAll?.(); } catch (err) { /* */ }\n deactivate();\n };\n document.addEventListener('pointerdown', S._down, true);\n document.addEventListener('keydown', S._key, true);\n\n S._reflow = () => { positionToolbar(); updateOverlay(); };\n window.addEventListener('scroll', S._reflow, true);\n window.addEventListener('resize', S._reflow);\n\n // Single bar at the top in docked mode: hide the rich-text placeholder while\n // our bar is up, and re-place ours if docked mode is toggled mid-edit.\n window.CustomRichEditor?.setExternalDockedActive?.(true);\n S._mode = () => positionToolbar();\n document.addEventListener('canvas:rich-toolbar-mode', S._mode);\n\n positionToolbar();\n };\n\n const deactivate = () => {\n if (!S) return;\n const { table, toolbar } = S;\n table.removeEventListener('pointerdown', S._pd);\n table.removeEventListener('pointermove', S._pm);\n document.removeEventListener('pointerup', S._pu);\n table.removeEventListener('mousemove', S._mm);\n table.removeEventListener('keydown', onTableKey);\n table.removeEventListener('paste', onPaste);\n window.removeEventListener('scroll', S._reflow, true);\n window.removeEventListener('resize', S._reflow);\n if (S._mode) document.removeEventListener('canvas:rich-toolbar-mode', S._mode);\n if (toolbar) window.CustomRichEditor?.untrackDockedBar?.(toolbar);\n window.CustomRichEditor?.setExternalDockedActive?.(false);\n document.removeEventListener('pointerdown', S._down, true);\n document.removeEventListener('keydown', S._key, true);\n hideContextMenu();\n unwireCells(table);\n if (toolbar) toolbar.remove();\n if (S.overlay) S.overlay.remove();\n S = null;\n };\n\n /* --------------------------------- init ---------------------------------- */\n\n const init = () => {\n const surface = document.querySelector('.custom-form-design') || document.body;\n const obs = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName !== 'class') continue;\n const el = m.target;\n if (!el.classList || el.dataset.blockType !== 'table') continue;\n if (el.classList.contains('cs-editing')) activate(el);\n else if (S && S.block === el) deactivate();\n }\n });\n obs.observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n\n // Right-click anywhere on a table block → our context menu (table only).\n document.addEventListener('contextmenu', (e) => {\n if (froalaMode()) return;\n const block = e.target.closest && e.target.closest('.cs_block_s[data-block-type=\"table\"]');\n if (!block) { hideContextMenu(); return; }\n e.preventDefault();\n if (!S || S.block !== block) {\n try { window.EditorManager?.select?.(block); } catch (err) { /* */ }\n activate(block);\n }\n const td = e.target.closest('td.cs-cell');\n if (td) S.activeCell = td;\n showContextMenu(e.clientX, e.clientY);\n });\n };\n\n Object.assign(window.TableBlock, { createBlock, activate, deactivate, normalizeCells });\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/synclist.js\">\n/**\n * @fileoverview \"List\" block — synchronised parallel columns of free-floating\n * blocks.\n *\n * Three selectable tiers:\n * List (.cs-synclist-block) — the outer block.\n * Container (.cs-synclist__col) — each column; selectable + resizable, and a\n * FREE canvas (like a Flexible block): the\n * blocks inside are absolutely positioned and\n * drag/resize freely, bounded to the column.\n * Block (.cs-synclist__col > .cs_block_s) — the actual content.\n *\n * Cross-column sync: every block belongs to a GROUP (dataset.slGroup) shared by\n * the matching block in each column. Add / delete / duplicate act on the whole\n * group (one block per column); move / resize on one block are mirrored live to\n * its group siblings (so the columns stay identical), while each block's text /\n * image / table CONTENT is edited individually.\n *\n * Only a fixed set of block types may be added (see ALLOWED). Add and column\n * controls live on a floating toolbar (in <body>, like the Table block).\n *\n * Exposes: window.SyncList.createBlock(cols), .handlePaste(anchor, newBlock)\n */\n(function () {\n window.SyncList = window.SyncList || {};\n\n const hash = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2));\n const FC = () => window.FlowCanvas || {};\n\n // Auto-height: the shared column height grows so the tallest block in any\n // column always clears the bottom by BOTTOM_GAP. DEFAULT_COL_H matches the\n // CSS `--col-item-h` fallback and acts as the minimum height.\n const BOTTOM_GAP = 10;\n const DEFAULT_COL_H = 240;\n\n const ALLOWED = [\n { type: 'heading', label: 'Heading' },\n { type: 'body-text', label: 'Body Text' },\n { type: 'image', label: 'Image' },\n { type: 'video', label: 'Video' },\n { type: 'table', label: 'Table' },\n { type: 'button', label: 'Button' },\n { type: 'label-tag', label: 'Label / Tag' },\n { type: 'spacer', label: 'Spacer' },\n { type: 'divider', label: 'Divider' },\n ];\n\n /* ------------------------------ DOM helpers ------------------------------ */\n\n const gridOf = (block) => block.querySelector(':scope > .cs-synclist');\n const colEls = (grid) => Array.from(grid.querySelectorAll(':scope > .cs-synclist__col'));\n const contentBlocks = (root) => Array.from(root.querySelectorAll('.cs-synclist__col > .cs_block_s'));\n\n // A column is itself a `.cs_block_s` so the inline-editor selects it as its\n // own \"Container\", and a position:relative free canvas for its blocks.\n const makeCol = () => {\n const col = document.createElement('div');\n col.className = 'cs_block_s cs-synclist__col';\n col.setAttribute('data', 'Container');\n col.setAttribute('custom-name', 'Container');\n col.dataset.blockType = 'sync-list-col';\n col.id = `block_${hash()}`;\n return col;\n };\n\n // A free-floating content block: absolutely positioned + csInSection so the\n // inline-editor's free drag/resize kicks in, tagged with its sync group.\n const makeBlock = (type, group, left, top) => {\n const inner = FC().createBlock?.(type);\n if (!inner) return null;\n FC().normalizeForFlow?.(inner); // strip the factory's absolute placement\n inner.dataset.csInSection = '1';\n inner.dataset.slGroup = group;\n inner.style.position = 'absolute';\n inner.style.left = `${left}px`;\n inner.style.top = `${top}px`;\n return inner;\n };\n\n // No divider element — columns sit side by side (separated by CSS) and are\n // resized with the Container's own handles. This only strips any stale\n // dividers left over from older documents.\n const dropDividers = (grid) => {\n grid.querySelectorAll(':scope > .cs-synclist__col-divider').forEach((d) => d.remove());\n };\n\n // Strip any stale inline sizing off the columns; the grid template drives\n // their size uniformly (the grid keeps its --col-item-w/--col-item-h vars).\n const resetColWidths = (grid) => colEls(grid).forEach((c) => {\n c.style.width = ''; c.style.height = ''; c.style.maxWidth = ''; c.style.flex = '';\n });\n\n const createBlock = (cols = 3) => {\n const block = document.createElement('div');\n block.className = 'cs_block_s cs-synclist-block';\n block.setAttribute('data', 'List');\n block.setAttribute('custom-name', 'List');\n block.dataset.blockType = 'sync-list';\n block.id = `block_${hash()}`;\n\n const grid = document.createElement('div');\n grid.className = 'cs-synclist';\n grid.id = `dynamic_${hash()}`;\n\n const group = hash();\n for (let c = 0; c < cols; c++) {\n const col = makeCol();\n const blk = makeBlock('heading', group, 0, 0);\n if (blk) col.appendChild(blk);\n grid.appendChild(col);\n }\n dropDividers(grid);\n block.appendChild(grid);\n return block;\n };\n\n /* --------------------------- clone / id helpers -------------------------- */\n\n const regenIds = (root) => {\n const bump = (el) => {\n if (!el.id) return;\n const us = el.id.lastIndexOf('_');\n el.id = `${us > -1 ? el.id.slice(0, us) : el.id}_${hash()}`;\n };\n bump(root);\n root.querySelectorAll('[id]').forEach(bump);\n };\n\n // Strip chrome / selection + re-id a cloned block (keeps dataset.slGroup,\n // csInSection and inline position so the copy stays a valid free block).\n const cleanInner = (inner) => {\n inner.querySelectorAll('[data-cs-chrome], .cs-block-grip, .cs-block-badge, .cs-resize-handle').forEach((e) => e.remove());\n inner.classList.remove('cs-selected', 'cs-editing');\n inner.querySelectorAll('.cs-selected, .cs-editing').forEach((e) => e.classList.remove('cs-selected', 'cs-editing'));\n regenIds(inner);\n };\n\n /* ----------------------------- list contexts ----------------------------- */\n\n // Context for a CONTENT block (not the column / list itself).\n const ctxFromBlock = (block) => {\n if (!block || !block.classList || !block.classList.contains('cs_block_s')) return null;\n if (block.classList.contains('cs-synclist__col') || block.classList.contains('cs-synclist-block')) return null;\n const col = block.closest('.cs-synclist__col');\n if (!col) return null;\n const list = col.closest('.cs-synclist-block');\n const grid = list && gridOf(list);\n if (!grid) return null;\n return { block, list, grid, col, colIndex: colEls(grid).indexOf(col), group: block.dataset.slGroup };\n };\n const isInList = (block) => !!ctxFromBlock(block);\n\n // Context when the block IS a column (the \"Container\" tier).\n const colCtx = (block) => {\n if (!block?.classList?.contains('cs-synclist__col')) return null;\n const list = block.closest('.cs-synclist-block');\n const grid = list && gridOf(list);\n if (!grid) return null;\n return { list, grid, index: colEls(grid).indexOf(block) };\n };\n\n const groupMembers = (list, group) => contentBlocks(list).filter((b) => b.dataset.slGroup === group);\n\n /* --------------------------- cross-column sync --------------------------- */\n\n // Minimum column width = the rightmost extent (left + offsetWidth) across all\n // absolutely-positioned child blocks. Prevents the column from being dragged\n // narrower than its content allows.\n const minColContentWidth = (col) => {\n let max = 0;\n Array.from(col.querySelectorAll(':scope > .cs_block_s')).forEach((child) => {\n const right = (parseFloat(child.style.left) || 0) + child.offsetWidth;\n if (right > max) max = right;\n });\n return Math.ceil(max);\n };\n\n // Flash the child block(s) whose right edge equals the minimum column width so\n // the user can see WHY the container cannot shrink further.\n const BLINK_TOL = 3; // px tolerance for \"rightmost\" match\n const blinkBlockers = (col, minW) => {\n Array.from(col.querySelectorAll(':scope > .cs_block_s')).forEach((child) => {\n const right = (parseFloat(child.style.left) || 0) + child.offsetWidth;\n if (Math.abs(right - minW) > BLINK_TOL) return;\n child.classList.remove('cs-synclist__blink');\n void child.offsetWidth; // force reflow to restart the animation\n child.classList.add('cs-synclist__blink');\n child.addEventListener('animationend', () => child.classList.remove('cs-synclist__blink'), { once: true });\n });\n };\n\n // Keep a block inside its column: never wider/taller than the column, never\n // positioned past its edges. This stops a block spilling into the next column\n // and caps resize at the container's width/height. Idempotent (only writes\n // when a value changes) so the style observer that calls it can't loop.\n const clampToCol = (block) => {\n const col = block.closest('.cs-synclist__col');\n if (!col) return;\n const cw = col.clientWidth;\n if (!cw) return;\n const set = (p, v) => { if (block.style[p] !== v) block.style[p] = v; };\n const w = block.offsetWidth;\n // Width is intentionally NOT clamped here: the column is prevented from\n // going narrower than its children by minColContentWidth, so child blocks\n // should never need to be shrunk. Clamping width here caused child blocks\n // to visually shrink whenever the column resize interaction ran clampToCol.\n let left = parseFloat(block.style.left) || 0;\n let top = parseFloat(block.style.top) || 0;\n if (left < 0) { left = 0; set('left', '0px'); }\n if (top < 0) { top = 0; set('top', '0px'); }\n if (left + w > cw) set('left', `${Math.max(0, cw - w)}px`);\n };\n\n // Grow the List's shared column height so the lowest block bottom across ALL\n // columns clears the bottom edge by BOTTOM_GAP. Columns share --col-item-h, so\n // a tall block in one column lifts every column to the same height (and the\n // 20px gap is preserved below the tallest block). A manual height-resize is\n // remembered on the grid (dataset.slFloorH) and used as the minimum so the\n // user can still make a List taller than its content. Only writes when the\n // value changes, so the ResizeObserver that calls it can't loop.\n const autoSizeList = (list) => {\n if (!list) return;\n const grid = gridOf(list);\n if (!grid) return;\n let maxBottom = 0;\n contentBlocks(list).forEach((b) => {\n const bottom = b.offsetTop + b.offsetHeight;\n if (bottom > maxBottom) maxBottom = bottom;\n });\n // Minimum height = the user's manual resize if there is one, else the\n // default. Content can always push beyond it, but never gets clipped.\n const manual = parseFloat(grid.dataset.slFloorH);\n const floor = Number.isFinite(manual) ? manual : DEFAULT_COL_H;\n const needed = Math.max(floor, Math.ceil(maxBottom + BOTTOM_GAP));\n const cur = parseFloat(grid.style.getPropertyValue('--col-item-h')) || DEFAULT_COL_H;\n if (needed !== cur) grid.style.setProperty('--col-item-h', `${needed}px`);\n };\n\n // Copy a block's geometry (position + size) onto its group siblings in the\n // other columns. Only writes when a value actually differs, so the style\n // MutationObserver that calls this can't loop.\n const mirrorGeometry = (block) => {\n const group = block.dataset.slGroup;\n const list = block.closest('.cs-synclist-block');\n if (!group || !list) return;\n // minHeight must be mirrored alongside height: inline-editor pins a manual\n // resize with minHeight so the block doesn't collapse when height:auto is set\n // on edit-entry (startEditor). Without mirroring minHeight, the sibling block\n // (which received only the height value) collapses back to content height the\n // moment the user clicks it to edit — the \"previous state comes back\" bug.\n const vals = { left: block.style.left, top: block.style.top, width: block.style.width, height: block.style.height, minHeight: block.style.minHeight };\n groupMembers(list, group).forEach((sib) => {\n if (sib === block) return;\n Object.keys(vals).forEach((p) => { if (sib.style[p] !== vals[p]) sib.style[p] = vals[p]; });\n });\n };\n\n const afterChange = () => { try { window.generate?.(); } catch (e) { /* */ } };\n\n // Re-assert selection on the List after a structural change (the inline-editor\n // observer tears down selection when new blocks appear).\n const finishStructural = (list) => {\n afterChange();\n if (!list || !list.isConnected) return;\n requestAnimationFrame(() => {\n try { window.EditorManager?.select?.(list); } catch (e) { /* */ }\n activate(list);\n autoSizeList(list);\n });\n };\n\n /* ----------------------------- group operations -------------------------- */\n\n // New block in every column (a new synced group) at a given position.\n const addBlockAt = (list, type, left, top) => {\n const group = hash();\n colEls(gridOf(list)).forEach((col) => {\n const blk = makeBlock(type, group, left, top);\n if (blk) col.appendChild(blk);\n });\n finishStructural(list);\n };\n\n /* ----------------------- reusable component drops ------------------------ */\n // A List holds only single content blocks (free-positioned, synced across\n // columns). A saved component that is itself a group/section/list/flexible\n // container must NOT be injected into a column — reject those and let the\n // canvas drop handler place them in page flow instead.\n const isSimpleComponentHtml = (html) => {\n const tmp = document.createElement('div');\n tmp.innerHTML = html || '';\n const root = tmp.firstElementChild;\n return !!root && !root.matches(\n '.cs-synclist-block, .cs-synclist__col, .cs-group-block, .cs-flexible-block, [data-block-type=\"section-container\"]'\n );\n };\n\n // Build a component instance for one column, with the same synced free-block\n // treatment as makeBlock(). buildComponentBlock regenerates ids on each call,\n // so calling it per column keeps every column's copy independent.\n const makeComponentBlock = (html, group, left, top) => {\n const inner = FC().buildComponentBlock?.(html);\n if (!inner) return null;\n FC().normalizeForFlow?.(inner); // strip any baked-in absolute placement\n inner.dataset.csInSection = '1';\n inner.dataset.slGroup = group;\n inner.style.position = 'absolute';\n inner.style.left = `${left}px`;\n inner.style.top = `${top}px`;\n return inner;\n };\n\n // A reusable component as a new synced group, cloned across every column.\n const addComponentAt = (list, html, left, top) => {\n const group = hash();\n colEls(gridOf(list)).forEach((col) => {\n const blk = makeComponentBlock(html, group, left, top);\n if (blk) col.appendChild(blk);\n });\n finishStructural(list);\n };\n\n // Toolbar \"+ Add block\" — stagger new groups below the last.\n const addRow = (list, type) => {\n const groupCount = new Set(contentBlocks(list).map((b) => b.dataset.slGroup)).size;\n addBlockAt(list, type, 8, 8 + groupCount * 64);\n };\n\n const deleteGroup = (block) => {\n const ctx = ctxFromBlock(block);\n if (!ctx) return;\n groupMembers(ctx.list, ctx.group).forEach((b) => b.remove());\n finishStructural(ctx.list);\n };\n\n const duplicateGroup = (block) => {\n const ctx = ctxFromBlock(block);\n if (!ctx) return null;\n const { list, grid, group } = ctx;\n const cols = colEls(grid);\n const newGroup = hash();\n const byCol = new Map();\n groupMembers(list, group).forEach((b) => byCol.set(b.closest('.cs-synclist__col'), b));\n cols.forEach((col) => {\n const src = byCol.get(col);\n if (!src) return;\n const clone = src.cloneNode(true);\n cleanInner(clone);\n clone.dataset.csInSection = '1';\n clone.dataset.slGroup = newGroup;\n clone.style.position = 'absolute';\n clone.style.left = `${(parseFloat(src.style.left) || 0) + 20}px`;\n clone.style.top = `${(parseFloat(src.style.top) || 0) + 20}px`;\n col.appendChild(clone);\n });\n finishStructural(list);\n return null;\n };\n\n // Badge ▲/▼ on a content block nudges it up/down; the style observer mirrors.\n const nudgeGroup = (block, dir) => {\n const ctx = ctxFromBlock(block);\n if (!ctx) return false;\n const top = Math.max(0, (parseFloat(block.style.top) || 0) + (dir === 'up' ? -20 : 20));\n block.style.top = `${top}px`;\n afterChange();\n return true;\n };\n\n // Paste → a new group offset from the anchor (into the anchor's column +\n // clones in the others).\n const handlePaste = (anchor, newBlock) => {\n const ctx = ctxFromBlock(anchor);\n if (!ctx || !newBlock) return null;\n const { list, grid, colIndex } = ctx;\n const cols = colEls(grid);\n const group = hash();\n const left = (parseFloat(anchor.style.left) || 0) + 20;\n const top = (parseFloat(anchor.style.top) || 0) + 20;\n let placed = null;\n cols.forEach((col, ci) => {\n const blk = (ci === colIndex) ? newBlock : newBlock.cloneNode(true);\n if (ci !== colIndex) cleanInner(blk);\n blk.dataset.csInSection = '1';\n blk.dataset.slGroup = group;\n blk.style.position = 'absolute';\n blk.style.left = `${left}px`;\n blk.style.top = `${top}px`;\n col.appendChild(blk);\n if (ci === colIndex) placed = blk;\n });\n finishStructural(list);\n return placed;\n };\n\n // Paste directly into a selected Container (the col tier, not a child block).\n // Creates the block in every column as a new synced group, same as handlePaste\n // but takes the col itself as the target instead of requiring a child anchor.\n const pasteIntoCol = (targetCol, newBlock) => {\n if (!targetCol?.classList?.contains('cs-synclist__col') || !newBlock) return null;\n const list = targetCol.closest('.cs-synclist-block');\n const grid = list && gridOf(list);\n if (!grid) return null;\n const cols = colEls(grid);\n const colIndex = cols.indexOf(targetCol);\n if (colIndex < 0) return null;\n const group = hash();\n let placed = null;\n cols.forEach((col, ci) => {\n const blk = (ci === colIndex) ? newBlock : newBlock.cloneNode(true);\n if (ci !== colIndex) cleanInner(blk);\n blk.dataset.csInSection = '1';\n blk.dataset.slGroup = group;\n blk.style.position = 'absolute';\n blk.style.left = '8px';\n blk.style.top = '8px';\n col.appendChild(blk);\n if (ci === colIndex) placed = blk;\n });\n finishStructural(list);\n return placed;\n };\n\n /* ---------------------------- column operations -------------------------- */\n\n const addColumn = (block) => {\n const grid = gridOf(block);\n const cols = colEls(grid);\n const src = cols[cols.length - 1];\n const newCol = makeCol();\n // Clone the last column's blocks, KEEPING each block's slGroup so the new\n // column joins the existing groups (regenIds only re-ids elements).\n (src ? Array.from(src.querySelectorAll(':scope > .cs_block_s')) : []).forEach((b) => {\n const clone = b.cloneNode(true);\n cleanInner(clone);\n clone.dataset.csInSection = '1';\n newCol.appendChild(clone);\n });\n if (!newCol.querySelector(':scope > .cs_block_s')) {\n const blk = makeBlock('heading', hash(), 8, 8);\n if (blk) newCol.appendChild(blk);\n }\n grid.appendChild(newCol);\n dropDividers(grid);\n resetColWidths(grid);\n finishStructural(block);\n };\n\n const deleteColumn = (block, colIndex) => {\n const grid = gridOf(block);\n const cols = colEls(grid);\n if (cols.length <= 1) return;\n const target = (colIndex == null || colIndex < 0 || colIndex >= cols.length) ? cols.length - 1 : colIndex;\n cols[target].remove();\n dropDividers(grid);\n resetColWidths(grid);\n finishStructural(block);\n };\n\n const cleanColumnClone = (col) => {\n col.classList.remove('cs-selected', 'cs-editing');\n // Clear inline sizing so the grid's uniform var rule (or default flex) drives it.\n col.style.width = ''; col.style.height = ''; col.style.maxWidth = ''; col.style.flex = '';\n col.querySelectorAll('[data-cs-chrome], .cs-block-grip, .cs-block-badge, .cs-resize-handle').forEach((e) => e.remove());\n col.querySelectorAll('.cs-selected, .cs-editing').forEach((e) => e.classList.remove('cs-selected', 'cs-editing'));\n regenIds(col);\n };\n\n // Copy/paste of a whole Container: append it as a NEW column. Its child blocks\n // keep their slGroup, so they join the existing sync groups (drag / resize /\n // delete then apply across this new column too).\n const handleColumnPaste = (anchorCol, newCol) => {\n const list = anchorCol?.closest?.('.cs-synclist-block');\n const grid = list && gridOf(list);\n if (!grid || !newCol) return null;\n newCol.classList.add('cs_block_s', 'cs-synclist__col');\n newCol.setAttribute('data', 'Container');\n newCol.setAttribute('custom-name', 'Container');\n newCol.dataset.blockType = 'sync-list-col';\n cleanColumnClone(newCol);\n // Keep children absolute + csInSection (and their slGroup) so they sync.\n Array.from(newCol.querySelectorAll(':scope > .cs_block_s')).forEach((b) => {\n b.dataset.csInSection = '1';\n if (!b.style.position) b.style.position = 'absolute';\n });\n grid.appendChild(newCol);\n resetColWidths(grid);\n finishStructural(list);\n return newCol;\n };\n\n const duplicateColumn = (block, index) => {\n const grid = gridOf(block);\n const src = colEls(grid)[index];\n if (!src) return null;\n const clone = src.cloneNode(true);\n cleanColumnClone(clone);\n src.after(clone);\n dropDividers(grid);\n resetColWidths(grid);\n finishStructural(block);\n return null;\n };\n\n const moveColumn = (block, index, dir) => {\n const grid = gridOf(block);\n const cols = colEls(grid);\n const to = dir === 'up' ? index - 1 : index + 1;\n if (to < 0 || to >= cols.length) return false;\n if (dir === 'up') cols[to].before(cols[index]); else cols[to].after(cols[index]);\n dropDividers(grid);\n finishStructural(block);\n return true;\n };\n\n /* ------------------------------ floating UI ------------------------------ */\n\n let active = null;\n let menuEl = null;\n\n const closeMenu = () => { if (menuEl) { menuEl.remove(); menuEl = null; } };\n\n const openAddMenu = (btn, list) => {\n closeMenu();\n const m = document.createElement('div');\n m.className = 'cs-tbl-menu cs-synclist-menu';\n m.setAttribute('data-cs-chrome', '');\n ALLOWED.forEach((a) => {\n const b = document.createElement('button');\n b.type = 'button';\n b.className = 'cs-tbl-menu__item';\n b.dataset.type = a.type;\n b.textContent = a.label;\n m.appendChild(b);\n });\n m.addEventListener('mousedown', (e) => e.preventDefault());\n m.addEventListener('click', (e) => {\n const t = e.target.closest('[data-type]')?.dataset.type;\n if (!t) return;\n addRow(list, t);\n closeMenu();\n });\n document.body.appendChild(m);\n const r = btn.getBoundingClientRect();\n let left = r.left;\n if (left + m.offsetWidth > window.innerWidth - 8) left = window.innerWidth - m.offsetWidth - 8;\n m.style.left = `${Math.max(8, left)}px`;\n m.style.top = `${r.bottom + 4}px`;\n menuEl = m;\n };\n\n const buildToolbar = (list) => {\n const tb = document.createElement('div');\n tb.className = 'cs-tbl-toolbar cs-synclist-toolbar';\n tb.setAttribute('data-cs-chrome', '');\n tb.innerHTML = `\n <div class=\"cs-tbl-group\">\n <button data-sl=\"add-row\" title=\"Add a block to every column\">+ Add block</button>\n </div>\n <div class=\"cs-tbl-group\">\n <button data-sl=\"add-col\" title=\"Add a column\">+ Column</button>\n <button data-sl=\"del-col\" title=\"Remove last column\">- Column</button>\n </div>`;\n tb.addEventListener('mousedown', (e) => { if (!e.target.closest('input')) e.preventDefault(); });\n tb.addEventListener('click', (e) => {\n const op = e.target.closest('[data-sl]')?.dataset.sl;\n if (!op) return;\n e.preventDefault();\n if (op === 'add-row') return openAddMenu(e.target.closest('button'), list);\n if (op === 'add-col') return addColumn(list);\n if (op === 'del-col') return deleteColumn(list, null);\n });\n document.body.appendChild(tb);\n return tb;\n };\n\n const positionToolbar = () => {\n if (!active) return;\n const r = active.block.getBoundingClientRect();\n const tb = active.toolbar;\n let top = r.top - tb.offsetHeight - 8;\n if (top < 8) top = r.bottom + 8;\n // Anchor the toolbar's RIGHT edge to the list's right edge (width-independent,\n // so it stays put even as the toolbar's own width settles / fonts load).\n // Use clientWidth (excludes the scrollbar) so `right` lines up with the\n // list's right edge from getBoundingClientRect (which also excludes it).\n const vw = document.documentElement.clientWidth || window.innerWidth;\n tb.style.left = 'auto';\n tb.style.right = `${Math.max(8, Math.round(vw - r.right))}px`;\n tb.style.top = `${Math.max(8, top)}px`;\n };\n\n const activate = (list) => {\n if (active && active.block === list) { positionToolbar(); return; }\n if (active) deactivate();\n active = { block: list, toolbar: buildToolbar(list) };\n active._reflow = () => positionToolbar();\n window.addEventListener('scroll', active._reflow, true);\n window.addEventListener('resize', active._reflow);\n positionToolbar();\n // Re-read once layout has settled (the list's rect can shift right after\n // it's created/selected); right-anchoring makes this stable.\n requestAnimationFrame(() => positionToolbar());\n };\n\n const deactivate = () => {\n if (!active) return;\n closeMenu();\n window.removeEventListener('scroll', active._reflow, true);\n window.removeEventListener('resize', active._reflow);\n active.toolbar.remove();\n active = null;\n };\n\n const activeListFromSelection = () => {\n const sel = document.querySelector('.cs_block_s.cs-selected, .cs_block_s.cs-editing');\n if (!sel) return null;\n return sel.classList.contains('cs-synclist-block') ? sel : sel.closest('.cs-synclist-block');\n };\n\n let syncQueued = false;\n const syncActive = () => {\n if (syncQueued) return;\n syncQueued = true;\n requestAnimationFrame(() => {\n syncQueued = false;\n const list = activeListFromSelection();\n if (list) activate(list); else deactivate();\n });\n };\n\n /* -------------------------------- wiring --------------------------------- */\n\n const wrapOverrides = () => {\n const fc = window.FlowCanvas;\n if (!fc || fc._synclistWrapped) return;\n fc._synclistWrapped = true;\n\n const origDelete = fc.deleteBlock;\n window.SyncList._origDelete = origDelete;\n fc.deleteBlock = function (block) {\n const cc = colCtx(block);\n if (cc) return deleteColumn(cc.list, cc.index);\n if (isInList(block)) return deleteGroup(block);\n return origDelete ? origDelete.call(this, block) : undefined;\n };\n\n const origMove = fc.moveBlock;\n fc.moveBlock = function (block, dir) {\n const cc = colCtx(block);\n if (cc) return moveColumn(cc.list, cc.index, dir);\n if (isInList(block)) return nudgeGroup(block, dir);\n return origMove ? origMove.call(this, block, dir) : false;\n };\n\n const origDup = fc.duplicateBlock;\n fc.duplicateBlock = function (block) {\n const cc = colCtx(block);\n if (cc) return duplicateColumn(cc.list, cc.index);\n if (isInList(block)) return duplicateGroup(block);\n return origDup ? origDup.call(this, block) : null;\n };\n };\n\n // Typing into a text block changes its CONTENT (not its `style`), so the\n // style observer never sees it. A ResizeObserver watches each content block's\n // box instead: when text makes it grow/shrink we re-fit the List's height.\n const observedBlocks = new WeakSet();\n let ro = null;\n let roQueue = new Set();\n let roRaf = 0;\n const flushRo = () => {\n roRaf = 0;\n const lists = Array.from(roQueue);\n roQueue.clear();\n lists.forEach((l) => { if (l.isConnected) autoSizeList(l); });\n };\n const observeBlock = (b) => {\n if (!ro || observedBlocks.has(b)) return;\n observedBlocks.add(b);\n ro.observe(b);\n };\n const observeAll = (root) => contentBlocks(root).forEach(observeBlock);\n\n const init = () => {\n wrapOverrides();\n\n // Watch the outer .cs_paper so BOTH content pages (.cs_page > .custom-form-design)\n // AND cover pages (.cs_page[data-cs-cover].custom-form-design, which are siblings\n // under .cs_paper not descendants of the first .custom-form-design) are observed.\n // Watching only the first .custom-form-design silently skipped cover pages.\n const surface = document.querySelector('.cs_paper') || document.querySelector('.custom-form-design') || document.body;\n\n if (typeof ResizeObserver !== 'undefined') {\n ro = new ResizeObserver((entries) => {\n for (const e of entries) {\n const list = e.target.closest?.('.cs-synclist-block');\n if (list) roQueue.add(list);\n }\n if (!roRaf) roRaf = requestAnimationFrame(flushRo);\n });\n }\n\n // Observe existing content blocks and fit each list once on load.\n document.querySelectorAll('.cs-synclist-block').forEach((list) => {\n observeAll(list);\n autoSizeList(list);\n });\n\n // Observe content blocks added later (drop, paste, add-row, add-column …)\n // and re-fit the list they land in.\n new MutationObserver((muts) => {\n for (const m of muts) {\n m.addedNodes.forEach((n) => {\n if (n.nodeType !== 1) return;\n if (n.matches?.('.cs-synclist__col > .cs_block_s')) observeBlock(n);\n n.querySelectorAll?.('.cs-synclist__col > .cs_block_s').forEach(observeBlock);\n const list = n.closest?.('.cs-synclist-block') || n.querySelector?.('.cs-synclist-block');\n if (list) autoSizeList(list);\n });\n }\n }).observe(surface, { childList: true, subtree: true });\n\n // Toolbar visibility follows selection in/out of a List.\n new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === 'class' && m.target.classList?.contains('cs_block_s')) { syncActive(); return; }\n }\n }).observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n\n // Style changes drive two syncs:\n // - a column's inline width → flex-basis (so the resize sticks);\n // - a content block's position/size → mirrored to its group siblings.\n new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName !== 'style') continue;\n const el = m.target;\n if (!(el instanceof HTMLElement) || !el.classList.contains('cs_block_s')) continue;\n if (el.classList.contains('cs-synclist__col')) {\n // Resizing a column feeds the shared --col-item-w/--col-item-h, so EVERY\n // column takes that exact px size (smooth, 1:1 with the drag); width\n // adds .cs-synclist--sized which switches columns from \"fill the row\n // equally\" to fixed px + wrap. Clear inline sizing so the var rule wins.\n const grid = el.closest('.cs-synclist');\n if (grid && (el.style.width || el.style.height)) {\n if (el.style.width) {\n // Clamp: never narrower than the rightmost child block's right edge.\n let w = parseFloat(el.style.width);\n const minW = minColContentWidth(el);\n if (minW > 0 && w < minW) {\n w = minW;\n blinkBlockers(el, minW);\n }\n grid.style.setProperty('--col-item-w', `${w}px`);\n grid.classList.add('cs-synclist--sized');\n }\n // A manual height-resize becomes the new minimum (floor) for the\n // auto-height; autoSizeList then enforces max(floor, content+gap).\n if (el.style.height) grid.dataset.slFloorH = String(parseFloat(el.style.height) || 0);\n colEls(grid).forEach((c) => { c.style.width = ''; c.style.height = ''; c.style.maxWidth = ''; c.style.flex = ''; });\n if (el.style.height) autoSizeList(grid.closest('.cs-synclist-block'));\n }\n continue;\n }\n if (el.classList.contains('cs-synclist-block')) continue;\n if (el.closest('.cs-synclist__col')) { clampToCol(el); mirrorGeometry(el); autoSizeList(el.closest('.cs-synclist-block')); }\n }\n }).observe(surface, { attributes: true, attributeFilter: ['style'], subtree: true });\n\n // Close the add menu on any outside press.\n document.addEventListener('pointerdown', (e) => {\n if (menuEl && !e.target.closest('.cs-synclist-menu') && !e.target.closest('.cs-synclist-toolbar')) closeMenu();\n }, true);\n\n // Persist after a free drag / resize ends.\n document.addEventListener('pointerup', () => { if (active) afterChange(); });\n\n // Drag a block from the sidebar INTO a Container → drop it as a new synced\n // group at the cursor position (cloned across columns). Capture phase so we\n // beat the canvas drop handler (which would place it in the page flow).\n const overCol = (e) => e.target.closest?.('.cs-synclist__col');\n // Remove every drop highlight (ours + the canvas's blue indicator line).\n const clearDropHighlight = () => {\n document.querySelectorAll('.cs-synclist__col--dropping').forEach((c) => c.classList.remove('cs-synclist__col--dropping'));\n window.FlowCanvas?.hideIndicator?.();\n };\n document.addEventListener('dragover', (e) => {\n const col = overCol(e);\n if (!col) return;\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';\n // Only the column under the cursor is highlighted.\n document.querySelectorAll('.cs-synclist__col--dropping').forEach((c) => { if (c !== col) c.classList.remove('cs-synclist__col--dropping'); });\n col.classList.add('cs-synclist__col--dropping');\n }, true);\n document.addEventListener('drop', (e) => {\n const col = overCol(e);\n if (!col) { clearDropHighlight(); return; }\n const payload = readDropPayload(e);\n const type = payload?.blockType;\n clearDropHighlight();\n\n // Reusable component → drop as a new synced group, but only when it's a\n // single content block. Groups/sections fall through WITHOUT stopping\n // propagation so the canvas drop handler lands them in page flow.\n if (type === 'component') {\n if (!payload.componentHtml || !isSimpleComponentHtml(payload.componentHtml)) return;\n e.preventDefault();\n e.stopPropagation();\n const list = col.closest('.cs-synclist-block');\n const r = col.getBoundingClientRect();\n addComponentAt(list, payload.componentHtml, Math.max(0, e.clientX - r.left - 20), Math.max(0, e.clientY - r.top - 10));\n return;\n }\n\n if (!type || !ALLOWED.some((a) => a.type === type)) return;\n e.preventDefault();\n e.stopPropagation();\n const list = col.closest('.cs-synclist-block');\n const r = col.getBoundingClientRect();\n addBlockAt(list, type, Math.max(0, e.clientX - r.left - 20), Math.max(0, e.clientY - r.top - 10));\n }, true);\n // If the drag is cancelled (Esc / dropped off-canvas), clear any highlight.\n document.addEventListener('dragend', clearDropHighlight, true);\n };\n\n // Read a sidebar drag payload (same sources flow-canvas.js uses).\n const readDropPayload = (e) => {\n const dt = e.dataTransfer;\n const parse = (s) => { try { return JSON.parse(s); } catch (err) { return null; } };\n const direct = (dt && (parse(dt.getData('application/x-brochure-block')) || parse(dt.getData('text/plain'))));\n if (direct?.blockType) return direct;\n try {\n const fb = window.parent?.['__BROCHURE_FLOW_DRAG__'];\n if (fb?.blockType) return fb;\n } catch (err) { /* cross-origin */ }\n return null;\n };\n\n Object.assign(window.SyncList, { createBlock, handlePaste, pasteIntoCol, handleColumnPaste, activate, deactivate });\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/row-col-builder.js\">\n/**\n * @fileoverview Row / column DOM scaffolding and block placement.\n *\n * Exposes:\n * window.FlowCanvas.makeRow()\n * window.FlowCanvas.makeCol(flexGrow?)\n * window.FlowCanvas.makeDivider()\n * window.FlowCanvas.rebuildDividers(row) — re-inserts dividers between cols\n * window.FlowCanvas.resetColFlex(row) — equalize col widths\n * window.FlowCanvas.normalizeForFlow(block) — strip inline absolute styles\n * window.FlowCanvas.placeBlock(doc, block, target) — handles 'between-rows', 'col-edge', 'in-col'\n *\n * In-section placement (target.kind === 'in-section') lives in section-canvas.js.\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const generateHash = () => {\n if (typeof BlockCreator !== 'undefined') {\n const protoHash = BlockCreator.prototype?.generateHash;\n if (typeof protoHash === 'function') {\n return protoHash.call({});\n }\n }\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n return Math.random().toString(16).slice(2) + '-' + Math.random().toString(16).slice(2);\n };\n\n const assignNodeId = (el, type) => {\n if (!el || el.id) return el;\n el.id = `${type}_${generateHash()}`;\n return el;\n };\n\n const makeRow = () => {\n const row = document.createElement('div');\n row.className = 'row-item';\n return assignNodeId(row, 'row');\n };\n\n const makeCol = (flexGrow = 1) => {\n const col = document.createElement('div');\n col.className = 'col-item';\n col.style.flex = `${flexGrow} 1 0`;\n return assignNodeId(col, 'col');\n };\n\n const makeDivider = () => {\n const div = document.createElement('div');\n div.className = 'cs-line-divider';\n return div;\n };\n\n const rebuildDividers = (row) => {\n row.querySelectorAll(':scope > .cs-line-divider').forEach(d => d.remove());\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n for (let i = 0; i < cols.length - 1; i++) {\n cols[i].after(makeDivider());\n }\n };\n\n const resetColFlex = (row) => {\n row.querySelectorAll(':scope > .col-item').forEach(col => {\n col.style.flex = '1 1 0';\n });\n };\n\n const normalizeForFlow = (block) => {\n block.style.position = '';\n block.style.left = '';\n block.style.top = '';\n block.style.width = '';\n block.style.height = '';\n block.style.minHeight = '';\n block.style.maxWidth = '';\n block.style.minWidth = '';\n delete block.dataset.csInSection;\n };\n\n const syncFlexibleContentBounds = (block) => {\n if (!block) return;\n const isFlexibleBlock =\n block.dataset.blockType === 'flexible' ||\n block.classList.contains('cs-flexible-block');\n if (!isFlexibleBlock) return;\n\n const content = block.querySelector(':scope > .cs-flexible-content');\n if (!content) return;\n\n const floor = window.CanvasConfig?.flexible?.minHeight ?? 20;\n\n if (block.style.height) {\n // Manual resize: the inner content must match the exact height the user\n // dragged to. Read it straight from the block's inline height instead of\n // clientHeight (which can lag/diverge and leaves content taller than the\n // block — e.g. block 20px but content stuck at 30px).\n const h = Math.max(floor, Math.round(parseFloat(block.style.height) || 0));\n content.style.minHeight = `${h}px`;\n content.style.height = `${h}px`;\n } else {\n // Auto height: keep a visible floor, otherwise grow with content.\n const h = Math.max(floor, Math.round(block.clientHeight || block.getBoundingClientRect().height || 0));\n content.style.minHeight = `${h}px`;\n content.style.height = '';\n }\n };\n\n /**\n * Insert a block into the document tree at the specified target.\n *\n * @param {HTMLElement} doc - the .cs_margin container\n * @param {HTMLElement} block - block element to insert\n * @param {Object} target - { kind, ... } from drop-zone detection\n */\n const placeBlock = (doc, block, target, clientX, clientY, blockType) => {\n if (!target) return;\n\n // Preserve styles of nested flexible blocks during drop\n // This prevents nested flexible containers from losing their position/size\n const preservedFlexibleStyles = new Map();\n doc.querySelectorAll('.cs-flexible-content').forEach(flexContainer => {\n const wrapper = flexContainer.closest('.cs_block_s');\n if (wrapper) {\n preservedFlexibleStyles.set(wrapper, {\n position: wrapper.style.position,\n left: wrapper.style.left,\n top: wrapper.style.top,\n width: wrapper.style.width,\n height: wrapper.style.height,\n maxWidth: wrapper.style.maxWidth,\n minWidth: wrapper.style.minWidth,\n csInSection: wrapper.dataset.csInSection\n });\n }\n });\n\n\n if (/^predefine-template-\\d+$/.test(blockType) && $(target.parent).hasClass('cs_margin')) {\n $(target.parent).append(block);\n return;\n }\n\n\n if (target.kind === 'between-rows') {\n const parent = target.parent || doc;\n\n // Check if parent is a free canvas (flexible container OR a cover page) -\n // if so, use absolute positioning. A cover page (.cs_page[data-cs-cover])\n // hosts its blocks as absolutely-positioned DIRECT children, with no\n // flexible-content wrapper.\n const isFreeCanvasParent = parent &&\n (parent.classList.contains('cs-flexible-content') || parent.dataset?.csCover === '1');\n if (isFreeCanvasParent) {\n // Restrict certain block types from being placed in flexible containers.\n // Exception: a cover page is a free-move canvas where ALL block types\n // are allowed, so the restriction is bypassed when the flexible\n // container lives inside a `data-cs-cover` page.\n const inCoverPage = !!parent.closest('[data-cs-cover=\"1\"]');\n const RESTRICTED_TYPES = window.FormBlockRegistry?.restrictedInFlexibleTypes() ||\n ['section-container', 'table-repeater', 'list-repeater'];\n if (!inCoverPage && RESTRICTED_TYPES.includes(blockType)) {\n // Fallback: place in doc root instead\n normalizeForFlow(block);\n const row = makeRow();\n const col = makeCol();\n col.appendChild(block);\n row.appendChild(col);\n doc.appendChild(row);\n return;\n }\n\n block.dataset.csInSection = '1';\n block.style.position = 'absolute';\n\n // Check if this is an existing flexible block being moved (already has width/height)\n // If so, preserve its size but update position based on cursor\n const isExistingFlexibleBlock = block.dataset.csInSection === '1' &&\n (block.style.width || block.style.height);\n\n // Insert FIRST so a new block can be measured — offsetWidth/Height are 0\n // while detached, which made the centring + clamp wrong and let the\n // block hang off the page edge (worst at the right edge).\n if (target.beforeRow) {\n target.beforeRow.before(block);\n } else {\n parent.appendChild(block);\n }\n\n if (!isExistingFlexibleBlock) {\n // New block - drop it where the cursor is RELEASED: the cursor maps to\n // the block's top-left corner (not its centre, which pulled a wide\n // default block ~half-its-width to the left). Account for the parent's\n // border so the math matches the absolute-positioning origin (the\n // padding edge), then clamp so the whole block stays inside the page.\n const parentRect = parent.getBoundingClientRect();\n const cs = getComputedStyle(parent);\n const borderL = parseFloat(cs.borderLeftWidth) || 0;\n const borderT = parseFloat(cs.borderTopWidth) || 0;\n const bw = block.offsetWidth || 0;\n const bh = block.offsetHeight || 0;\n let left = clientX - parentRect.left - borderL;\n let top = clientY - parentRect.top - borderT;\n\n left = Math.max(0, Math.min(left, Math.max(0, parent.clientWidth - bw)));\n top = Math.max(0, Math.min(top, Math.max(0, parent.clientHeight - bh)));\n\n block.style.left = `${left}px`;\n block.style.top = `${top}px`;\n }\n syncFlexibleContentBounds(block);\n return;\n }\n\n normalizeForFlow(block);\n const row = makeRow();\n const col = makeCol();\n col.appendChild(block);\n row.appendChild(col);\n // The drop-zone may have decided this drop belongs inside a section's\n // content area instead of the doc root. `target.parent` carries that\n // scope when present.\n if (target.beforeRow) {\n target.beforeRow.before(row);\n } else {\n parent.appendChild(row);\n }\n syncFlexibleContentBounds(block);\n return;\n }\n\n if (target.kind === 'col-edge') {\n normalizeForFlow(block);\n const col = makeCol();\n col.appendChild(block);\n if (target.beforeCol) {\n target.beforeCol.before(col);\n } else {\n target.row.appendChild(col);\n }\n rebuildDividers(target.row);\n syncFlexibleContentBounds(block);\n return;\n }\n\n if (target.kind === 'in-col') {\n normalizeForFlow(block);\n if (target.beforeBlock) {\n target.beforeBlock.before(block);\n } else {\n target.col.appendChild(block);\n }\n }\n\n syncFlexibleContentBounds(block);\n\n // Restore preserved flexible block styles\n preservedFlexibleStyles.forEach((styles, wrapper) => {\n wrapper.style.position = styles.position;\n wrapper.style.left = styles.left;\n wrapper.style.top = styles.top;\n wrapper.style.width = styles.width;\n wrapper.style.height = styles.height;\n wrapper.style.maxWidth = styles.maxWidth;\n wrapper.style.minWidth = styles.minWidth;\n if (styles.csInSection) {\n wrapper.dataset.csInSection = styles.csInSection;\n }\n syncFlexibleContentBounds(wrapper);\n });\n };\n\n Object.assign(window.FlowCanvas, {\n generateHash,\n assignNodeId,\n makeRow,\n makeCol,\n makeDivider,\n rebuildDividers,\n resetColFlex,\n normalizeForFlow,\n syncFlexibleContentBounds,\n placeBlock,\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/group.js\">\n/**\n * @fileoverview Group / ungroup for free-move (cover page) blocks — model + UI.\n *\n * Scoped entirely to cover pages (`.cs_page[data-cs-cover=\"1\"]`) so normal flow\n * pages and the single-block selection in inline-editor.js are untouched.\n *\n * UI:\n * - Drag a rubber-band rectangle over empty cover-page area → every block it\n * touches becomes `.cs-multi-selected`; a floating \"Group\" button appears.\n * - Click a group → inline-editor selects the whole group (via the\n * `FlowCanvas.resolveSelectable` hook); a floating \"Ungroup\" button appears.\n * - Click again (drill in) → the inner child is selected; its \"Ungroup\" pops\n * just that child out.\n * - Ctrl+G groups the marquee selection, Ctrl+Shift+G ungroups.\n *\n * Model (on window.FlowCanvas):\n * groupBlocks(blocks) → group element (or null) — bundle 2+ free blocks\n * ungroupBlocks(group) — dissolve, all kids loose\n * ungroupOne(child) → child — pop one kid out of a group\n *\n * A \"group\" is a normal free-form `.cs_block_s.cs-group-block` (`position:absolute`,\n * `data-cs-in-section=\"1\"`) whose children are absolute blocks positioned relative\n * to the group box, so moving the group moves them together — reusing the existing\n * free-move machinery. Because a group is just a `.cs_block_s`, duplicate / delete /\n * export work on it as-is.\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const FC = window.FlowCanvas;\n const EM = () => window.EditorManager;\n\n const num = (v) => { const n = parseFloat(v); return Number.isNaN(n) ? 0 : n; };\n\n // Position/size of a free block relative to its positioned parent\n // (the cover page, or — for a child — the group box).\n const posOf = (block) => ({\n left: block.style.left ? num(block.style.left) : block.offsetLeft,\n top: block.style.top ? num(block.style.top) : block.offsetTop,\n width: block.offsetWidth,\n height: block.offsetHeight,\n });\n\n const coverOf = (el) => el?.closest?.('[data-cs-cover=\"1\"]') || null;\n const childBlocksOf = (group) =>\n Array.from(group.children).filter((c) => c.classList?.contains('cs_block_s'));\n const coverChildBlocks = (cover) =>\n Array.from(cover.children).filter((c) => c.classList?.contains('cs_block_s'));\n\n const markFree = (block) => {\n block.style.position = 'absolute';\n block.dataset.csInSection = '1';\n };\n\n /* ============================== MODEL ============================== */\n\n FC.groupBlocks = function (blocks) {\n blocks = (blocks || []).filter((b) => b && b.classList?.contains('cs_block_s'));\n if (blocks.length < 2) return null;\n const cover = coverOf(blocks[0]);\n if (!cover) return null;\n\n // Bounding box in cover-page coordinates.\n let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;\n const meta = blocks.map((b) => {\n const p = posOf(b);\n minL = Math.min(minL, p.left);\n minT = Math.min(minT, p.top);\n maxR = Math.max(maxR, p.left + p.width);\n maxB = Math.max(maxB, p.top + p.height);\n return { block: b, p };\n });\n\n const group = document.createElement('div');\n group.className = 'cs_block_s cs-group-block';\n group.dataset.blockType = 'group';\n group.dataset.csInSection = '1';\n group.setAttribute('data', 'Group');\n group.setAttribute('custom-name', 'Group');\n FC.assignNodeId?.(group, 'group');\n group.style.position = 'absolute';\n group.style.left = `${minL}px`;\n group.style.top = `${minT}px`;\n group.style.width = `${maxR - minL}px`;\n group.style.height = `${maxB - minT}px`;\n cover.appendChild(group);\n\n // Reparent children, repositioning relative to the group origin (DOM order\n // preserved by iterating the original list).\n meta.forEach(({ block, p }) => {\n markFree(block);\n block.style.left = `${p.left - minL}px`;\n block.style.top = `${p.top - minT}px`;\n block.classList.remove('cs-multi-selected', 'cs-selected', 'cs-editing');\n group.appendChild(block);\n });\n\n return group;\n };\n\n FC.ungroupBlocks = function (group) {\n if (!group || !group.classList?.contains('cs-group-block')) return;\n const cover = coverOf(group) || group.parentElement;\n if (!cover) return;\n const gp = posOf(group);\n childBlocksOf(group).forEach((child) => {\n const c = posOf(child);\n markFree(child);\n child.style.left = `${gp.left + c.left}px`;\n child.style.top = `${gp.top + c.top}px`;\n cover.appendChild(child);\n });\n group.remove();\n };\n\n // Recompute a group's box to tightly fit its remaining children, adjusting\n // child offsets so they keep their on-screen position.\n const refitGroup = (group) => {\n const kids = childBlocksOf(group);\n if (!kids.length) { group.remove(); return; }\n const gp = posOf(group);\n let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;\n const meta = kids.map((k) => {\n const p = posOf(k);\n minL = Math.min(minL, p.left);\n minT = Math.min(minT, p.top);\n maxR = Math.max(maxR, p.left + p.width);\n maxB = Math.max(maxB, p.top + p.height);\n return { k, p };\n });\n group.style.left = `${gp.left + minL}px`;\n group.style.top = `${gp.top + minT}px`;\n group.style.width = `${maxR - minL}px`;\n group.style.height = `${maxB - minT}px`;\n meta.forEach(({ k, p }) => {\n k.style.left = `${p.left - minL}px`;\n k.style.top = `${p.top - minT}px`;\n });\n };\n\n // Public: grow/shrink a group so its box always wraps every child (called\n // after a child is moved, resized, or pasted in).\n FC.refitGroupToChildren = (group) => {\n if (group && group.classList?.contains('cs-group-block')) refitGroup(group);\n };\n\n FC.ungroupOne = function (child) {\n if (!child) return null;\n const group = child.closest('.cs-group-block');\n if (!group) return null;\n const cover = coverOf(group) || group.parentElement;\n if (!cover) return null;\n\n const gp = posOf(group);\n const c = posOf(child);\n markFree(child);\n child.style.left = `${gp.left + c.left}px`;\n child.style.top = `${gp.top + c.top}px`;\n cover.appendChild(child);\n\n // A group of one is pointless — dissolve it (releasing the last child too).\n const remaining = childBlocksOf(group);\n if (remaining.length <= 1) {\n FC.ungroupBlocks(group);\n } else {\n refitGroup(group);\n }\n return child;\n };\n\n /* ============================== SELECTION + UI ============================== */\n\n /* ---- multi-select state ---- */\n const multi = new Set();\n const clearMulti = () => {\n multi.forEach((b) => b.classList.remove('cs-multi-selected'));\n multi.clear();\n hideToolbar();\n hideBounds();\n };\n const setMulti = (blocks) => {\n multi.forEach((b) => b.classList.remove('cs-multi-selected'));\n multi.clear();\n blocks.forEach((b) => { multi.add(b); b.classList.add('cs-multi-selected'); });\n };\n FC.getMultiSelection = () => [...multi];\n FC.clearMultiSelection = clearMulti;\n\n /* ---- floating toolbar ---- */\n let toolbar = null;\n const ensureToolbar = () => {\n if (toolbar) return toolbar;\n toolbar = document.createElement('div');\n toolbar.className = 'cs-group-toolbar';\n toolbar.setAttribute('data-cs-chrome', '');\n document.body.appendChild(toolbar);\n return toolbar;\n };\n const hideToolbar = () => { if (toolbar) toolbar.style.display = 'none'; };\n\n const placeToolbar = (anchorRect, html, below = false) => {\n const tb = ensureToolbar();\n tb.innerHTML = html;\n tb.style.display = 'inline-flex';\n tb.style.position = 'fixed';\n tb.style.zIndex = '10001';\n tb.style.left = `${Math.max(4, anchorRect.left)}px`;\n tb.style.top = below ? `${anchorRect.bottom + 6}px` : `${Math.max(4, anchorRect.top - 34)}px`;\n };\n\n const bboxOf = (els) => {\n let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;\n els.forEach((el) => {\n const q = el.getBoundingClientRect();\n l = Math.min(l, q.left); t = Math.min(t, q.top);\n r = Math.max(r, q.right); b = Math.max(b, q.bottom);\n });\n return { left: l, top: t, right: r, bottom: b };\n };\n\n /* ---- dotted bounding box around the whole multi-selection (group preview) ---- */\n let boundsEl = null;\n const hideBounds = () => { if (boundsEl) boundsEl.style.display = 'none'; };\n const showBounds = (els) => {\n if (!els || els.length < 2) { hideBounds(); return; }\n if (!boundsEl) {\n boundsEl = document.createElement('div');\n boundsEl.className = 'cs-group-bounds';\n boundsEl.setAttribute('data-cs-chrome', '');\n document.body.appendChild(boundsEl);\n }\n const r = bboxOf(els);\n boundsEl.style.display = 'block';\n boundsEl.style.position = 'fixed';\n boundsEl.style.zIndex = '9999';\n boundsEl.style.left = `${r.left}px`;\n boundsEl.style.top = `${r.top}px`;\n boundsEl.style.width = `${r.right - r.left}px`;\n boundsEl.style.height = `${r.bottom - r.top}px`;\n };\n\n /* ---- align / distribute the multi-selection ---- */\n const A_ICON = {\n left: '<svg viewBox=\"0 0 16 16\"><line x1=\"2\" y1=\"1.5\" x2=\"2\" y2=\"14.5\"/><rect x=\"3.5\" y=\"4\" width=\"9\" height=\"3\"/><rect x=\"3.5\" y=\"9\" width=\"6\" height=\"3\"/></svg>',\n cx: '<svg viewBox=\"0 0 16 16\"><line x1=\"8\" y1=\"1.5\" x2=\"8\" y2=\"14.5\"/><rect x=\"3\" y=\"4\" width=\"10\" height=\"3\"/><rect x=\"4.5\" y=\"9\" width=\"7\" height=\"3\"/></svg>',\n right: '<svg viewBox=\"0 0 16 16\"><line x1=\"14\" y1=\"1.5\" x2=\"14\" y2=\"14.5\"/><rect x=\"3.5\" y=\"4\" width=\"9\" height=\"3\"/><rect x=\"6.5\" y=\"9\" width=\"6\" height=\"3\"/></svg>',\n top: '<svg viewBox=\"0 0 16 16\"><line x1=\"1.5\" y1=\"2\" x2=\"14.5\" y2=\"2\"/><rect x=\"4\" y=\"3.5\" width=\"3\" height=\"9\"/><rect x=\"9\" y=\"3.5\" width=\"3\" height=\"6\"/></svg>',\n cy: '<svg viewBox=\"0 0 16 16\"><line x1=\"1.5\" y1=\"8\" x2=\"14.5\" y2=\"8\"/><rect x=\"4\" y=\"3\" width=\"3\" height=\"10\"/><rect x=\"9\" y=\"4.5\" width=\"3\" height=\"7\"/></svg>',\n bottom: '<svg viewBox=\"0 0 16 16\"><line x1=\"1.5\" y1=\"14\" x2=\"14.5\" y2=\"14\"/><rect x=\"4\" y=\"3.5\" width=\"3\" height=\"9\"/><rect x=\"9\" y=\"6.5\" width=\"3\" height=\"6\"/></svg>',\n distH: '<svg viewBox=\"0 0 16 16\"><rect x=\"1\" y=\"4\" width=\"2.5\" height=\"8\"/><rect x=\"6.75\" y=\"4\" width=\"2.5\" height=\"8\"/><rect x=\"12.5\" y=\"4\" width=\"2.5\" height=\"8\"/></svg>',\n distV: '<svg viewBox=\"0 0 16 16\"><rect x=\"4\" y=\"1\" width=\"8\" height=\"2.5\"/><rect x=\"4\" y=\"6.75\" width=\"8\" height=\"2.5\"/><rect x=\"4\" y=\"12.5\" width=\"8\" height=\"2.5\"/></svg>',\n };\n const aBtn = (action, ic, title) =>\n `<button type=\"button\" class=\"cs-group-toolbar__ico\" data-cs-group-action=\"${action}\" title=\"${title}\">${A_ICON[ic]}</button>`;\n\n // Align every selected block to the SELECTION's bounding box (free-move blocks\n // are absolutely positioned, so we set inline left/top — export-safe).\n const alignSelection = (cmd) => {\n const blocks = [...multi].filter((b) => b.offsetParent);\n if (blocks.length < 2) return;\n let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;\n blocks.forEach((b) => {\n minL = Math.min(minL, b.offsetLeft); minT = Math.min(minT, b.offsetTop);\n maxR = Math.max(maxR, b.offsetLeft + b.offsetWidth); maxB = Math.max(maxB, b.offsetTop + b.offsetHeight);\n });\n const cx = (minL + maxR) / 2, cy = (minT + maxB) / 2;\n blocks.forEach((b) => {\n const w = b.offsetWidth, h = b.offsetHeight;\n if (cmd === 'left') b.style.left = `${Math.round(minL)}px`;\n else if (cmd === 'cx') b.style.left = `${Math.round(cx - w / 2)}px`;\n else if (cmd === 'right') b.style.left = `${Math.round(maxR - w)}px`;\n else if (cmd === 'top') b.style.top = `${Math.round(minT)}px`;\n else if (cmd === 'cy') b.style.top = `${Math.round(cy - h / 2)}px`;\n else if (cmd === 'bottom') b.style.top = `${Math.round(maxB - h)}px`;\n });\n showGroupButton(); showBounds(blocks);\n };\n\n const distributeSelection = (axis) => {\n const blocks = [...multi].filter((b) => b.offsetParent);\n if (blocks.length < 3) return;\n const c = (b) => axis === 'h' ? (b.offsetLeft + b.offsetWidth / 2) : (b.offsetTop + b.offsetHeight / 2);\n blocks.sort((a, b) => c(a) - c(b));\n const c0 = c(blocks[0]), c1 = c(blocks[blocks.length - 1]), step = (c1 - c0) / (blocks.length - 1);\n blocks.forEach((b, i) => {\n if (i === 0 || i === blocks.length - 1) return;\n const target = c0 + step * i;\n if (axis === 'h') b.style.left = `${Math.round(target - b.offsetWidth / 2)}px`;\n else b.style.top = `${Math.round(target - b.offsetHeight / 2)}px`;\n });\n showGroupButton(); showBounds(blocks);\n };\n\n const showGroupButton = () => {\n const blocks = [...multi];\n if (blocks.length < 2) { hideToolbar(); return; }\n let html = aBtn('align-left', 'left', 'Align left') + aBtn('align-cx', 'cx', 'Align centre') + aBtn('align-right', 'right', 'Align right')\n + `<span class=\"cs-group-toolbar__sep\"></span>`\n + aBtn('align-top', 'top', 'Align top') + aBtn('align-cy', 'cy', 'Align middle') + aBtn('align-bottom', 'bottom', 'Align bottom');\n if (blocks.length >= 3) {\n html += `<span class=\"cs-group-toolbar__sep\"></span>` + aBtn('dist-h', 'distH', 'Distribute horizontally') + aBtn('dist-v', 'distV', 'Distribute vertically');\n }\n html += `<span class=\"cs-group-toolbar__sep\"></span>`\n + `<button type=\"button\" class=\"cs-group-toolbar__btn\" data-cs-group-action=\"group\">&#x29C9; Group</button>`;\n placeToolbar(bboxOf(blocks), html);\n };\n\n // Show \"Ungroup\" when a single group (or a child inside a group) is selected.\n const refreshUngroupButton = () => {\n if (multi.size >= 2) return; // group button wins\n const sel = EM()?.getSelected?.();\n if (!sel) { hideToolbar(); return; }\n const isGroup = sel.classList.contains('cs-group-block');\n const inGroup = !isGroup && sel.closest('.cs-group-block');\n if (!isGroup && !inGroup) { hideToolbar(); return; }\n placeToolbar(sel.getBoundingClientRect(),\n `<button type=\"button\" class=\"cs-group-toolbar__btn\" data-cs-group-action=\"ungroup\">&#x29C8; ${isGroup ? 'Ungroup' : 'Ungroup this'}</button>`,\n true);\n };\n\n const doGroup = () => {\n if (multi.size < 2) return;\n const group = FC.groupBlocks([...multi]);\n clearMulti();\n if (group) EM()?.select?.(group);\n };\n\n const doUngroup = () => {\n const sel = EM()?.getSelected?.();\n if (!sel) return;\n const isGroup = sel.classList.contains('cs-group-block');\n const inGroup = !isGroup && sel.closest('.cs-group-block');\n if (!isGroup && !inGroup) return;\n EM()?.clearAll?.();\n hideToolbar();\n if (isGroup) FC.ungroupBlocks(sel);\n else FC.ungroupOne(sel);\n };\n\n // Suppress the click that follows a multi-block drag so inline-editor doesn't\n // collapse the selection to a single block.\n let suppressClick = false;\n\n // Toolbar button clicks (capture phase, stop before inline-editor sees them).\n document.addEventListener('click', (e) => {\n if (suppressClick) { suppressClick = false; e.stopPropagation(); e.preventDefault(); return; }\n const btn = e.target.closest?.('[data-cs-group-action]');\n if (!btn) return;\n e.preventDefault();\n e.stopPropagation();\n const act = btn.dataset.csGroupAction;\n if (act === 'group') doGroup();\n else if (act === 'ungroup') doUngroup();\n else if (act.indexOf('align-') === 0) alignSelection(act.slice(6));\n else if (act.indexOf('dist-') === 0) distributeSelection(act.slice(5));\n }, true);\n\n /* ---- drag the whole selection (marquee multi-select OR a group) ----\n * Becomes an ACTIVE drag only after the pointer moves past a small threshold,\n * so a clean press-release still reaches inline-editor as a click (needed to\n * drill into / select an inner block). No pointer capture is used — the\n * trailing click target stays intact, which is what makes drill-in work. */\n let drag = null;\n\n const snapshot = (blocks) => blocks.map((b) => ({\n block: b,\n left: b.style.left ? num(b.style.left) : b.offsetLeft,\n top: b.style.top ? num(b.style.top) : b.offsetTop,\n }));\n\n const beginPending = (e, blocks, kind) => {\n drag = { startX: e.clientX, startY: e.clientY, active: false, kind, items: snapshot(blocks) };\n };\n\n const pointInBox = (x, y, blocks) => {\n if (!blocks.length) return false;\n const r = bboxOf(blocks);\n return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;\n };\n\n /* ---- marquee ---- */\n let band = null;\n let overlay = null;\n const drawOverlay = (x0, y0, x1, y1) => {\n if (!overlay) {\n overlay = document.createElement('div');\n overlay.className = 'cs-marquee';\n overlay.setAttribute('data-cs-chrome', '');\n document.body.appendChild(overlay);\n }\n overlay.style.display = 'block';\n overlay.style.position = 'fixed';\n overlay.style.zIndex = '10000';\n overlay.style.left = `${Math.min(x0, x1)}px`;\n overlay.style.top = `${Math.min(y0, y1)}px`;\n overlay.style.width = `${Math.abs(x1 - x0)}px`;\n overlay.style.height = `${Math.abs(y1 - y0)}px`;\n };\n\n const onPointerDown = (e) => {\n if (e.button !== 0) return;\n // Ignore our chrome — except the badge move handle, which should still be\n // able to drag a selected group (handled by case 2 below).\n if (e.target.closest('[data-cs-chrome]') && !e.target.closest('[data-cs-move]')) return;\n\n const hitBlock = e.target.closest('.cs_block_s');\n const sel = EM()?.getSelected?.();\n const group = sel && sel.classList.contains('cs-group-block') ? sel : null;\n const cover = e.target.closest('[data-cs-cover=\"1\"]');\n\n // (1) Drag a 2+ marquee selection — from any selected block OR from empty\n // space inside the selection's bounding box.\n if (multi.size >= 2) {\n const onSelBlock = hitBlock && [...multi].some((b) => b === hitBlock || b.contains(e.target));\n const lockedHit = hitBlock && hitBlock.closest('[data-cs-locked=\"1\"]');\n if (!lockedHit && (onSelBlock || (cover && pointInBox(e.clientX, e.clientY, [...multi])))) {\n beginPending(e, [...multi], 'multi'); // threshold drag; click still drills/collapses\n return;\n }\n }\n\n // (2) Drag a selected GROUP from anywhere inside it (a clean click drills in).\n // Locked groups aren't draggable (but stay clickable to drill in).\n if (group && group.dataset.csLocked !== '1' && (e.target === group || group.contains(e.target))) {\n beginPending(e, [group], 'group');\n return;\n }\n\n // (3) Pressed another block → inline-editor owns selection/move.\n if (hitBlock) { if (multi.size) clearMulti(); hideToolbar(); return; }\n\n // (4) Empty cover area → start a marquee.\n if (!cover) return;\n e.preventDefault();\n clearMulti();\n EM()?.clearAll?.();\n band = { cover, x0: e.clientX, y0: e.clientY };\n drawOverlay(e.clientX, e.clientY, e.clientX, e.clientY);\n };\n\n const onPointerMove = (e) => {\n if (drag) {\n const dx = e.clientX - drag.startX;\n const dy = e.clientY - drag.startY;\n if (!drag.active) {\n if (Math.abs(dx) <= 3 && Math.abs(dy) <= 3) return; // below threshold: still a click\n drag.active = true;\n hideToolbar();\n }\n e.preventDefault();\n window.getSelection?.()?.removeAllRanges?.(); // don't text-select while dragging\n drag.items.forEach(({ block, left, top }) => {\n block.style.left = `${left + dx}px`;\n block.style.top = `${top + dy}px`;\n });\n // Keep the selection box visible and following the drag (border stays on).\n if (drag.kind === 'multi') showBounds(drag.items.map((i) => i.block));\n return;\n }\n if (!band) return;\n drawOverlay(band.x0, band.y0, e.clientX, e.clientY);\n const box = {\n left: Math.min(band.x0, e.clientX), top: Math.min(band.y0, e.clientY),\n right: Math.max(band.x0, e.clientX), bottom: Math.max(band.y0, e.clientY),\n };\n const hits = coverChildBlocks(band.cover).filter((c) => {\n const r = c.getBoundingClientRect();\n return !(r.right < box.left || r.left > box.right || r.bottom < box.top || r.top > box.bottom);\n });\n setMulti(hits);\n };\n\n const onPointerUp = () => {\n if (drag) {\n const { active, kind, items } = drag;\n drag = null;\n if (active) {\n // A real drag — keep the selection; swallow the trailing click so\n // inline-editor doesn't collapse/re-select.\n suppressClick = true;\n if (kind === 'multi') { showBounds(items.map((i) => i.block)); showGroupButton(); }\n else requestAnimationFrame(refreshUngroupButton); // group moved → reposition Ungroup\n } else if (kind === 'multi') {\n // Plain click on a selected block → collapse to single-select; the\n // trailing click lands on inline-editor as usual.\n clearMulti();\n }\n // kind 'group' + no drag → do nothing; the trailing click drills into a child.\n return;\n }\n if (band) {\n band = null;\n if (overlay) overlay.style.display = 'none';\n // Only now (on release) draw the dotted bounding box + Group button.\n showBounds([...multi]);\n showGroupButton();\n return;\n }\n requestAnimationFrame(refreshUngroupButton); // selection may have changed via a block click\n };\n\n const onKeydown = (e) => {\n // Multi-select delete: Delete/Backspace removes every marquee-selected block.\n if ((e.key === 'Delete' || e.key === 'Backspace') && multi.size >= 1) {\n const ae = document.activeElement;\n if (ae && (ae.isContentEditable || ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return;\n e.preventDefault();\n const blocks = [...multi];\n clearMulti();\n const del = window.FlowCanvas?.deleteBlock || ((b) => b.remove());\n blocks.forEach((b) => del(b));\n return;\n }\n\n const g = (e.ctrlKey || e.metaKey) && (e.key === 'g' || e.key === 'G');\n if (!g) return;\n if (e.shiftKey) {\n const sel = EM()?.getSelected?.();\n if (sel && (sel.classList.contains('cs-group-block') || sel.closest('.cs-group-block'))) {\n e.preventDefault();\n doUngroup();\n }\n } else if (multi.size >= 2) {\n e.preventDefault();\n doGroup();\n }\n };\n\n const init = () => {\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('pointermove', onPointerMove, true);\n document.addEventListener('pointerup', onPointerUp, true);\n document.addEventListener('pointercancel', onPointerUp, true);\n document.addEventListener('keydown', onKeydown);\n\n // Keep the Ungroup button in sync with inline-editor's selection changes.\n const surface = document.querySelector('.cs_paper') || document.querySelector('.custom-form-design');\n if (surface) {\n let scheduled = false;\n const obs = new MutationObserver(() => {\n if (scheduled) return;\n scheduled = true;\n requestAnimationFrame(() => { scheduled = false; if (!band) refreshUngroupButton(); });\n });\n obs.observe(surface, { attributes: true, attributeFilter: ['class'], subtree: true });\n }\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/context-menu.js\">\n/**\n * @fileoverview Right-click context menu for blocks.\n *\n * Right-click a block → quick actions: Duplicate, Delete, z-order (free blocks),\n * Copy / Paste style, Lock / Unlock. Reuses FlowCanvas.duplicateBlock /\n * deleteBlock; z-order + lock + style-copy are handled here so they work for any\n * free-move block (cover page / section child). Editor-only (menu lives in\n * <body>, never exported). Right-clicking while editing text falls through to\n * the browser's native menu (spellcheck etc.).\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const FC = window.FlowCanvas;\n\n const isFree = (b) => !!(b && b.classList && b.classList.contains('cs_block_s') &&\n (b.dataset.csInSection === '1' || b.classList.contains('cs-flexible-block') ||\n (b.closest && b.closest('[data-cs-cover=\"1\"]'))));\n\n /* ------------------------------ operations -------------------------------- */\n\n // Dense z-index reorder among `.cs_block_s` siblings (cover / section).\n const zOrder = (block, kind) => {\n const parent = block.parentElement;\n if (!parent) return;\n const sibs = Array.from(parent.children).filter((c) => c.matches && c.matches('.cs_block_s'));\n if (sibs.length < 2) return;\n const z = (el) => (parseInt(el.style.zIndex || '0', 10) || 0);\n const ordered = sibs.slice().sort((a, b) => (z(a) - z(b)) || (sibs.indexOf(a) - sibs.indexOf(b)));\n const i = ordered.indexOf(block);\n if (i < 0) return;\n if (kind === 'front') { ordered.splice(i, 1); ordered.push(block); }\n else if (kind === 'back') { ordered.splice(i, 1); ordered.unshift(block); }\n else if (kind === 'forward' && i < ordered.length - 1) { ordered.splice(i, 1); ordered.splice(i + 1, 0, block); }\n else if (kind === 'backward' && i > 0) { ordered.splice(i, 1); ordered.splice(i - 1, 0, block); }\n else return;\n ordered.forEach((el, idx) => { el.style.zIndex = String(idx + 1); });\n };\n\n const toggleLock = (block) => {\n if (block.dataset.csLocked === '1') delete block.dataset.csLocked;\n else block.dataset.csLocked = '1';\n };\n\n // Format painter. Copies the EFFECTIVE (computed) look — typography from the\n // text element, box look from the block — and pastes it as concrete inline\n // styles (export-safe). NOT position/size, so paste never moves the block.\n const textEl = (b) => b.querySelector(':scope > .edit_me') || b.querySelector(':scope > .canvas-block__content') || b;\n const TYPO_KEYS = ['color', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',\n 'textAlign', 'lineHeight', 'letterSpacing', 'textTransform', 'textDecorationLine'];\n const BOX_KEYS = ['backgroundColor',\n 'borderTopWidth', 'borderTopStyle', 'borderTopColor',\n 'borderRightWidth', 'borderRightStyle', 'borderRightColor',\n 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor',\n 'borderLeftWidth', 'borderLeftStyle', 'borderLeftColor',\n 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius',\n 'boxShadow', 'opacity'];\n let styleClip = null;\n const copyStyle = (block) => {\n const boxCs = getComputedStyle(block);\n const typoCs = getComputedStyle(textEl(block));\n styleClip = { typo: {}, box: {} };\n TYPO_KEYS.forEach((k) => { styleClip.typo[k] = typoCs[k]; });\n BOX_KEYS.forEach((k) => { styleClip.box[k] = boxCs[k]; });\n };\n const pasteStyle = (block) => {\n if (!styleClip) return;\n const dst = textEl(block);\n Object.entries(styleClip.typo).forEach(([k, v]) => { if (v) dst.style[k] = v; });\n Object.entries(styleClip.box).forEach(([k, v]) => { if (v) block.style[k] = v; });\n };\n\n /* -------------------------------- the menu -------------------------------- */\n\n let menu = null;\n const closeMenu = () => { if (menu) { menu.remove(); menu = null; } };\n\n const buildItems = (block) => {\n const free = isFree(block);\n const del = FC.deleteBlock || ((b) => b.remove());\n const items = [\n { label: 'Duplicate', hint: '⌘/Ctrl+D', act: () => FC.duplicateBlock && FC.duplicateBlock(block) },\n { label: 'Delete', hint: 'Del', danger: true, act: () => del(block) },\n ];\n if (free) {\n items.push({ sep: true },\n { label: 'Bring to front', act: () => zOrder(block, 'front') },\n { label: 'Bring forward', act: () => zOrder(block, 'forward') },\n { label: 'Send backward', act: () => zOrder(block, 'backward') },\n { label: 'Send to back', act: () => zOrder(block, 'back') });\n }\n items.push({ sep: true }, { label: 'Copy style', act: () => copyStyle(block) });\n if (styleClip) items.push({ label: 'Paste style', act: () => pasteStyle(block) });\n if (free) items.push({ sep: true }, { label: block.dataset.csLocked === '1' ? 'Unlock' : 'Lock', act: () => toggleLock(block) });\n return items;\n };\n\n const openMenu = (x, y, block) => {\n closeMenu();\n menu = document.createElement('div');\n menu.className = 'cs-ctx-menu';\n menu.setAttribute('data-cs-chrome', '');\n buildItems(block).forEach((it) => {\n if (it.sep) { const s = document.createElement('div'); s.className = 'cs-ctx-menu__sep'; menu.appendChild(s); return; }\n const b = document.createElement('button');\n b.type = 'button';\n b.className = 'cs-ctx-menu__item' + (it.danger ? ' is-danger' : '');\n b.innerHTML = `<span>${it.label}</span>${it.hint ? `<span class=\"cs-ctx-menu__hint\">${it.hint}</span>` : ''}`;\n b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); try { it.act(); } catch (err) { /* */ } closeMenu(); });\n menu.appendChild(b);\n });\n menu.addEventListener('pointerdown', (e) => e.stopPropagation(), true);\n document.body.appendChild(menu);\n // Clamp to viewport.\n const w = menu.offsetWidth, h = menu.offsetHeight;\n const vw = window.innerWidth, vh = window.innerHeight;\n menu.style.left = `${Math.min(x, vw - w - 8)}px`;\n menu.style.top = `${Math.min(y, vh - h - 8)}px`;\n };\n\n /* --------------------------------- wiring --------------------------------- */\n\n const init = () => {\n document.addEventListener('contextmenu', (e) => {\n const block = e.target.closest && e.target.closest('.cs_block_s');\n if (!block) { closeMenu(); return; } // empty area → native menu\n // Table blocks own their own context menu (table-block.js) outside Froala\n // mode — bail here so both menus don't open at once on the same right-click.\n const inFroala = (typeof window.isFroalaEditor === 'function') && window.isFroalaEditor();\n if (block.dataset.blockType === 'table' && !inFroala) { closeMenu(); return; }\n // While editing text, defer to the browser's native menu.\n if (window.EditorManager && window.EditorManager.getEditing && window.EditorManager.getEditing() === block) return;\n e.preventDefault();\n try { window.EditorManager && window.EditorManager.select && window.EditorManager.select(block); } catch (err) { /* */ }\n openMenu(e.clientX, e.clientY, block);\n });\n document.addEventListener('pointerdown', (e) => {\n if (menu && !e.target.closest('.cs-ctx-menu')) closeMenu();\n }, true);\n document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); });\n document.addEventListener('scroll', closeMenu, true);\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/shortcuts-overlay.js\">\n/**\n * @fileoverview Keyboard-shortcuts help overlay.\n *\n * Press “?” (Shift+/) anywhere outside a text field to toggle a cheat-sheet of\n * the editor's shortcuts. Esc or a click on the backdrop closes it. Also\n * openable via window.ShortcutsOverlay.toggle() (e.g. from a host button).\n */\n(function () {\n window.ShortcutsOverlay = window.ShortcutsOverlay || {};\n\n const isMac = /Mac|iPhone|iPad|iPod/i.test((navigator.platform || '') + ' ' + (navigator.userAgent || ''));\n const MOD = isMac ? '⌘' : 'Ctrl';\n\n const SECTIONS = [\n ['General', [\n [`${MOD} Z`, 'Undo'],\n [isMac ? '⌘ ⇧ Z' : 'Ctrl Y', 'Redo'],\n ['?', 'This help'],\n ['Esc', 'Deselect / close'],\n ]],\n ['Blocks', [\n [`${MOD} C`, 'Copy'],\n [`${MOD} V`, 'Paste'],\n [`${MOD} D`, 'Duplicate'],\n ['Del', 'Delete'],\n [`${MOD} R`, 'Rename block'],\n ['Arrows', 'Nudge / reorder (Shift = bigger)'],\n ['Right-click', 'Context menu'],\n ]],\n ['AI Writer', [\n [isMac ? '⌘ H' : 'Alt H', 'Ask Aiden to write'],\n ]],\n ['Pen / Shape', [\n ['✒ then click', 'Add points'],\n ['Hover edge', '+ to add a point'],\n ['Hover point', '× to remove (drag = move)'],\n ['✋', 'Move points / shape'],\n ['Enter', 'Close the shape'],\n [`${MOD} drag-snap`, 'Smart-align guides'],\n [`${MOD} wheel`, 'Zoom (shape designer)'],\n ]],\n ];\n\n let modal = null;\n\n const close = () => { if (modal) { modal.remove(); modal = null; } };\n\n const open = () => {\n if (modal) return;\n modal = document.createElement('div');\n modal.className = 'cs-shortcuts';\n modal.setAttribute('data-cs-chrome', '');\n let cols = '';\n SECTIONS.forEach(([title, rows]) => {\n cols += `<div class=\"cs-shortcuts__group\"><div class=\"cs-shortcuts__title\">${title}</div>`;\n rows.forEach(([k, d]) => {\n cols += `<div class=\"cs-shortcuts__row\"><kbd>${k}</kbd><span>${d}</span></div>`;\n });\n cols += '</div>';\n });\n modal.innerHTML = `\n <div class=\"cs-shortcuts__backdrop\"></div>\n <div class=\"cs-shortcuts__panel\">\n <div class=\"cs-shortcuts__head\">\n <span>Keyboard shortcuts</span>\n <button type=\"button\" class=\"cs-shortcuts__close\" aria-label=\"Close\">✕</button>\n </div>\n <div class=\"cs-shortcuts__cols\">${cols}</div>\n </div>`;\n modal.addEventListener('click', (e) => {\n if (e.target.closest('.cs-shortcuts__close') || e.target.classList.contains('cs-shortcuts__backdrop')) close();\n });\n document.body.appendChild(modal);\n };\n\n const toggle = () => { if (modal) close(); else open(); };\n Object.assign(window.ShortcutsOverlay, { open, close, toggle });\n\n const init = () => {\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') { close(); return; }\n // “?” = Shift + / . Ignore while typing in a field / editing text.\n if (e.key !== '?') return;\n const t = e.target;\n if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;\n if (window.EditorManager && window.EditorManager.getEditing && window.EditorManager.getEditing()) return;\n e.preventDefault();\n toggle();\n });\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/measure-distance.js\">\n/**\n * @fileoverview Figma-style distance measurement overlay.\n *\n * Select a free-move block, hold Ctrl (⌘ on Mac), then hover another free\n * block: an animated overlay draws the gap between the two blocks with px\n * labels — exactly like Figma's measure mode. The selected block gets a solid\n * outline (the reference), the hovered block a dashed marching-ants outline,\n * and the distance is rendered as red measurement lines + value badges.\n *\n * Smart geometry, per axis:\n * • side-by-side → one horizontal gap line\n * • stacked → one vertical gap line\n * • diagonal → both gaps + dotted extension lines (the classic case)\n * • overlapping → four edge-inset distances (left/right/top/bottom)\n *\n * Editor-only chrome: the overlay lives in <body> with data-cs-chrome and is\n * transient (only exists while measuring), so it never reaches export. Works\n * regardless of scroll/zoom because every render reads live getBoundingClientRect.\n *\n * Public API: window.MeasureDistance.{ enable, disable, isActive }.\n * Self-contained — injects its own CSS; the only host edit is the <script> tag.\n */\n(function () {\n // Manager kill-switch (defaults on when the flag is absent).\n if (window.EditorFeatures && window.EditorFeatures.measureDistance === false) return;\n\n window.MeasureDistance = window.MeasureDistance || {};\n\n const SVG_NS = 'http://www.w3.org/2000/svg';\n const TICK = 4; // half-length of an end-cap tick (px)\n const MIN_EDGE = 1; // ignore sub-pixel edge offsets in the overlap case\n // Styling lives in custom-form.css under the \".cs-measure\" section.\n\n // ------------------------------------------------------------------ state\n const DWELL = 250; // ms the modifier must be held before\n // drawing — so Ctrl+C/V/D don't flash it\n let enabled = true;\n let armed = false; // modifier key held\n let dwellTimer = 0; // suppression window (0 = elapsed)\n let overlay = null, svg = null; // DOM handles\n let lastSrc = null, lastTgt = null; // currently drawn pair\n let raf = 0;\n const pointer = { x: 0, y: 0 };\n\n // ------------------------------------------------------------------ helpers\n const modifier = (e) => e.ctrlKey || e.metaKey;\n\n // Restrict to free-positioned blocks (cover page / section / flexible) — the\n // only place free-canvas distance is meaningful. Mirrors inline-editor's\n // isFreeFormBlock.\n const isFree = (b) => !!b && b.classList && b.classList.contains('cs_block_s') &&\n (b.dataset.csInSection === '1' ||\n b.classList.contains('cs-flexible-block') ||\n !!(b.closest && b.closest('[data-cs-cover=\"1\"]')));\n\n const getSource = () => {\n const sel = (window.EditorManager && window.EditorManager.getSelected &&\n window.EditorManager.getSelected()) ||\n document.querySelector('.cs_block_s.cs-selected');\n return isFree(sel) ? sel : null;\n };\n\n const blockUnderPointer = (src) => {\n const el = document.elementFromPoint(pointer.x, pointer.y);\n const block = el && el.closest && el.closest('.cs_block_s');\n if (!block || block === src || !isFree(block)) return null;\n // Don't measure a block against its own ancestor/descendant — that's the\n // chrome of the same thing, not a sibling gap.\n if (src && (src.contains(block) || block.contains(src))) return null;\n return block;\n };\n\n const rectOf = (el) => {\n const r = el.getBoundingClientRect();\n return { left: r.left, top: r.top, right: r.right, bottom: r.bottom,\n w: r.width, h: r.height, cx: r.left + r.width / 2, cy: r.top + r.height / 2 };\n };\n\n const busy = () => (window.EditorManager &&\n ((window.EditorManager.isInteracting && window.EditorManager.isInteracting()) ||\n (window.EditorManager.getEditing && window.EditorManager.getEditing())));\n\n // ------------------------------------------------------------------ drawing\n const line = (x1, y1, x2, y2, cls) => {\n const l = document.createElementNS(SVG_NS, 'line');\n l.setAttribute('x1', x1); l.setAttribute('y1', y1);\n l.setAttribute('x2', x2); l.setAttribute('y2', y2);\n l.setAttribute('pathLength', '1'); // normalise so draw anim works at any length\n l.setAttribute('class', cls);\n svg.appendChild(l);\n };\n const rect = (r, cls) => {\n const el = document.createElementNS(SVG_NS, 'rect');\n el.setAttribute('x', r.left); el.setAttribute('y', r.top);\n el.setAttribute('width', Math.max(0, r.w)); el.setAttribute('height', Math.max(0, r.h));\n el.setAttribute('rx', '2'); el.setAttribute('class', cls);\n svg.appendChild(el);\n };\n const label = (x, y, value) => {\n const d = document.createElement('div');\n d.className = 'cs-measure__label';\n d.style.left = `${x}px`; d.style.top = `${y}px`;\n d.textContent = `${Math.round(value)}px`;\n overlay.appendChild(d);\n };\n\n // A measured span between two parallel edges, with caps and (when the line\n // overshoots a block) dotted extension lines anchoring it to that edge.\n const hMeasure = (x1, x2, yLine, aSpan, bSpan) => {\n line(x1, yLine, x2, yLine, 'cs-measure__line');\n line(x1, yLine - TICK, x1, yLine + TICK, 'cs-measure__cap');\n line(x2, yLine - TICK, x2, yLine + TICK, 'cs-measure__cap');\n // extension: if yLine sits outside a block's vertical span, dot it to the edge\n if (yLine < aSpan[0]) line(x1, aSpan[0], x1, yLine, 'cs-measure__ext');\n else if (yLine > aSpan[1]) line(x1, aSpan[1], x1, yLine, 'cs-measure__ext');\n if (yLine < bSpan[0]) line(x2, bSpan[0], x2, yLine, 'cs-measure__ext');\n else if (yLine > bSpan[1]) line(x2, bSpan[1], x2, yLine, 'cs-measure__ext');\n label((x1 + x2) / 2, yLine, x2 - x1);\n };\n const vMeasure = (y1, y2, xLine, aSpan, bSpan) => {\n line(xLine, y1, xLine, y2, 'cs-measure__line');\n line(xLine - TICK, y1, xLine + TICK, y1, 'cs-measure__cap');\n line(xLine - TICK, y2, xLine + TICK, y2, 'cs-measure__cap');\n if (xLine < aSpan[0]) line(aSpan[0], y1, xLine, y1, 'cs-measure__ext');\n else if (xLine > aSpan[1]) line(aSpan[1], y1, xLine, y1, 'cs-measure__ext');\n if (xLine < bSpan[0]) line(bSpan[0], y2, xLine, y2, 'cs-measure__ext');\n else if (xLine > bSpan[1]) line(bSpan[1], y2, xLine, y2, 'cs-measure__ext');\n label(xLine, (y1 + y2) / 2, y2 - y1);\n };\n\n const measure = (S, T) => {\n // horizontal relationship\n let hGap = null; // {x1,x2, aSpan,bSpan}\n if (T.left >= S.right) hGap = { x1: S.right, x2: T.left, aSpan: [S.top, S.bottom], bSpan: [T.top, T.bottom] };\n else if (T.right <= S.left) hGap = { x1: T.right, x2: S.left, aSpan: [T.top, T.bottom], bSpan: [S.top, S.bottom] };\n // vertical relationship. aSpan must be the horizontal span of the block that\n // owns y1, bSpan the one that owns y2 — so the dotted extension lines anchor\n // to the correct block. (When T is above S, y1 belongs to T, not S.)\n let vGap = null; // {y1,y2, aSpan,bSpan}\n if (T.top >= S.bottom) vGap = { y1: S.bottom, y2: T.top, aSpan: [S.left, S.right], bSpan: [T.left, T.right] };\n else if (T.bottom <= S.top) vGap = { y1: T.bottom, y2: S.top, aSpan: [T.left, T.right], bSpan: [S.left, S.right] };\n\n if (hGap) {\n const yOverlap = vGap ? null : [Math.max(S.top, T.top), Math.min(S.bottom, T.bottom)];\n const yLine = yOverlap ? (yOverlap[0] + yOverlap[1]) / 2 : S.cy; // anchor to source when diagonal\n hMeasure(hGap.x1, hGap.x2, yLine, hGap.aSpan, hGap.bSpan);\n }\n if (vGap) {\n const xOverlap = hGap ? null : [Math.max(S.left, T.left), Math.min(S.right, T.right)];\n const xLine = xOverlap ? (xOverlap[0] + xOverlap[1]) / 2 : S.cx;\n vMeasure(vGap.y1, vGap.y2, xLine, vGap.aSpan, vGap.bSpan);\n }\n\n // Overlapping on both axes → show the four edge-inset distances.\n if (!hGap && !vGap) {\n const yMid = (Math.max(S.top, T.top) + Math.min(S.bottom, T.bottom)) / 2;\n const xMid = (Math.max(S.left, T.left) + Math.min(S.right, T.right)) / 2;\n if (Math.abs(T.left - S.left) >= MIN_EDGE)\n hMeasure(Math.min(S.left, T.left), Math.max(S.left, T.left), yMid, [S.top, S.bottom], [T.top, T.bottom]);\n if (Math.abs(T.right - S.right) >= MIN_EDGE)\n hMeasure(Math.min(S.right, T.right), Math.max(S.right, T.right), yMid, [S.top, S.bottom], [T.top, T.bottom]);\n if (Math.abs(T.top - S.top) >= MIN_EDGE)\n vMeasure(Math.min(S.top, T.top), Math.max(S.top, T.top), xMid, [S.left, S.right], [T.left, T.right]);\n if (Math.abs(T.bottom - S.bottom) >= MIN_EDGE)\n vMeasure(Math.min(S.bottom, T.bottom), Math.max(S.bottom, T.bottom), xMid, [S.left, S.right], [T.left, T.right]);\n }\n };\n\n // ------------------------------------------------------------------ overlay\n const ensureOverlay = () => {\n if (overlay) return;\n overlay = document.createElement('div');\n overlay.className = 'cs-measure';\n overlay.setAttribute('data-cs-chrome', '');\n svg = document.createElementNS(SVG_NS, 'svg');\n svg.setAttribute('class', 'cs-measure__svg');\n overlay.appendChild(svg);\n document.body.appendChild(overlay);\n };\n const clear = () => {\n if (overlay) { overlay.remove(); overlay = null; svg = null; }\n lastSrc = lastTgt = null;\n };\n\n const arm = () => {\n if (armed) return;\n armed = true;\n if (dwellTimer) clearTimeout(dwellTimer);\n dwellTimer = setTimeout(() => { dwellTimer = 0; schedule(); }, DWELL);\n };\n const disarm = () => {\n armed = false;\n if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = 0; }\n clear();\n };\n\n const showHint = (src) => {\n clear();\n ensureOverlay();\n const isMac = /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '');\n const hint = document.createElement('div');\n hint.className = 'cs-measure__hint';\n hint.style.left = `${pointer.x}px`; hint.style.top = `${pointer.y}px`;\n hint.innerHTML = `<b>${isMac ? '⌘' : 'Ctrl'}</b> · hover a block to measure`;\n overlay.appendChild(hint);\n lastSrc = src; lastTgt = 'hint';\n };\n\n // Full rebuild — only called when the measured pair changes (positions are\n // stable while merely hovering, so same-pair moves are a cheap no-op).\n const render = () => {\n raf = 0;\n if (!enabled || !armed) { clear(); return; }\n if (dwellTimer) return; // still inside the suppression window\n if (busy()) { clear(); return; }\n const src = getSource();\n if (!src) { clear(); return; }\n const tgt = blockUnderPointer(src);\n\n if (!tgt) {\n if (lastSrc !== src || lastTgt !== 'hint') showHint(src);\n else if (overlay) { // keep hint glued to the cursor\n const h = overlay.querySelector('.cs-measure__hint');\n if (h) { h.style.left = `${pointer.x}px`; h.style.top = `${pointer.y}px`; }\n }\n return;\n }\n if (src === lastSrc && tgt === lastTgt) return; // already drawn, geometry unchanged\n\n clear();\n ensureOverlay();\n const W = window.innerWidth, H = window.innerHeight;\n svg.setAttribute('width', W); svg.setAttribute('height', H);\n const S = rectOf(src), T = rectOf(tgt);\n rect(S, 'cs-measure__box cs-measure__box--src');\n rect(T, 'cs-measure__box cs-measure__box--tgt');\n measure(S, T);\n lastSrc = src; lastTgt = tgt;\n };\n\n const schedule = () => { if (!raf) raf = requestAnimationFrame(render); };\n\n // ------------------------------------------------------------------ events\n const onKeyDown = (e) => {\n if (!enabled) return;\n if (e.key === 'Escape' && armed) { disarm(); return; }\n if (modifier(e)) arm();\n };\n const onKeyUp = (e) => {\n // Disarm only once no modifier remains held (releasing some other key while\n // Ctrl is still down must not tear the overlay down).\n if (armed && !modifier(e)) disarm();\n };\n const onMove = (e) => {\n pointer.x = e.clientX; pointer.y = e.clientY;\n if (!enabled) return;\n if (modifier(e)) { arm(); schedule(); }\n else if (armed) disarm();\n };\n const onScrollResize = () => { if (armed) { lastTgt = null; schedule(); } };\n const onBlur = () => { if (armed) disarm(); };\n const onDown = () => { if (armed) clear(); }; // a drag/click starts → get out of the way\n\n const init = () => {\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('keyup', onKeyUp, true);\n document.addEventListener('mousemove', onMove, true);\n document.addEventListener('pointerdown', onDown, true);\n window.addEventListener('scroll', onScrollResize, true);\n window.addEventListener('resize', onScrollResize, true);\n window.addEventListener('blur', onBlur);\n };\n\n Object.assign(window.MeasureDistance, {\n enable: () => { enabled = true; },\n disable: () => { enabled = false; armed = false; clear(); },\n isActive: () => !!overlay,\n });\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow/zoom-shortcuts.js\">\n/**\n * @fileoverview Canvas-zoom keyboard shortcuts (iframe side).\n *\n * The canvas zoom is owned by the Angular host (it sets `--editor-zoom` and\n * scales the iframe wrapper). When focus is inside this editor iframe, Ctrl/⌘\n * +/−/0 fire HERE, not on the host — so the browser would do its own page\n * zoom instead of the editor's. This module intercepts those presses, blocks\n * the native zoom (preventDefault), and forwards the intent to the host via the\n * standard postMessage channel; the host applies the editor zoom.\n *\n * The host has a matching keydown handler for when focus is on its own chrome,\n * so the shortcut works no matter where focus sits.\n */\n(function () {\n if (window.EditorFeatures && window.EditorFeatures.zoomShortcuts === false) return;\n\n // Map a Ctrl/⌘ key event to a zoom direction, or null if it isn't one.\n const zoomDir = (e) => {\n if (!e.ctrlKey && !e.metaKey) return null;\n const k = e.key;\n if (k === '+' || k === '=' || e.code === 'NumpadAdd') return 'in';\n if (k === '-' || k === '_' || e.code === 'NumpadSubtract') return 'out';\n if (k === '0' || e.code === 'Numpad0' || e.code === 'Digit0') return 'reset';\n return null;\n };\n\n const onKeyDown = (e) => {\n const dir = zoomDir(e);\n if (!dir) return;\n e.preventDefault(); // stop the browser's own page zoom\n try {\n if (window.parent && window.parent !== window) {\n window.parent.postMessage({ source: 'custom-form-twig', type: 'editor:zoom', dir }, '*');\n }\n } catch (err) { /* cross-origin parent — nothing we can do */ }\n };\n\n // Capture phase so we win before any block-level handler swallows the combo.\n document.addEventListener('keydown', onKeyDown, true);\n})();\n\n<\/script>\n <script data-src=\"./js/flow/brand.js\">\n/**\n * @fileoverview Brand Kit — document-wide font application (iframe side).\n *\n * Listens for the Angular shell's `brand:apply-fonts` message and restyles every\n * text element (`.edit_me`) in the canvas with the chosen brand fonts:\n * - heading blocks → the heading font\n * - everything else → the body font\n *\n * Fonts are written as CONCRETE inline `font-family` (not CSS variables) so the\n * change survives the Twig/PDF export, which only resolves var() fallbacks.\n *\n * Colours are applied per-block from the Style-panel brand swatches (concrete\n * inline too), so nothing here is needed for colour.\n */\n(function () {\n const isHeadingBlock = (block) => {\n if (!block) return false;\n const t = (block.dataset && block.dataset.blockType) || '';\n if (t.indexOf('heading') === 0) return true; // 'heading' / 'heading-two'\n if (block.classList && block.classList.contains('add-heading-two')) return true;\n return !!block.querySelector(':scope > .add-heading-two, :scope > .edit_me.add-heading-two');\n };\n\n const applyFonts = (headingFont, bodyFont) => {\n const root = document.querySelector('.cs_paper') || document.querySelector('.custom-form-design') || document.body;\n if (!root) return;\n root.querySelectorAll('.edit_me').forEach((edit) => {\n const block = edit.closest('.cs_block_s');\n const font = isHeadingBlock(block) || edit.classList.contains('add-heading-two') ? headingFont : bodyFont;\n if (font) edit.style.fontFamily = font;\n });\n };\n\n window.addEventListener('message', (e) => {\n const msg = e.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n if (msg.type === 'brand:apply-fonts') {\n applyFonts(msg.headingFont, msg.bodyFont);\n }\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/layer-system.js\">\n/**\n * @fileoverview Photoshop-style layer system for COVER PAGES only.\n *\n * Stacking is controlled purely by inline `z-index` on each block — never by\n * reordering the DOM — so List sync, Section flow and Group child structure are\n * never disturbed, and the order round-trips through twig export / reload (the\n * inline style is part of the serialized DOM).\n *\n * A block's \"layer siblings\" are the `.cs_block_s` elements that share its\n * immediate parent (cover root, a group, a section/list column, …). Within each\n * such container the siblings stack by z-index; because a positioned container\n * (group/section/list) forms its own stacking context, its children stack WITHIN\n * it — exactly Photoshop's nested-layer behaviour.\n *\n * Talks to the right-panel Layers tab over the existing postMessage bus:\n * panel → iframe : layers:request | layers:op {blockId,op} | layers:reorder\n * {blockId,targetId,position}\n * iframe → panel : layers:tree {data:{pageId, selectedId, nodes}}\n * Selection itself reuses the existing `block:select` message.\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const FC = window.FlowCanvas;\n const EM = () => window.EditorManager;\n\n const COVER_SEL = '[data-cs-cover=\"1\"]';\n const isCoverBlock = (b) => !!b?.closest?.(COVER_SEL);\n\n const getZ = (el) => {\n const z = parseInt(el.style.zIndex || '', 10);\n return Number.isNaN(z) ? null : z;\n };\n\n // Current visual (paint) order of a set of sibling blocks, bottom → top.\n // No explicit z-index paints below any explicit one (treated as 0); ties break\n // by DOM order.\n const visualOrder = (sibs) => {\n const idx = new Map(sibs.map((el, i) => [el, i]));\n return sibs.slice().sort((a, b) => {\n const ea = getZ(a) ?? 0;\n const eb = getZ(b) ?? 0;\n if (ea !== eb) return ea - eb;\n return idx.get(a) - idx.get(b);\n });\n };\n\n // Blocks that share `block`'s immediate parent — the set we restack.\n const zSiblings = (block) =>\n Array.from(block.parentElement?.children || []).filter((c) => c.classList?.contains('cs_block_s'));\n\n // Immediate nested layer-children of a block (those whose nearest `.cs_block_s`\n // ancestor is this block) — used to build the tree for groups/sections/lists.\n const childBlocksOf = (el) =>\n Array.from(el.querySelectorAll('.cs_block_s')).filter(\n (b) => b.parentElement?.closest('.cs_block_s') === el\n );\n\n // Top-level blocks of a cover page (its direct .cs_block_s children).\n const topBlocksOf = (cover) =>\n Array.from(cover.children).filter((c) => c.classList?.contains('cs_block_s'));\n\n const applyOrder = (ordered) => ordered.forEach((el, i) => { el.style.zIndex = String(i + 1); });\n\n // Reorder one block among its siblings and re-stamp dense z-index 1..N.\n const op = (block, kind) => {\n const sibs = zSiblings(block);\n if (sibs.length < 2) return;\n const order = visualOrder(sibs);\n const i = order.indexOf(block);\n if (i < 0) return;\n if (kind === 'front') { order.splice(i, 1); order.push(block); }\n else if (kind === 'back') { order.splice(i, 1); order.unshift(block); }\n else if (kind === 'forward' && i < order.length - 1) { order.splice(i, 1); order.splice(i + 1, 0, block); }\n else if (kind === 'backward' && i > 0) { order.splice(i, 1); order.splice(i - 1, 0, block); }\n else return;\n applyOrder(order);\n };\n\n // Drag-reorder: move `block` next to `target` (same parent only). `position`\n // is in TREE order (top layer first), but `order` is bottom→top — so tree\n // 'before' (ABOVE target = higher layer) means insert AFTER target here, and\n // tree 'after' means insert BEFORE. (Getting this inverted is why a dropped\n // layer used to snap back to the end.)\n const reorderTo = (block, target, position) => {\n if (!block || !target || block === target) return;\n if (block.parentElement !== target.parentElement) return; // same container only\n const order = visualOrder(zSiblings(block));\n const from = order.indexOf(block);\n if (from < 0) return;\n order.splice(from, 1);\n let to = order.indexOf(target);\n if (to < 0) return;\n if (position === 'before') to += 1; // tree-above → after in bottom→top order\n order.splice(to, 0, block);\n applyOrder(order);\n };\n\n /* ----------------------------- tree → panel ----------------------------- */\n\n const labelOf = (b) =>\n b.getAttribute('custom-name') || b.dataset.blockType || b.getAttribute('data') || 'Block';\n const typeOf = (b) =>\n b.classList.contains('cs-group-block') ? 'group'\n : b.querySelector(':scope > .section-container-content') ? 'section'\n : b.classList.contains('cs-synclist__col') || b.querySelector(':scope .cs-synclist') ? 'list'\n : (b.dataset.blockType || 'block');\n\n const ensureId = (b) => {\n if (!b.id) (FC.assignNodeId ? FC.assignNodeId(b, 'block') : (b.id = 'block_' + Math.random().toString(16).slice(2)));\n return b.id;\n };\n\n const imageThumb = (b) => {\n const img = b.querySelector('.image-container img, img');\n return img?.getAttribute('src') || null;\n };\n\n const buildNode = (b, selectedId) => {\n ensureId(b);\n const kids = visualOrder(childBlocksOf(b)).reverse(); // top layer first\n return {\n id: b.id,\n label: labelOf(b),\n type: typeOf(b),\n selected: b.id === selectedId,\n hidden: b.dataset.csHidden === '1',\n locked: b.dataset.csLocked === '1',\n thumb: imageThumb(b),\n hasChildren: kids.length > 0,\n children: kids.map((c) => buildNode(c, selectedId)),\n };\n };\n\n // Photoshop \"eye\" — toggle a block's visibility. Use inline `!important` so it\n // beats the cover/group `display:block !important` rules, and round-trips\n // through export/reload (inline style is part of the serialized DOM).\n const toggleVisibility = (b) => {\n if (b.dataset.csHidden === '1') {\n delete b.dataset.csHidden;\n b.style.removeProperty('display');\n } else {\n b.dataset.csHidden = '1';\n b.style.setProperty('display', 'none', 'important');\n }\n };\n\n const toggleLock = (b) => {\n if (b.dataset.csLocked === '1') delete b.dataset.csLocked;\n else b.dataset.csLocked = '1';\n };\n\n const activeCover = () => {\n const sel = EM()?.getSelected?.() || EM()?.getEditing?.();\n return sel?.closest?.(COVER_SEL) || document.querySelector(COVER_SEL) || null;\n };\n\n const sendTree = () => {\n const cover = activeCover();\n const sel = EM()?.getSelected?.() || EM()?.getEditing?.();\n const selId = sel && cover && cover.contains(sel) ? sel.id || null : null;\n const tops = cover ? visualOrder(topBlocksOf(cover)).reverse() : [];\n const nodes = tops.map((b) => buildNode(b, selId));\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'layers:tree',\n data: { pageId: cover?.id || null, selectedId: selId, nodes },\n }, '*');\n };\n\n // Debounced refresh on any structural / selection / style change on the board.\n let scheduled = false;\n const scheduleSend = () => {\n if (scheduled) return;\n scheduled = true;\n requestAnimationFrame(() => { scheduled = false; sendTree(); });\n };\n\n /* ----------------------------- wiring ----------------------------- */\n\n window.addEventListener('message', (e) => {\n const msg = e.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n if (msg.type === 'layers:request') {\n sendTree();\n } else if (msg.type === 'layers:op' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { op(b, msg.op); sendTree(); }\n } else if (msg.type === 'layers:reorder' && msg.blockId && msg.targetId) {\n const b = document.getElementById(msg.blockId);\n const t = document.getElementById(msg.targetId);\n if (b && t && isCoverBlock(b)) { reorderTo(b, t, msg.position); sendTree(); }\n } else if (msg.type === 'layers:visibility' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { toggleVisibility(b); sendTree(); }\n } else if (msg.type === 'layers:rename' && msg.blockId && typeof msg.name === 'string') {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) {\n b.setAttribute('custom-name', msg.name);\n // Keep the on-canvas badge label in sync if the block is selected.\n const lbl = b.querySelector(':scope > .cs-block-badge .cs-block-badge__label');\n if (lbl) lbl.textContent = msg.name;\n sendTree();\n }\n } else if (msg.type === 'layers:lock' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { toggleLock(b); sendTree(); }\n } else if (msg.type === 'layers:duplicate' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) { FC.duplicateBlock?.(b); sendTree(); }\n } else if (msg.type === 'layers:delete' && msg.blockId) {\n const b = document.getElementById(msg.blockId);\n if (b && isCoverBlock(b)) {\n if (FC.deleteBlock) FC.deleteBlock(b); else b.remove();\n sendTree();\n }\n }\n });\n\n const init = () => {\n const board = document.querySelector('.cs_paper') || document.querySelector('.custom-form-design');\n if (board) {\n const obs = new MutationObserver(scheduleSend);\n obs.observe(board, {\n attributes: true,\n attributeFilter: ['class', 'style'],\n childList: true,\n subtree: true,\n });\n }\n sendTree();\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/drop-zones.js\">\n/**\n * @fileoverview Drop-zone detection and visual indicator.\n *\n * Decides where a dragged block will land given the pointer position, and\n * shows a thin blue line indicating the drop target.\n *\n * Exposes:\n * window.FlowCanvas.findDropTarget(doc, canvas, clientX, clientY) → { target, indicator } | null\n * window.FlowCanvas.showIndicator(hint)\n * window.FlowCanvas.hideIndicator()\n *\n * Drop target kinds:\n * between-rows — new row at gap\n * col-edge — new column inside an existing row\n * in-col — into an existing column (between blocks or empty col)\n * in-section — inside a section's content area (free placement)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n const cfg = (window.CanvasConfig && window.CanvasConfig.dropZone) || {};\n const ROW_EDGE_GAP = cfg.rowEdgeGap ?? 12;\n const COL_EDGE_GAP = cfg.colEdgeGap ?? 24;\n\n // A \"free canvas\" is any root that positions its children absolutely:\n // a flexible container, or a cover page (`.cs_page[data-cs-cover]`). Drops\n // into one are placed by cursor position, not woven into row/col flow.\n const isFreeCanvas = (el) =>\n !!el && (el.classList?.contains('cs-flexible-content') || el.matches?.('[data-cs-cover=\"1\"]'));\n\n // ---------------------------------------------------------------------------\n // Section drop target — sections now act as nested row/col flow canvases\n // (same model as the document root). Returning null falls through to the\n // standard row/col logic, but with the section's content area passed in\n // as the scoped root via `findDropTargetIn`.\n //\n // Returns the innermost section content element under the cursor, or null\n // when the cursor isn't over any section.\n // ---------------------------------------------------------------------------\n const findSectionUnderCursor = (canvas, clientX, clientY) => {\n const sections = Array.from(canvas.querySelectorAll('.section-container-content, .cs-flexible-content'));\n // Walk in reverse so an inner section wins over an outer one when nested.\n for (let i = sections.length - 1; i >= 0; i--) {\n const section = sections[i];\n const rect = section.getBoundingClientRect();\n if (clientX >= rect.left && clientX <= rect.right &&\n clientY >= rect.top && clientY <= rect.bottom) {\n return section;\n }\n }\n return null;\n };\n\n // ---------------------------------------------------------------------------\n // In-column target (between blocks or empty col)\n // ---------------------------------------------------------------------------\n const findInColTarget = (col, clientY) => {\n const blocks = Array.from(col.children).filter(c => !c.matches('.cs-line-divider'));\n const rect = col.getBoundingClientRect();\n\n if (blocks.length === 0) {\n return {\n target: { kind: 'in-col', col, beforeBlock: null },\n indicator: { type: 'horizontal', top: rect.top + rect.height / 2 - 1, left: rect.left, right: rect.right }\n };\n }\n\n for (let i = 0; i < blocks.length; i++) {\n const bRect = blocks[i].getBoundingClientRect();\n const mid = (bRect.top + bRect.bottom) / 2;\n if (clientY < mid) {\n return {\n target: { kind: 'in-col', col, beforeBlock: blocks[i] },\n indicator: { type: 'horizontal', top: bRect.top - 3, left: rect.left, right: rect.right }\n };\n }\n }\n\n const lastRect = blocks[blocks.length - 1].getBoundingClientRect();\n return {\n target: { kind: 'in-col', col, beforeBlock: null },\n indicator: { type: 'horizontal', top: lastRect.bottom + 1, left: rect.left, right: rect.right }\n };\n };\n\n // ---------------------------------------------------------------------------\n // Column-level routing inside a row\n // ---------------------------------------------------------------------------\n const findColTarget = (row, clientX, clientY) => {\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n if (cols.length === 0) {\n const rect = row.getBoundingClientRect();\n return {\n target: { kind: 'col-edge', row, beforeCol: null },\n indicator: { type: 'vertical', left: rect.left, top: rect.top, bottom: rect.bottom }\n };\n }\n\n const firstRect = cols[0].getBoundingClientRect();\n if (clientX < firstRect.left + COL_EDGE_GAP) {\n return {\n target: { kind: 'col-edge', row, beforeCol: cols[0] },\n indicator: { type: 'vertical', left: firstRect.left - 4, top: firstRect.top, bottom: firstRect.bottom }\n };\n }\n\n const lastRect = cols[cols.length - 1].getBoundingClientRect();\n if (clientX > lastRect.right - COL_EDGE_GAP) {\n return {\n target: { kind: 'col-edge', row, beforeCol: null },\n indicator: { type: 'vertical', left: lastRect.right + 1, top: lastRect.top, bottom: lastRect.bottom }\n };\n }\n\n for (let i = 0; i < cols.length; i++) {\n const col = cols[i];\n const rect = col.getBoundingClientRect();\n if (clientX >= rect.left && clientX <= rect.right) {\n if (i < cols.length - 1) {\n const nextRect = cols[i + 1].getBoundingClientRect();\n if (clientX > rect.right - COL_EDGE_GAP && clientX < nextRect.left + COL_EDGE_GAP) {\n return {\n target: { kind: 'col-edge', row, beforeCol: cols[i + 1] },\n indicator: { type: 'vertical', left: (rect.right + nextRect.left) / 2 - 1, top: rect.top, bottom: rect.bottom }\n };\n }\n }\n return findInColTarget(col, clientY);\n }\n }\n return findInColTarget(cols[cols.length - 1], clientY);\n };\n\n // ---------------------------------------------------------------------------\n // Top-level: row-level routing\n //\n // When the cursor is over a section's content area we treat that area as\n // a nested doc: same row/col flow detection, just scoped to the section.\n // The dropped block becomes a real child of the section's row tree, so\n // the section's height grows with content (no more absolute positioning\n // that left tables hanging outside the section's box).\n // ---------------------------------------------------------------------------\n const findDropTarget = (doc, canvas, clientX, clientY, blockType) => {\n const section = findSectionUnderCursor(canvas, clientX, clientY);\n let root = section || doc;\n let isHeaderFooter = false;\n\n if (!section && root.classList.contains('cs_margin')) {\n // Check if cursor is over header or footer\n const header = root.querySelector(':scope > .cs-page-header');\n const footer = root.querySelector(':scope > .cs-page-footer');\n const main = root.querySelector(':scope > .body-main-content');\n\n if (header) {\n const headerRect = header.getBoundingClientRect();\n if (clientY >= headerRect.top && clientY <= headerRect.bottom) {\n root = header;\n isHeaderFooter = true;\n } else if (footer) {\n const footerRect = footer.getBoundingClientRect();\n if (clientY >= footerRect.top && clientY <= footerRect.bottom) {\n root = footer;\n isHeaderFooter = true;\n } else if (main) {\n root = main;\n }\n } else if (main) {\n root = main;\n }\n } else if (main) {\n root = main;\n }\n }\n\n // Header/footer are themselves rows with columns as direct children\n // Main content area contains rows as direct children\n let rows = [];\n if (isHeaderFooter) {\n // Header/footer is a single row, so use it directly for column targeting\n rows = [root];\n } else {\n rows = Array.from(root.querySelectorAll(':scope > .row-item'));\n }\n\n if (rows.length === 0) {\n const rootRect = root.getBoundingClientRect();\n // Don't show indicator for flexible containers, but show flexible bounds highlight\n const isFlexible = isFreeCanvas(root);\n let indicator = null;\n if (!isFlexible) {\n indicator = { type: 'horizontal', top: rootRect.top + 4, left: rootRect.left, right: rootRect.right };\n } else {\n // Show flexible container bounds as a subtle highlight\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n }\n return {\n target: { kind: 'between-rows', beforeRow: null, parent: root },\n indicator: indicator\n };\n }\n\n // For flexible containers, never show line indicator - only show bounds highlight\n const isFlexible = isFreeCanvas(root);\n const rootRect = root.getBoundingClientRect();\n\n const firstRect = rows[0].getBoundingClientRect();\n if (clientY < firstRect.top + ROW_EDGE_GAP) {\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: firstRect.top - 4, left: firstRect.left, right: firstRect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: rows[0], parent: root },\n indicator: indicator\n };\n }\n\n const lastRect = rows[rows.length - 1].getBoundingClientRect();\n if (clientY > lastRect.bottom - ROW_EDGE_GAP) {\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: lastRect.bottom + 4, left: lastRect.left, right: lastRect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: null, parent: root },\n indicator: indicator\n };\n }\n\n for (let i = 0; i < rows.length; i++) {\n const row = rows[i];\n const rect = row.getBoundingClientRect();\n if (i < rows.length - 1) {\n const nextRect = rows[i + 1].getBoundingClientRect();\n if (clientY > rect.bottom - ROW_EDGE_GAP && clientY < nextRect.top + ROW_EDGE_GAP) {\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: (rect.bottom + nextRect.top) / 2 - 2, left: rect.left, right: rect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: rows[i + 1], parent: root },\n indicator: indicator\n };\n }\n }\n if (clientY >= rect.top && clientY <= rect.bottom) {\n return findColTarget(row, clientX, clientY);\n }\n }\n\n let indicator = null;\n if (isFlexible) {\n indicator = {\n type: 'flexible-highlight',\n flexibleBounds: {\n left: rootRect.left,\n top: rootRect.top,\n width: rootRect.width,\n height: rootRect.height\n }\n };\n } else {\n indicator = { type: 'horizontal', top: lastRect.bottom + 4, left: lastRect.left, right: lastRect.right };\n }\n return {\n target: { kind: 'between-rows', beforeRow: null, parent: root },\n indicator: indicator\n };\n };\n\n // ---------------------------------------------------------------------------\n // Visual indicator (single shared element)\n // ---------------------------------------------------------------------------\n let indicatorEl = null;\n\n let flexibleHighlightEl = null;\n\n const showIndicator = (hint) => {\n if (!hint) {\n if (indicatorEl) {\n indicatorEl.style.display = 'none';\n indicatorEl.style.visibility = 'hidden';\n }\n if (flexibleHighlightEl) {\n flexibleHighlightEl.style.display = 'none';\n flexibleHighlightEl.style.visibility = 'hidden';\n }\n return;\n }\n\n if (!indicatorEl) {\n indicatorEl = document.createElement('div');\n indicatorEl.className = 'cs-drop-indicator';\n indicatorEl.style.zIndex = '9999';\n document.body.appendChild(indicatorEl);\n }\n indicatorEl.classList.remove('cs-drop-indicator--horizontal', 'cs-drop-indicator--vertical');\n\n // For flexible highlight, hide the line indicator\n if (hint.type === 'flexible-highlight') {\n indicatorEl.style.display = 'none';\n indicatorEl.style.visibility = 'hidden';\n } else if (hint.type === 'horizontal') {\n indicatorEl.style.display = 'block';\n indicatorEl.style.visibility = 'visible';\n indicatorEl.classList.add('cs-drop-indicator--horizontal');\n indicatorEl.style.top = `${hint.top}px`;\n indicatorEl.style.left = `${hint.left}px`;\n indicatorEl.style.width = `${hint.right - hint.left}px`;\n indicatorEl.style.height = '1px';\n indicatorEl.style.overflow = 'hidden';\n } else {\n indicatorEl.style.display = 'block';\n indicatorEl.style.visibility = 'visible';\n indicatorEl.classList.add('cs-drop-indicator--vertical');\n indicatorEl.style.left = `${hint.left}px`;\n indicatorEl.style.top = `${hint.top}px`;\n indicatorEl.style.height = `${hint.bottom - hint.top}px`;\n indicatorEl.style.width = '1px';\n indicatorEl.style.overflow = 'hidden';\n }\n\n // Show subtle highlight for flexible container bounds if specified\n if (hint.flexibleBounds) {\n if (!flexibleHighlightEl) {\n flexibleHighlightEl = document.createElement('div');\n flexibleHighlightEl.className = 'cs-flexible-highlight';\n flexibleHighlightEl.style.position = 'fixed';\n flexibleHighlightEl.style.pointerEvents = 'none';\n flexibleHighlightEl.style.backgroundColor = 'rgba(92, 92, 255, 0.05)';\n flexibleHighlightEl.style.border = '1px solid rgba(92, 92, 255, 0.2)';\n flexibleHighlightEl.style.zIndex = '9998';\n flexibleHighlightEl.style.visibility = 'hidden';\n document.body.appendChild(flexibleHighlightEl);\n }\n const bounds = hint.flexibleBounds;\n flexibleHighlightEl.style.display = 'block';\n flexibleHighlightEl.style.visibility = 'visible';\n flexibleHighlightEl.style.left = `${bounds.left}px`;\n flexibleHighlightEl.style.top = `${bounds.top}px`;\n flexibleHighlightEl.style.width = `${bounds.width}px`;\n flexibleHighlightEl.style.height = `${bounds.height}px`;\n } else if (flexibleHighlightEl) {\n flexibleHighlightEl.style.display = 'none';\n flexibleHighlightEl.style.visibility = 'hidden';\n }\n };\n\n const hideIndicator = () => {\n if (indicatorEl) {\n indicatorEl.style.display = 'none';\n indicatorEl.style.visibility = 'hidden';\n }\n if (flexibleHighlightEl) {\n flexibleHighlightEl.style.display = 'none';\n flexibleHighlightEl.style.visibility = 'hidden';\n }\n };\n\n Object.assign(window.FlowCanvas, {\n findDropTarget,\n showIndicator,\n hideIndicator,\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/col-resize.js\">\n/**\n * @fileoverview Column resize via draggable divider.\n *\n * Attaches pointer handlers to the canvas (capture phase, so they run before\n * inline-editor.js's bubble-phase handlers). Drag a .cs-line-divider to resize\n * the two adjacent columns; their combined width is preserved.\n *\n * Exposes:\n * window.FlowCanvas.initColResize(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const COL_MIN_WIDTH = (window.CanvasConfig?.column?.minWidth) ?? 60;\n\n window.FlowCanvas.initColResize = function (canvas) {\n let colResize = null;\n\n canvas.addEventListener('pointerdown', (event) => {\n const divider = event.target.closest?.('.cs-line-divider');\n if (!divider) return;\n\n event.preventDefault();\n event.stopPropagation();\n event.stopImmediatePropagation();\n\n const prevCol = divider.previousElementSibling;\n const nextCol = divider.nextElementSibling;\n if (!prevCol || !nextCol || !prevCol.matches('.col-item') || !nextCol.matches('.col-item')) return;\n\n const prevRect = prevCol.getBoundingClientRect();\n const nextRect = nextCol.getBoundingClientRect();\n const totalWidth = prevRect.width + nextRect.width;\n const startX = event.clientX;\n const prevStartWidth = prevRect.width;\n\n const row = divider.closest('.row-item');\n if (row) {\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n cols.forEach(c => c.dataset.startWidth = c.getBoundingClientRect().width);\n cols.forEach(c => c.style.flex = `${c.dataset.startWidth} 0 0`);\n }\n\n divider.classList.add('cs-line-divider--active');\n try { divider.setPointerCapture?.(event.pointerId); } catch (e) { }\n\n colResize = { prevCol, nextCol, totalWidth, startX, prevStartWidth, divider, pointerId: event.pointerId };\n }, true);\n\n canvas.addEventListener('pointermove', (event) => {\n if (!colResize) return;\n const { prevCol, nextCol, totalWidth, startX, prevStartWidth } = colResize;\n const dx = event.clientX - startX;\n\n const prevW = Math.max(COL_MIN_WIDTH, Math.min(totalWidth - COL_MIN_WIDTH, prevStartWidth + dx));\n const nextW = totalWidth - prevW;\n\n prevCol.style.flex = `${prevW} 0 0`;\n nextCol.style.flex = `${nextW} 0 0`;\n }, true);\n\n const endResize = () => {\n if (!colResize) return;\n colResize.divider.classList.remove('cs-line-divider--active');\n try { colResize.divider.releasePointerCapture?.(colResize.pointerId); } catch (e) { }\n colResize = null;\n };\n canvas.addEventListener('pointerup', endResize, true);\n canvas.addEventListener('pointercancel', endResize, true);\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/section-canvas.js\">\n/**\n * @fileoverview Section content area — one-time migration.\n *\n * Sections used to render as absolute-positioning mini-canvases (every\n * dropped child got `position: absolute`). They now render as row/col\n * flow containers like the doc root, so the section height grows\n * naturally as content stretches (eg. a table picking up more rows\n * from a {% for %} loop). Drop placement is handled centrally in\n * `drop-zones.js` + `row-col-builder.js`.\n *\n * This file now only contains a startup migration: any old block left\n * over with `position: absolute` inside a section is rehomed into a\n * fresh row/col pair and stripped of its inline coordinates. Without\n * this, previously-saved documents would render with their old\n * absolute layout sticking out of the new flow box.\n *\n * Exposes: window.FlowCanvas.migrateLegacySectionLayouts()\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const makeRow = () => {\n if (typeof window.FlowCanvas.makeRow === 'function') {\n return window.FlowCanvas.makeRow();\n }\n const row = document.createElement('div');\n row.className = 'row-item';\n window.FlowCanvas.assignNodeId?.(row, 'row');\n return row;\n };\n const makeCol = () => {\n if (typeof window.FlowCanvas.makeCol === 'function') {\n return window.FlowCanvas.makeCol();\n }\n const col = document.createElement('div');\n col.className = 'col-item';\n col.style.flex = '1 1 0';\n window.FlowCanvas.assignNodeId?.(col, 'col');\n return col;\n };\n\n const stripAbsolute = (block) => {\n block.style.position = '';\n block.style.left = '';\n block.style.top = '';\n block.style.width = '';\n block.style.maxWidth = '';\n delete block.dataset.csInSection;\n };\n\n window.FlowCanvas.migrateLegacySectionLayouts = function () {\n document.querySelectorAll('.section-container-content').forEach((section) => {\n // Clear any leftover minHeight/position from the absolute era — flow\n // layout sizes the section by content alone.\n section.style.position = '';\n section.style.minHeight = '';\n section.style.height = '';\n\n // The outer .cs_block_s wrapper used to store a fixed height back\n // when sections rendered as absolute mini-canvases (so the user's\n // last manual resize was preserved). With flow layout the wrapper\n // must size with its child rows — strip any inline height/min-\n // height so the table can push the section down naturally.\n const wrapper = section.closest('.cs_block_s');\n if (wrapper) {\n wrapper.style.height = '';\n wrapper.style.minHeight = '';\n }\n\n // Pull every legacy absolute child, sort by visual top so the\n // resulting flow preserves the user's vertical intent, then rebuild\n // as one block per row inside the section.\n const legacy = Array.from(section.children).filter((c) => {\n return c.classList?.contains('cs_block_s') &&\n (c.dataset?.csInSection === '1' || c.style?.position === 'absolute');\n });\n if (!legacy.length) return;\n\n legacy.sort((a, b) => (parseFloat(a.style.top) || 0) - (parseFloat(b.style.top) || 0));\n legacy.forEach((block) => {\n stripAbsolute(block);\n const row = makeRow();\n const col = makeCol();\n col.appendChild(block);\n row.appendChild(col);\n section.appendChild(row);\n });\n });\n };\n\n // Run once at startup; the cleanup observer (initCleanupObserver) handles\n // ongoing structural maintenance.\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', window.FlowCanvas.migrateLegacySectionLayouts);\n } else {\n window.FlowCanvas.migrateLegacySectionLayouts();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/cleanup-observer.js\">\n/**\n * @fileoverview Auto-cleanup of empty columns and rows.\n *\n * When a block is removed (Delete key, postMessage, manual remove, etc.),\n * walk the document and:\n * - Remove any column with no block content.\n * - Redistribute remaining columns' flex so survivors reclaim the freed width.\n * - Remove any row with no columns.\n *\n * Exposes:\n * window.FlowCanvas.cleanupEmpty(doc) — run cleanup pass (idempotent)\n * window.FlowCanvas.initCleanupObserver(doc) — start watching for removals\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n let running = false;\n\n const colHasContent = (col) => {\n return !!col.querySelector('.cs_block_s, .canvas-block');\n };\n\n // Clean every flow root in the tree: the doc itself plus each\n // .section-container-content (sections now act as nested flow\n // canvases, so they accumulate empty rows/cols the same way).\n const cleanupOneRoot = (root) => {\n let changed = false;\n const rows = Array.from(root.querySelectorAll(':scope > .row-item'));\n for (const row of rows) {\n if (row.matches('.cs-page-header, .cs-page-footer')) continue;\n\n const cols = Array.from(row.querySelectorAll(':scope > .col-item'));\n let removedAny = false;\n\n // Remove empty columns\n for (const col of cols) {\n if (!colHasContent(col)) {\n col.remove();\n removedAny = true;\n changed = true;\n }\n }\n\n // If columns were removed, rebuild dividers and flex layout\n if (removedAny) {\n window.FlowCanvas.rebuildDividers?.(row);\n window.FlowCanvas.resetColFlex?.(row);\n }\n\n // After cleanup, remove orphaned dividers (shouldn't happen but be safe)\n const remainingCols = row.querySelectorAll(':scope > .col-item');\n if (remainingCols.length === 0) {\n // No columns left - remove all dividers and the row\n row.querySelectorAll(':scope > .cs-line-divider').forEach(d => d.remove());\n row.remove();\n changed = true;\n } else if (removedAny) {\n // Double-check that divider count matches column count (n-1 dividers for n columns)\n const dividerCount = row.querySelectorAll(':scope > .cs-line-divider').length;\n const columnCount = remainingCols.length;\n const expectedDividerCount = Math.max(0, columnCount - 1);\n if (dividerCount !== expectedDividerCount) {\n // Divider count mismatch - rebuild again\n window.FlowCanvas.rebuildDividers?.(row);\n changed = true;\n }\n }\n }\n return changed;\n };\n\n const cleanupEmpty = (doc) => {\n if (running) return false;\n running = true;\n let changed = false;\n try {\n changed = cleanupOneRoot(doc) || changed;\n // Every flow root in the tree needs its own pass. Rows live directly\n // under a `.cs_margin` page wrapper, so when cleanup is invoked with the\n // canvas (`.custom-form-design`) as `doc` — as the Delete-key / badge\n // path does via getCanvas() — `cleanupOneRoot(doc)` finds no `:scope >\n // .row-item` and top-level empty columns would survive (showing the\n // \"Drop block here\" placeholder). Include `.cs_margin` here so that path\n // reaches them too. (When `doc` is already a `.cs_margin`, querySelectorAll\n // only matches descendants, so there's no double pass.)\n doc.querySelectorAll('.cs_margin, .body-main-content, .section-container-content').forEach((container) => {\n changed = cleanupOneRoot(container) || changed;\n });\n } finally {\n running = false;\n }\n return changed;\n };\n\n const initCleanupObserver = (doc) => {\n const observer = new MutationObserver((mutations) => {\n let blockRemoved = false;\n for (const m of mutations) {\n if (m.type !== 'childList') continue;\n for (const node of m.removedNodes) {\n if (node.nodeType !== 1) continue;\n if (node.matches?.('.cs_block_s, .canvas-block') ||\n node.querySelector?.('.cs_block_s, .canvas-block')) {\n blockRemoved = true;\n break;\n }\n }\n if (blockRemoved) break;\n }\n if (blockRemoved) cleanupEmpty(doc);\n });\n observer.observe(doc, { childList: true, subtree: true });\n return observer;\n };\n\n Object.assign(window.FlowCanvas, {\n cleanupEmpty,\n initCleanupObserver,\n });\n})();\n\n<\/script>\n <script data-src=\"./js/flow/block-reorder.js\">\n/**\n * @fileoverview Internal block drag-and-drop using pointer events.\n *\n * Why not HTML5 native drag? Native drag conflicts with Froala / inline-editor\n * pointerdown handlers on text content and has quirky drag-image behavior. We\n * use raw pointer events on dedicated chrome handles for reliability.\n *\n * UX:\n * - Every top-level flow block gets a `.cs-block-grip` (⋮⋮) handle in its\n * top-left corner (visible on hover).\n * - Mouse-down on the grip or selected-state `.cs-block-badge` starts a\n * tracked drag. The block goes 40%\n * transparent and we show the same blue drop indicator used by sidebar\n * drops (powered by drop-zones.js).\n * - On pointerup, the block is detached and re-inserted at the computed\n * drop target. Cleanup observer prunes empty columns.\n *\n * Supports all four drop zone kinds (between-rows, col-edge, in-col, in-section).\n *\n * Exposes:\n * window.FlowCanvas.initBlockReorder(canvas, doc)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const isFlowBlock = (el) => {\n return el && el.matches?.('.cs_block_s, .canvas-block') &&\n el.closest('.cs-flow-canvas') &&\n !el.dataset.csInSection &&\n el.parentElement?.matches?.('.col-item');\n };\n\n const ensureGrip = (block) => {\n if (block.querySelector(':scope > .cs-block-grip')) return;\n const grip = document.createElement('div');\n grip.className = 'cs-block-grip';\n grip.setAttribute('data-cs-chrome', '');\n grip.setAttribute('title', 'Drag to reorder');\n // 6-dot grip pattern — the universal \"drag to move\" affordance.\n grip.innerHTML = `\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"currentColor\" aria-hidden=\"true\">\n <circle cx=\"4\" cy=\"3\" r=\"1.4\"/>\n <circle cx=\"10\" cy=\"3\" r=\"1.4\"/>\n <circle cx=\"4\" cy=\"7\" r=\"1.4\"/>\n <circle cx=\"10\" cy=\"7\" r=\"1.4\"/>\n <circle cx=\"4\" cy=\"11\" r=\"1.4\"/>\n <circle cx=\"10\" cy=\"11\" r=\"1.4\"/>\n </svg>`;\n block.appendChild(grip);\n };\n\n const ensureGripsOnAll = (doc) => {\n doc.querySelectorAll('.col-item > .cs_block_s, .col-item > .canvas-block').forEach(ensureGrip);\n };\n\n const findReorderHandle = (target) => {\n // Badge action buttons (move/duplicate/delete) live inside the badge but\n // are clicks, not drag handles — never start a reorder on them.\n if (target?.closest?.('[data-cs-action]')) return null;\n return target?.closest?.('.cs-block-grip, .cs-block-badge') || null;\n };\n\n window.FlowCanvas.initBlockReorder = function (canvas, doc) {\n ensureGripsOnAll(doc);\n const observer = new MutationObserver(() => ensureGripsOnAll(doc));\n observer.observe(doc, { childList: true, subtree: true });\n\n const FC = window.FlowCanvas;\n let drag = null; // { block, grip, pointerId }\n\n // ---- pointerdown on a reorder handle ----\n canvas.addEventListener('pointerdown', (event) => {\n const handle = findReorderHandle(event.target);\n if (!handle) return;\n const block = handle.closest?.('.cs_block_s, .canvas-block');\n if (!block || !isFlowBlock(block)) return;\n\n // Lone block on the page → nowhere to drop. Don't start a drag (and don't\n // swallow the event) so no pointless blue drop-indicator line appears.\n if (FC.canReorder && !FC.canReorder(block)) return;\n\n event.preventDefault();\n event.stopPropagation();\n\n drag = { block, grip: handle, pointerId: event.pointerId };\n block.classList.add('cs-block--dragging');\n handle.setPointerCapture?.(event.pointerId);\n canvas.style.cursor = 'grabbing';\n }, true);\n\n // ---- pointermove: compute drop target and show indicator ----\n canvas.addEventListener('pointermove', (event) => {\n if (!drag) return;\n const result = FC.findDropTarget?.(doc, canvas, event.clientX, event.clientY);\n if (result) {\n FC.showIndicator?.(result.indicator);\n canvas._pendingReorderTarget = result.target;\n } else {\n FC.hideIndicator?.();\n canvas._pendingReorderTarget = null;\n }\n });\n\n // ---- pointerup: place block at target ----\n const finishDrag = (event) => {\n if (!drag) return;\n const { block, grip, pointerId } = drag;\n try { grip.releasePointerCapture?.(pointerId); } catch (e) { }\n block.classList.remove('cs-block--dragging');\n canvas.style.cursor = '';\n FC.hideIndicator?.();\n\n const target = canvas._pendingReorderTarget;\n canvas._pendingReorderTarget = null;\n drag = null;\n\n if (!target) return;\n\n // Detach from old parent, reinsert at new location.\n block.remove();\n FC.placeBlock?.(doc, block, target);\n };\n\n canvas.addEventListener('pointerup', finishDrag);\n canvas.addEventListener('pointercancel', finishDrag);\n\n // ---- cancel on Escape ----\n document.addEventListener('keydown', (event) => {\n if (event.key !== 'Escape' || !drag) return;\n drag.block.classList.remove('cs-block--dragging');\n canvas.style.cursor = '';\n FC.hideIndicator?.();\n canvas._pendingReorderTarget = null;\n drag = null;\n });\n };\n\n // ---------------------------------------------------------------------------\n // Programmatic move (used by the block badge \"move up / down\" actions).\n //\n // - If the block shares its column with siblings → reorder within the column.\n // - Otherwise (single block in the column) → move the whole ROW up / down\n // among its sibling rows. This matches the common single-column document\n // flow where each block sits on its own row.\n // ---------------------------------------------------------------------------\n const directBlocks = (col) => (\n col ? Array.from(col.children).filter((c) => c.matches?.('.cs_block_s, .canvas-block')) : []\n );\n\n const siblingRows = (parent) => (\n parent ? Array.from(parent.children).filter(\n (c) => c.matches?.('.row-item') && !c.matches('.cs-page-header, .cs-page-footer')\n ) : []\n );\n\n const siblingCols = (row) => (\n row ? Array.from(row.children).filter((c) => c.matches?.('.col-item')) : []\n );\n\n window.FlowCanvas.moveBlock = function (block, dir) {\n if (!block || (dir !== 'up' && dir !== 'down')) return false;\n const col = block.closest('.col-item');\n if (!col) return false;\n\n const blocks = directBlocks(col);\n let moved = false;\n\n if (blocks.length > 1) {\n // Reorder within the column.\n const i = blocks.indexOf(block);\n if (dir === 'up' && i > 0) { blocks[i - 1].before(block); moved = true; }\n if (dir === 'down' && i < blocks.length - 1) { blocks[i + 1].after(block); moved = true; }\n } else {\n // Move the whole row among its siblings.\n const row = block.closest('.row-item');\n const parent = row?.parentElement;\n const rows = siblingRows(parent);\n const i = rows.indexOf(row);\n if (dir === 'up' && i > 0) { rows[i - 1].before(row); moved = true; }\n if (dir === 'down' && i >= 0 && i < rows.length - 1) { rows[i + 1].after(row); moved = true; }\n }\n\n if (moved) {\n block.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n }\n return moved;\n };\n\n // True when there is somewhere to drag the block TO:\n // - the column holds more than one block (reorder within the column), OR\n // - the row has more than one column (drag between columns), OR\n // - there is more than one movable row (drag between rows).\n // ONLY a lone block — one row, one column, one block — has none of these, so\n // the drag handler skips starting a drag (no drop-indicator highlight). A row\n // with multiple columns DOES allow reorder, so the column highlight shows.\n window.FlowCanvas.canReorder = function (block) {\n if (!block) return false;\n const col = block.closest('.col-item');\n if (!col) return false;\n if (directBlocks(col).length > 1) return true;\n const row = block.closest('.row-item');\n if (siblingCols(row).length > 1) return true;\n return siblingRows(row?.parentElement).length > 1;\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/field-panel.js\">\n/**\n * @fileoverview Field bridge — emits the list of bindable fields for the\n * currently selected repeater block to the parent Angular app via postMessage.\n *\n * The parent (app.ts / app.html) renders the actual field chips inside its\n * existing properties side panel. This module only computes the list and\n * notifies the parent when the selection changes.\n *\n * Nested-repeat scoping\n * ---------------------\n * When the selected block sits *inside* one or more ancestor repeaters, the\n * suggested expressions are written relative to the innermost ancestor alias:\n *\n * {% for visit in mainContent.visitDetails %}\n * {% for feedback in visit.arriveOnSiteFeedback %}\n * {{ feedback.answer }} ← scope = feedback (innermost)\n * {% endfor %}\n * {% endfor %}\n *\n * Nested arrays found inside the alias scope (e.g. `arriveOnSiteFeedback` inside\n * each `visit`) are exposed as `kind: 'array'` rows so the parent UI can offer\n * them as binding targets for a child repeater.\n *\n * Message contract:\n * { source: 'custom-form-twig', type: 'fields:available',\n * data: { repeatPath, repeatAlias, fields: [{key, kind, expr, arrayPath?}, ...] } }\n * { source: 'custom-form-twig', type: 'fields:cleared' }\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n // -------------------------------------------------------------------------\n // Binding-data lookup (read from parent window)\n // -------------------------------------------------------------------------\n const getBindingData = () => {\n try {\n const getter = window.parent?.__BROCHURE_FLOW_GET_BINDING_DATA__;\n if (typeof getter === 'function') return getter();\n return window.parent?.__BROCHURE_FLOW_BINDING_DATA__ ?? null;\n } catch (e) { return null; }\n };\n\n const resolvePath = (data, path) => {\n if (!data || !path) return undefined;\n const parts = path.split('.');\n let cur = data;\n for (const p of parts) {\n if (cur == null) return undefined;\n cur = cur[p];\n }\n return cur;\n };\n\n // From an array, sample the first item — that is what we use to infer the\n // shape of each iteration.\n const sampleItem = (arr) => (Array.isArray(arr) && arr.length > 0) ? arr[0] : null;\n\n // For a given sample object, return scalar fields and nested-array fields\n // separately. The scope is the alias the parent {% for %} introduces so all\n // returned `expr` values are alias-relative.\n const buildFieldsForScope = (sample, alias) => {\n if (!sample || typeof sample !== 'object') return [];\n return Object.keys(sample).map((key) => {\n const value = sample[key];\n const kind = Array.isArray(value)\n ? 'array'\n : (value !== null && typeof value === 'object') ? 'object' : 'value';\n\n const out = { key, kind, expr: `{{ ${alias}.${key} }}` };\n\n if (kind === 'array') {\n out.arrayPath = `${alias}.${key}`;\n out.count = value.length;\n const inner = sampleItem(value);\n out.preview = inner && typeof inner === 'object'\n ? Object.keys(inner).slice(0, 3).join(', ')\n : '';\n }\n return out;\n });\n };\n\n // -------------------------------------------------------------------------\n // Ancestor-repeater chain.\n //\n // Walks UP from a selected block collecting EVERY ancestor that carries a\n // `data-repeat-path`. The innermost (closest to the selection) becomes the\n // active scope; the rest let us resolve nested-alias paths against real\n // sample data.\n // -------------------------------------------------------------------------\n // Walk UP from a block, collecting every ancestor repeater's chain. A block\n // can carry either a single binding (data-repeat-path + -alias) OR a full\n // multi-level chain (data-repeat-chain = JSON). The multi-level form expands\n // into multiple chain entries, so a child block dropped inside sees ALL the\n // alias namespaces its parent introduces.\n const parseChainAttr = (json) => {\n if (!json) return null;\n try {\n const parsed = JSON.parse(json);\n if (!Array.isArray(parsed)) return null;\n return parsed.filter((s) => s && s.path && s.alias);\n } catch (e) { return null; }\n };\n\n const findRepeaterChain = (block) => {\n const chain = [];\n if (!block) return chain;\n let cur = block;\n while (cur) {\n const multi = parseChainAttr(cur.dataset?.repeatChain);\n if (multi && multi.length) {\n // Push in reverse so the final chain.reverse() below restores the\n // outermost-first order. Spread each step so extra fields like\n // `kind: 'map'` and `keyAlias` survive — the chain resolver and\n // twig generator both need them.\n for (let i = multi.length - 1; i >= 0; i--) {\n chain.push({ ...multi[i] });\n }\n } else if (cur.dataset?.repeatPath) {\n chain.push({\n path: cur.dataset.repeatPath,\n alias: cur.dataset.repeatAlias || 'item'\n });\n }\n if (cur.matches?.('.cs_margin, .cs-flow-canvas') || cur.tagName === 'BODY') break;\n cur = cur.parentElement;\n }\n // chain is currently innermost-first; reverse to outermost-first, then\n // dedupe steps whose path was already seen. Modal-saved chains on a\n // child block typically include the same outer loops their ancestor\n // section also carries — without dedup, resolveChainSample iterates\n // the same array twice which works but is wasteful.\n const reversed = chain.reverse();\n const seen = new Set();\n const out = [];\n for (const step of reversed) {\n if (seen.has(step.path)) continue;\n seen.add(step.path);\n out.push(step);\n }\n return out;\n };\n\n // Given a repeater chain like\n // [{ path: 'mainContent.visitDetails', alias: 'visit' },\n // { path: 'visit.arriveOnSiteFeedback', alias: 'feedback' }]\n // resolve the chain against real binding data and return the sample item\n // representing one iteration of the innermost loop.\n const resolveChainSample = (chain, bindingData) => {\n if (!chain.length) return null;\n\n let sample = null;\n let arr = null;\n const aliasNamespace = {};\n\n for (const step of chain) {\n const path = step.path;\n const firstSegment = path.split('.')[0];\n let base;\n let remainder;\n if (Object.prototype.hasOwnProperty.call(aliasNamespace, firstSegment)) {\n base = aliasNamespace[firstSegment];\n remainder = path.slice(firstSegment.length + 1);\n } else {\n base = bindingData;\n remainder = path;\n }\n\n arr = remainder ? resolvePath(base, remainder) : base;\n // Map step: the path resolves to a date-keyed object whose values\n // are arrays. The \"sample\" of one iteration is the FIRST value\n // (an array of labour items), which the next step will then\n // sample further.\n if (step.kind === 'map' && arr && typeof arr === 'object' && !Array.isArray(arr)) {\n const firstKey = Object.keys(arr)[0];\n sample = firstKey != null ? arr[firstKey] : null;\n } else {\n sample = sampleItem(arr);\n }\n if (!sample) return null;\n aliasNamespace[step.alias] = sample;\n }\n\n return sample;\n };\n\n // -------------------------------------------------------------------------\n // Message helpers\n // -------------------------------------------------------------------------\n let lastSentKey = null;\n\n const sendFields = (chain) => {\n const innermost = chain[chain.length - 1];\n const data = getBindingData();\n const sample = resolveChainSample(chain, data);\n\n // Map-step terminus: the innermost saved step iterates a date-keyed\n // object whose values are arrays. Field chips for \"an array\" would\n // just be numeric indices, which isn't useful — what the user\n // actually wants is the shape of one labour item. So when the\n // resolved sample is an Array, we sample its first item for fields\n // and report the alias unchanged (the same alias they'd use in the\n // implicit inner loop).\n let displaySample = sample;\n if (Array.isArray(sample)) {\n displaySample = sample.length ? sample[0] : null;\n }\n\n const fields = displaySample ? buildFieldsForScope(displaySample, innermost.alias) : [];\n\n const key = `${innermost.path}::${innermost.alias}::${fields.length}::${chain.length}`;\n if (key === lastSentKey) return;\n lastSentKey = key;\n\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'fields:available',\n data: {\n repeatPath: innermost.path,\n repeatAlias: innermost.alias,\n fields,\n ancestorChain: chain\n }\n }, '*');\n } catch (e) { /* ignore */ }\n };\n\n const sendCleared = () => {\n if (lastSentKey === null) return;\n lastSentKey = null;\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'fields:cleared'\n }, '*');\n } catch (e) { /* ignore */ }\n };\n\n // -------------------------------------------------------------------------\n // Selection watcher\n // -------------------------------------------------------------------------\n const checkSelection = () => {\n const selected = document.querySelector(\n '.cs-flow-canvas .cs_block_s.cs-selected, ' +\n '.cs-flow-canvas .cs_block_s.cs-editing'\n );\n const chain = findRepeaterChain(selected);\n if (chain.length) {\n sendFields(chain);\n } else if (selected) {\n // Block is selected but not inside a repeater — show root-level variables\n const bindingData = getBindingData();\n if (bindingData && typeof bindingData === 'object') {\n const rootFields = buildFieldsForScope(bindingData, 'mainContent');\n const key = `root::mainContent::${rootFields.length}::0`;\n if (key !== lastSentKey) {\n lastSentKey = key;\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'fields:available',\n data: {\n repeatPath: '',\n repeatAlias: 'mainContent',\n fields: rootFields,\n ancestorChain: []\n }\n }, '*');\n } catch (e) { /* ignore */ }\n }\n }\n } else {\n sendCleared();\n }\n };\n\n window.FlowCanvas.initFieldPanel = function (canvas) {\n const observer = new MutationObserver(() => {\n requestAnimationFrame(checkSelection);\n });\n observer.observe(canvas, {\n subtree: true,\n attributes: true,\n attributeFilter: ['class', 'data-repeat-path', 'data-repeat-alias']\n });\n checkSelection();\n };\n\n // -------------------------------------------------------------------------\n // Tree builder for the binding modal — walks every array reachable from a\n // starting scope, including arrays nested inside other arrays, and emits\n // indented rows. Each row carries the *full chain* needed to reproduce that\n // path at runtime so the twig generator can wrap the block in multiple\n // {% for %} loops.\n //\n // Each row shape:\n // {\n // path: 'visit.arriveOnSiteFeedback', // display path (relative to scope)\n // fullPath: 'mainContent.visitDetails[0].arriveOnSiteFeedback', // for debug\n // count: 5,\n // preview: 'name, answered, answer',\n // depth: 1, // 0 = top-level item under scope\n // chain: [ // every for-loop needed to reach here\n // { path: 'mainContent.visitDetails', alias: 'visit' },\n // { path: 'visit.arriveOnSiteFeedback', alias: '__leaf__' } // alias replaced on apply\n // ],\n // scope: 'root' | 'ancestor'\n // }\n //\n // `seedChain` lets the caller prefix every emitted row with parent for-loops\n // that already exist (when scoping from an ancestor section).\n // -------------------------------------------------------------------------\n const defaultAliasFor = (key, depth) => {\n // Heuristic singularize: drop trailing 's' / 'es' / 'ies'. Falls back to\n // the original key if nothing matches. Aliases are only PLACEHOLDERS —\n // the user can rename the leaf alias in the modal; intermediate aliases\n // stay as-is.\n if (!key) return `item${depth}`;\n if (/ies$/i.test(key)) return key.slice(0, -3) + 'y';\n if (/[^aeiou]es$/i.test(key)) return key.slice(0, -2);\n if (/s$/i.test(key) && key.length > 2) return key.slice(0, -1);\n return key;\n };\n\n // Detect whether an object is a \"map of arrays\" — i.e. its values are\n // all arrays. This is the shape Twig iterates with\n // {% for key, list in obj %}\n // For these we emit ONE loopable row (with key+value aliases) plus a\n // child row representing the inner array's items.\n const isMapOfArrays = (obj) => {\n if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;\n const keys = Object.keys(obj);\n if (!keys.length) return false;\n return keys.every((k) => Array.isArray(obj[k]));\n };\n\n const buildFullArrayTree = (sample, scopeAlias, seedChain, scope) => {\n const rows = [];\n if (!sample || typeof sample !== 'object') return rows;\n\n // Walk every key. For nested arrays we emit a row and recurse into\n // their sample item. For nested plain objects we DON'T emit a row\n // but still recurse so deeper arrays surface (otherwise dot-paths\n // like `visit.labourTimeDetails.date[\"...\"]` would be invisible).\n // For map-of-arrays objects (date-keyed lists) we emit one row that\n // loops `key, value` pairs, plus a deeper row for the inner array.\n const walk = (obj, pathPrefix, chain, depth, recurseAlias) => {\n Object.keys(obj).forEach((key) => {\n const value = obj[key];\n const relPath = pathPrefix ? `${pathPrefix}.${key}` : key;\n\n if (Array.isArray(value)) {\n const inner = sampleItem(value);\n const childAlias = defaultAliasFor(key, depth + 1);\n const row = {\n path: relPath,\n count: value.length,\n preview: inner && typeof inner === 'object'\n ? Object.keys(inner).slice(0, 3).join(', ')\n : String(inner ?? ''),\n depth,\n chain: [\n ...chain,\n { path: relPath, alias: childAlias }\n ],\n scope\n };\n rows.push(row);\n if (inner && typeof inner === 'object') {\n walk(inner, childAlias, row.chain, depth + 1, childAlias);\n }\n return;\n }\n\n if (value && typeof value === 'object') {\n // Map-of-arrays (eg. labourTimeDetails.date) → emit ONE\n // composite loopable row. Selecting it produces TWO nested\n // for-loops in the generated twig: an outer\n // {% for key, list in path %}\n // pair, plus an inner\n // {% for item in list %}\n // so the user can immediately bind the inner item's fields\n // without having to pick two rows. The user's \"Loop variable\n // name\" input edits the INNER alias (the actual row variable\n // they'll reference in cells).\n if (isMapOfArrays(value)) {\n const sampleKey = Object.keys(value)[0];\n const innerArr = value[sampleKey];\n const keyAlias = defaultAliasFor(key + 'Key', depth + 1);\n const valueAlias = defaultAliasFor(key, depth + 1) + 's';\n const innerSample = sampleItem(innerArr);\n const innerAlias = defaultAliasFor(key + 'Item', depth + 2);\n const fullChain = [\n ...chain,\n { path: relPath, alias: valueAlias, keyAlias, kind: 'map' },\n { path: valueAlias, alias: innerAlias },\n ];\n const row = {\n path: relPath,\n count: innerArr.length,\n preview: innerSample && typeof innerSample === 'object'\n ? Object.keys(innerSample).slice(0, 3).join(', ')\n : String(innerSample ?? ''),\n depth,\n chain: fullChain,\n scope,\n kind: 'map'\n };\n rows.push(row);\n\n if (innerSample && typeof innerSample === 'object') {\n walk(innerSample, innerAlias, fullChain, depth + 1, innerAlias);\n }\n return;\n }\n\n // Plain nested object — descend without emitting a row.\n walk(value, relPath, chain, depth, recurseAlias);\n }\n });\n };\n\n walk(sample, scopeAlias, seedChain, 0, scopeAlias);\n return rows;\n };\n\n // Public utility — also used by custom-form.js to compute scoped arrays for\n // the binding modal when a user is dropping a new repeater inside an existing\n // repeater scope. Walks the ancestor chain ABOVE the dropped block (not\n // including itself), resolves each alias against real binding data, and\n // returns the arrays that exist inside the innermost ancestor's iteration.\n window.FlowCanvas.computeScopedArrays = function (block, bindingData) {\n const ancestor = block?.parentElement || null;\n const chain = findRepeaterChain(ancestor);\n if (!chain.length) return null;\n\n const innermost = chain[chain.length - 1];\n const sample = resolveChainSample(chain, bindingData);\n if (!sample) return { alias: innermost.alias, arrays: [] };\n\n // Tree-aware: full nested-array tree relative to the innermost ancestor.\n // Seed chain = the ancestor for-loops that already exist; rows append on\n // top of those so the twig generator can produce the full nested\n // {% for %} stack from a single block.\n const seedChain = chain.map((s) => ({ path: s.path, alias: s.alias }));\n const arrays = buildFullArrayTree(sample, innermost.alias, seedChain, 'ancestor');\n\n return { alias: innermost.alias, arrays };\n };\n\n // Build the FULL tree starting from root binding data — used when a block\n // is dropped on the canvas root (no ancestor repeater).\n window.FlowCanvas.buildRootArrayTree = function (bindingData) {\n if (!bindingData || typeof bindingData !== 'object') return [];\n // Walk the object tree looking for arrays. When we find one, emit it as\n // a depth-0 row and recurse into its first item for deeper arrays.\n const rows = [];\n\n const walkObject = (obj, pathPrefix) => {\n if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;\n Object.keys(obj).forEach((key) => {\n const value = obj[key];\n const nextPath = pathPrefix ? `${pathPrefix}.${key}` : key;\n if (Array.isArray(value)) {\n const inner = sampleItem(value);\n const alias = defaultAliasFor(key, 1);\n const row = {\n path: nextPath,\n count: value.length,\n preview: inner && typeof inner === 'object'\n ? Object.keys(inner).slice(0, 3).join(', ')\n : String(inner ?? ''),\n depth: 0,\n chain: [{ path: nextPath, alias }],\n scope: 'root'\n };\n rows.push(row);\n // Recurse into first item to surface deeper arrays.\n if (inner && typeof inner === 'object') {\n const deeper = buildFullArrayTree(inner, alias, row.chain, 'root');\n // Bump depths so they're relative to this top-level row.\n deeper.forEach((d) => { d.depth = d.depth + 1; });\n rows.push(...deeper);\n }\n } else if (value && typeof value === 'object') {\n walkObject(value, nextPath);\n }\n });\n };\n\n walkObject(bindingData, '');\n return rows;\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/history-manager.js\">\n/**\n * @fileoverview Canvas history manager — undo / redo for block-level\n * actions (add, remove, move, binding changes, page splits).\n *\n * Design (DOM-snapshot, observer-driven):\n *\n * 1. We observe the canvas tree with a MutationObserver. Whenever\n * blocks / rows / cols / pages are added or removed (or their\n * data-repeat-* / data-twig-* attributes change), we take a\n * snapshot of the canvas innerHTML.\n *\n * 2. A burst of mutations from a single user action (eg. dropping a\n * block creates a row + col + block all at once) is collapsed\n * into ONE history entry via a 300ms debounce.\n *\n * 3. Undo restores the canvas innerHTML to the previous snapshot.\n * Redo restores to the next one.\n *\n * 4. During a restore we set `suspended = true` so the observer\n * doesn't record the restoration itself as a new action.\n *\n * 5. We DON'T track inline text edits — Froala owns its own undo\n * stack for that. (Text edits arrive as `characterData` mutations,\n * which we ignore.)\n *\n * Why DOM snapshot instead of command pattern?\n *\n * The codebase has many mutation sites scattered across modules\n * (placeBlock, page splits, binding-modal apply, reorder, cleanup\n * observer, etc.). Instrumenting every one is invasive and easy to\n * forget — a missed site = silently broken undo. Observing the DOM\n * covers EVERY mutation uniformly without touching any existing\n * logic, which the user explicitly asked for.\n *\n * Memory: snapshots cap at HISTORY_LIMIT entries (default 50). At ~50KB\n * per snapshot for a typical document, worst case is ~2.5MB — well\n * within budget.\n *\n * Exposes:\n * window.FlowCanvas.initHistory(canvas)\n * window.FlowCanvas.undo()\n * window.FlowCanvas.redo()\n * window.FlowCanvas.suspendHistory(fn) — run fn without recording\n * window.FlowCanvas.getHistoryState() — { undoCount, redoCount }\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const HISTORY_LIMIT = 50;\n const COMMIT_DEBOUNCE_MS = 300;\n\n // Snapshot stacks. `past` holds older states (the most recent is the\n // one we'd revert to on undo). `future` holds states the user undid;\n // any new action clears `future` (standard editor behaviour).\n const past = [];\n const future = [];\n let baseline = ''; // the snapshot the canvas currently matches\n let canvasRef = null; // root we snapshot\n\n let suspended = false;\n let pendingCommit = null;\n\n // Take a fresh snapshot of the canvas. Returns the HTML string we'd\n // restore on undo. We snapshot innerHTML of the canvas (which\n // contains the cs_margin pages); the canvas element itself stays in\n // place so listeners attached to it survive the restore.\n const snapshot = () => canvasRef ? canvasRef.innerHTML : '';\n\n const restore = (html) => {\n if (!canvasRef) return;\n suspended = true;\n try {\n canvasRef.innerHTML = html;\n baseline = html;\n // Tell downstream observers (twig generator, field panel, etc.)\n // that the tree has changed via a synthetic mutation. They\n // already react to childList mutations on the canvas, which the\n // innerHTML assignment triggers naturally — no manual nudge\n // needed beyond clearing our suspend flag.\n } finally {\n // Let any synchronous mutation handlers finish before we resume,\n // otherwise their cleanup pass would record a fresh entry on\n // top of the restored state.\n requestAnimationFrame(() => { suspended = false; });\n }\n };\n\n // Commit the current canvas state to history. Called after the\n // debounce window expires for a burst of mutations.\n const commit = () => {\n pendingCommit = null;\n if (suspended || !canvasRef) return;\n const next = snapshot();\n if (next === baseline) return; // nothing actually changed\n past.push(baseline);\n if (past.length > HISTORY_LIMIT) past.shift();\n baseline = next;\n // Any new committed change invalidates the redo stack — once the\n // user diverges from the previous future, that future is gone.\n future.length = 0;\n };\n\n const scheduleCommit = () => {\n if (suspended) return;\n if (pendingCommit) clearTimeout(pendingCommit);\n pendingCommit = setTimeout(commit, COMMIT_DEBOUNCE_MS);\n };\n\n // Public: run `fn` without history capturing. Used by code that\n // performs migrations or other behind-the-scenes mutations that\n // shouldn't be exposed as undoable user actions.\n const suspendHistory = (fn) => {\n const wasSuspended = suspended;\n suspended = true;\n try { fn(); }\n finally {\n requestAnimationFrame(() => { suspended = wasSuspended; });\n }\n };\n\n const undo = () => {\n if (pendingCommit) {\n // Flush pending burst first so the user gets the most recent\n // state into the undo stack before stepping back.\n clearTimeout(pendingCommit);\n commit();\n }\n if (!past.length) return false;\n future.push(baseline);\n const prev = past.pop();\n restore(prev);\n return true;\n };\n\n const redo = () => {\n if (!future.length) return false;\n past.push(baseline);\n const next = future.pop();\n restore(next);\n return true;\n };\n\n const getHistoryState = () => ({\n undoCount: past.length,\n redoCount: future.length,\n });\n\n // ---------------------------------------------------------------------------\n // Init: attach the observer and the keyboard shortcuts.\n //\n // Watches childList (block/row/col add/remove) and selected attribute\n // changes (data-repeat-*, data-twig-if, data-page) so binding /\n // condition / page edits are also captured. Inline style and class\n // changes are ignored — they're cosmetic and would flood the stack\n // with selection / hover noise.\n // ---------------------------------------------------------------------------\n window.FlowCanvas.initHistory = function (canvas) {\n if (!canvas) return;\n canvasRef = canvas;\n // Defer the first baseline snapshot until other startup work has\n // finished (section migration, page creation, etc.). Otherwise the\n // user's very first action would have nothing to undo back to AND\n // any startup mutation would be recorded as a phantom user action.\n suspended = true;\n requestAnimationFrame(() => {\n baseline = snapshot();\n suspended = false;\n });\n\n const obs = new MutationObserver((mutations) => {\n if (suspended) return;\n // Reject mutations that are clearly NOT user-edits: characterData\n // (inline text edits — Froala territory) and style/class changes.\n let interesting = false;\n for (const m of mutations) {\n if (m.type === 'childList') {\n // Skip mutations that ONLY add/remove chrome elements —\n // they're our own decoration, not user content.\n const isChrome = (n) =>\n n.nodeType === 1 && (\n n.hasAttribute?.('data-cs-chrome') ||\n n.classList?.contains('cs-overflow-mark') ||\n n.classList?.contains('cs-block-grip') ||\n n.classList?.contains('cs-block-badge') ||\n n.classList?.contains('section-binding-info')\n );\n const added = Array.from(m.addedNodes).filter((n) => !isChrome(n));\n const removed = Array.from(m.removedNodes).filter((n) => !isChrome(n));\n if (added.length || removed.length) { interesting = true; break; }\n } else if (m.type === 'attributes') {\n interesting = true; break;\n }\n }\n if (interesting) scheduleCommit();\n });\n obs.observe(canvas, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\n 'data-repeat-path',\n 'data-repeat-alias',\n 'data-repeat-chain',\n 'data-twig-if',\n 'data-page',\n ],\n });\n\n // Keyboard shortcuts. We bind on document so the focus can be\n // anywhere in the iframe. If the user is typing inside a\n // contenteditable (Froala) we defer to its own undo handler.\n document.addEventListener('keydown', (e) => {\n const inEditable = e.target?.isContentEditable ||\n e.target?.tagName === 'INPUT' ||\n e.target?.tagName === 'TEXTAREA';\n if (inEditable) return;\n const ctrl = e.ctrlKey || e.metaKey;\n if (!ctrl) return;\n const key = e.key.toLowerCase();\n if (key === 'z' && !e.shiftKey) {\n e.preventDefault();\n undo();\n } else if (key === 'y' || (key === 'z' && e.shiftKey)) {\n e.preventDefault();\n redo();\n }\n });\n };\n\n window.FlowCanvas.undo = undo;\n window.FlowCanvas.redo = redo;\n window.FlowCanvas.suspendHistory = suspendHistory;\n window.FlowCanvas.getHistoryState = getHistoryState;\n})();\n\n<\/script>\n <script data-src=\"./js/flow/inline-insert.js\">\n/**\n * @fileoverview Hover-based inline insert control for the flow canvas.\n *\n * Shows a small \"+\" button on the left edge of the current insertion line.\n * Clicking it opens a block picker and inserts the selected block using the\n * same createBlock/placeBlock path as sidebar drag/drop.\n *\n * Exposes:\n * window.FlowCanvas.initInlineInsert(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n // Menu sections come straight from the shared block registry — add a block\n // there with `inInlineMenu: true` and it appears here automatically.\n const getInlineSections = () => (\n window.FormBlockRegistry?.sections('inInlineMenu') || []\n );\n\n const clamp = (value, min, max) => Math.min(Math.max(value, min), max);\n\n // How close to a column's left/right edge the pointer must be before we offer\n // a \"new column beside this block\" drop instead of an in-column insert.\n const COL_EDGE_GAP = (window.CanvasConfig?.dropZone?.colEdgeGap) ?? 24;\n\n // When the pointer is directly over a block's content the user almost always\n // wants an in-column insert, so we only flip to the vertical \"new column\"\n // mode within a much tighter band of the true edge. This stops the indicator\n // from jumping to vertical while casually hovering blocks in a multi-column\n // row (the full COL_EDGE_GAP still applies over a column's empty area).\n const COL_EDGE_GAP_OVER_BLOCK = 6;\n\n const directChildren = (root, selector) => {\n if (!root) return [];\n return Array.from(root.children).filter((child) => child.matches?.(selector));\n };\n\n const blockChildrenOfCol = (col) => (\n directChildren(col, '.cs_block_s, .canvas-block')\n );\n\n const rowChildrenOfRoot = (root) => (\n directChildren(root, '.row-item')\n );\n\n const isContentRoot = (node) => (\n !!node && (\n node.classList?.contains('cs_margin') ||\n node.classList?.contains('body-main-content') ||\n node.classList?.contains('section-container-content')\n )\n );\n\n const isInteractiveChrome = (target) => (\n !!target?.closest?.(\n '.cs-block-grip, .cs-line-divider, [data-cs-chrome], .fr-toolbar, .fr-popup, .fr-modal, .fr-tooltip'\n )\n );\n\n const resolveInColTarget = (col, clientY) => {\n const blocks = blockChildrenOfCol(col);\n if (!blocks.length) {\n return { target: { kind: 'in-col', col, beforeBlock: null } };\n }\n\n for (let i = 0; i < blocks.length; i++) {\n const rect = blocks[i].getBoundingClientRect();\n const mid = (rect.top + rect.bottom) / 2;\n if (clientY < mid) {\n return { target: { kind: 'in-col', col, beforeBlock: blocks[i] } };\n }\n }\n\n return { target: { kind: 'in-col', col, beforeBlock: null } };\n };\n\n // When the pointer sits near the left/right edge of a column, offer a\n // \"new column beside this block\" drop (col-edge) instead of an in-column\n // insert. Returns null when the pointer is comfortably inside the column.\n const resolveColEdge = (col, clientX, gap = COL_EDGE_GAP) => {\n const row = col.closest('.row-item');\n if (!row) return null;\n const rect = col.getBoundingClientRect();\n const cols = directChildren(row, '.col-item');\n\n if (clientX <= rect.left + gap) {\n return { target: { kind: 'col-edge', row, beforeCol: col } };\n }\n if (clientX >= rect.right - gap) {\n const idx = cols.indexOf(col);\n return { target: { kind: 'col-edge', row, beforeCol: cols[idx + 1] || null } };\n }\n return null;\n };\n\n const computeGeometry = (target, doc, clientX, clientY) => {\n if (!target || !doc) return null;\n\n if (target.kind === 'col-edge' && target.row) {\n const rowRect = target.row.getBoundingClientRect();\n const cols = directChildren(target.row, '.col-item');\n let x;\n if (target.beforeCol) {\n x = target.beforeCol.getBoundingClientRect().left;\n } else if (cols.length) {\n x = cols[cols.length - 1].getBoundingClientRect().right;\n } else {\n x = rowRect.left;\n }\n // The line spans the whole row height, but the \"+\" handle tracks the\n // pointer's Y (clamped inside the row) so it stays next to the cursor —\n // just like the horizontal/in-column case. Without this the handle pins\n // to the row's top corner and appears to jump away the moment the hover\n // switches from an in-column (horizontal) to a new-column (vertical)\n // insert.\n return {\n vertical: true,\n x,\n top: rowRect.top,\n bottom: rowRect.bottom,\n y: clamp(clientY, rowRect.top + 16, rowRect.bottom - 16),\n };\n }\n\n if (target.kind === 'in-col' && target.col) {\n const rect = target.col.getBoundingClientRect();\n const blocks = blockChildrenOfCol(target.col);\n let lineY = clamp(clientY, rect.top + 12, rect.bottom - 12);\n if (blocks.length) {\n if (target.beforeBlock) {\n lineY = target.beforeBlock.getBoundingClientRect().top;\n } else {\n lineY = blocks[blocks.length - 1].getBoundingClientRect().bottom;\n }\n }\n return {\n left: rect.left,\n right: rect.right,\n y: lineY,\n };\n }\n\n if (target.kind === 'between-rows') {\n const root = isContentRoot(target.parent) ? target.parent : doc;\n const rootRect = root.getBoundingClientRect();\n const rows = rowChildrenOfRoot(root).filter((row) => {\n return !row.matches('.cs-page-header, .cs-page-footer');\n });\n\n let lineY;\n if (!rows.length) {\n // Empty page: the very first insert always pins to the page top,\n // regardless of where the pointer is (so a hover in the centre still\n // drops the first block at the top). Once a block exists this branch\n // is skipped and the line follows the pointer / sits between rows.\n lineY = rootRect.top + 14;\n } else if (target.beforeRow) {\n lineY = target.beforeRow.getBoundingClientRect().top;\n } else {\n lineY = rows[rows.length - 1].getBoundingClientRect().bottom;\n }\n\n return {\n left: rootRect.left,\n right: rootRect.right,\n y: lineY,\n };\n }\n\n return null;\n };\n\n const renderMenuSections = (menuEl, onChoose) => {\n menuEl.innerHTML = '';\n getInlineSections().forEach((section) => {\n const sectionEl = document.createElement('div');\n sectionEl.className = 'cs-inline-insert-menu__section';\n\n const titleEl = document.createElement('div');\n titleEl.className = 'cs-inline-insert-menu__title';\n titleEl.textContent = section.title;\n sectionEl.appendChild(titleEl);\n\n section.items.forEach((item) => {\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'cs-inline-insert-menu__item';\n button.dataset.blockType = item.type;\n button.innerHTML = `\n <span class=\"cs-inline-insert-menu__icon\">${item.icon}</span>\n <span class=\"cs-inline-insert-menu__label\">${item.label}</span>\n `;\n button.addEventListener('click', (event) => {\n event.preventDefault();\n event.stopPropagation();\n onChoose(item);\n });\n sectionEl.appendChild(button);\n });\n\n menuEl.appendChild(sectionEl);\n });\n };\n\n window.FlowCanvas.initInlineInsert = function (canvas) {\n if (!canvas || canvas.dataset.inlineInsertInit === '1') return;\n canvas.dataset.inlineInsertInit = '1';\n\n const FC = window.FlowCanvas || {};\n const paper = canvas.closest('.cs_paper') || canvas;\n let enabled = window.CanvasConfig?.inlineInsert?.enabled !== false;\n\n const plusEl = document.createElement('button');\n plusEl.type = 'button';\n plusEl.className = 'cs-inline-insert';\n plusEl.setAttribute('aria-label', 'Add content');\n plusEl.setAttribute('title', 'Add content');\n plusEl.innerHTML = '<span>+</span>';\n\n const lineEl = document.createElement('div');\n lineEl.className = 'cs-inline-insert-line';\n\n const menuEl = document.createElement('div');\n menuEl.className = 'cs-inline-insert-menu';\n\n document.body.appendChild(lineEl);\n document.body.appendChild(plusEl);\n document.body.appendChild(menuEl);\n\n const state = {\n doc: null,\n target: null,\n clientX: 0,\n clientY: 0,\n geometry: null,\n open: false,\n visible: false,\n };\n\n const hideVisuals = () => {\n state.visible = false;\n lineEl.classList.remove('is-visible');\n lineEl.classList.remove('is-active');\n plusEl.classList.remove('is-visible', 'is-open');\n };\n\n const closeMenu = ({ keepVisuals = false } = {}) => {\n state.open = false;\n menuEl.classList.remove('is-open');\n plusEl.classList.remove('is-open');\n lineEl.classList.remove('is-active');\n if (!keepVisuals) hideVisuals();\n };\n\n const showVisuals = (geometry) => {\n if (!enabled) return;\n state.visible = true;\n // Cover pages show only the \"+\" — the line is distracting there.\n if (geometry.plusOnly) lineEl.classList.remove('is-visible');\n else lineEl.classList.add('is-visible');\n plusEl.classList.add('is-visible');\n\n if (geometry.vertical) {\n // New-column indicator: vertical line on the block's left/right edge.\n lineEl.classList.add('cs-inline-insert-line--vertical');\n plusEl.classList.add('cs-inline-insert--vertical');\n lineEl.style.left = `${geometry.x}px`;\n lineEl.style.top = `${geometry.top}px`;\n lineEl.style.width = '';\n lineEl.style.height = `${Math.max(32, geometry.bottom - geometry.top)}px`;\n\n plusEl.style.left = `${geometry.x}px`;\n plusEl.style.top = `${geometry.y ?? geometry.top}px`;\n } else {\n // New-row / in-column indicator: horizontal line.\n lineEl.classList.remove('cs-inline-insert-line--vertical');\n plusEl.classList.remove('cs-inline-insert--vertical');\n lineEl.style.left = `${geometry.left}px`;\n lineEl.style.top = `${geometry.y}px`;\n lineEl.style.height = '';\n lineEl.style.width = `${Math.max(32, geometry.right - geometry.left)}px`;\n\n plusEl.style.left = `${(geometry.plusX ?? geometry.left) - 14}px`;\n plusEl.style.top = `${geometry.y}px`;\n }\n };\n\n const positionMenu = () => {\n if (!state.geometry) return;\n const g = state.geometry;\n const anchorY = g.y ?? g.top;\n const anchorX = g.vertical ? g.x : (g.plusX ?? g.left);\n const menuHeight = Math.min(420, menuEl.offsetHeight || 420);\n const maxTop = Math.max(12, window.innerHeight - menuHeight - 12);\n const maxLeft = Math.max(12, window.innerWidth - 288);\n const top = clamp(anchorY - 12, 12, maxTop);\n const left = clamp(anchorX + 18, 12, maxLeft);\n menuEl.style.top = `${top}px`;\n menuEl.style.left = `${left}px`;\n };\n\n // Cover pages (.cs_page[data-cs-cover]) are free-move canvases — included\n // here so the insert \"+\" works on them too (it tracks the pointer there).\n const findActiveDoc = (clientX, clientY) => {\n const docs = Array.from(paper.querySelectorAll('.cs_margin, .cs_page[data-cs-cover=\"1\"]'));\n for (const doc of docs) {\n const rect = doc.getBoundingClientRect();\n if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {\n return doc;\n }\n }\n return docs[0] || null;\n };\n\n const resolveTarget = (doc, clientX, clientY, eventTarget) => {\n const hoveredCol = eventTarget?.closest?.('.col-item');\n if (hoveredCol && doc.contains(hoveredCol)) {\n // Single-column rows only: if the cursor is near the row's top/bottom\n // edge, prefer between-rows (new row) over in-col. In a multi-column\n // row each column's top/bottom is still a valid in-col target, so we\n // leave that behaviour untouched.\n const row = hoveredCol.closest('.row-item');\n const blocks = blockChildrenOfCol(hoveredCol);\n const rowCols = row ? directChildren(row, '.col-item') : [];\n if (row && blocks.length && rowCols.length === 1 && typeof FC.findDropTarget === 'function') {\n const rowRect = row.getBoundingClientRect();\n const ROW_EDGE_THRESHOLD = 14;\n if (clientY <= rowRect.top + ROW_EDGE_THRESHOLD || clientY >= rowRect.bottom - ROW_EDGE_THRESHOLD) {\n const r = FC.findDropTarget(doc, paper, clientX, clientY);\n if (r?.target) return r;\n }\n }\n\n // Near the column's left/right edge → offer a new column beside it.\n // Tighten that edge band while hovering a block so an in-column insert\n // stays the default and the indicator doesn't flip to vertical mid-column.\n const overBlock = !!eventTarget?.closest?.('.cs_block_s, .canvas-block');\n const gap = overBlock ? COL_EDGE_GAP_OVER_BLOCK : COL_EDGE_GAP;\n const edge = resolveColEdge(hoveredCol, clientX, gap);\n if (edge) return edge;\n return resolveInColTarget(hoveredCol, clientY);\n }\n if (!doc || typeof FC.findDropTarget !== 'function') return null;\n const result = FC.findDropTarget(doc, paper, clientX, clientY);\n if (!result?.target) return null;\n // Keep col-edge as a real new-column drop (vertical indicator).\n return result;\n };\n\n const refreshHover = (clientX, clientY, eventTarget) => {\n if (!enabled) {\n hideVisuals();\n return;\n }\n if (state.open) return;\n if (canvas.querySelector('.cs-block--dragging')) {\n hideVisuals();\n return;\n }\n // While any block is being edited (typing / selecting text), the insert\n // indicator must stay hidden — it overlaps the editing surface and makes\n // mouse text-selection awkward. `.cs-editing` is the shared edit-mode\n // marker set by inline-editor.js for every editable block type.\n if (document.querySelector('.cs_block_s.cs-editing')) {\n hideVisuals();\n return;\n }\n if (eventTarget?.closest?.('.cs-inline-insert, .cs-inline-insert-menu')) {\n if (state.geometry) showVisuals(state.geometry);\n return;\n }\n if (isInteractiveChrome(eventTarget)) {\n hideVisuals();\n return;\n }\n\n const doc = findActiveDoc(clientX, clientY);\n if (!doc) {\n hideVisuals();\n return;\n }\n\n const insideCanvas = eventTarget?.closest?.('.custom-form-design');\n if (!insideCanvas) {\n hideVisuals();\n return;\n }\n\n // Cover page: a free-move canvas with no rows/columns. The indicator is a\n // full-width horizontal line with the \"+\" on the left (same look as a\n // content page), but its Y follows the pointer; a click drops the block at\n // the cursor (absolute placement, same path as a sidebar drag onto the\n // cover). Resolve the cover from the hovered element so we only activate\n // when truly over one.\n const coverEl = eventTarget?.closest?.('[data-cs-cover=\"1\"]');\n if (coverEl) {\n // On a cover page the line only shows in the IDLE state — never while a\n // block is selected, being edited, moved, or resized. (Move requires a\n // selected block, resize requires an editing block, so checking\n // selected + editing here covers all of those interactions.)\n if (coverEl.querySelector('.cs-selected, .cs-multi-selected, .cs-editing')) {\n hideVisuals();\n return;\n }\n const coverRect = coverEl.getBoundingClientRect();\n // The \"+\" is an edge affordance: it appears only when the cursor is\n // within EDGE px of the page's left or right edge — left edge → \"+\" on\n // the left, right edge → \"+\" on the right. Anywhere in between shows\n // nothing (the line was distracting; a centre \"+\" isn't wanted).\n const EDGE = 30;\n const distLeft = clientX - coverRect.left;\n const distRight = coverRect.right - clientX;\n let plusX = null;\n if (distLeft >= 0 && distLeft <= EDGE && distLeft <= distRight) {\n plusX = coverRect.left;\n } else if (distRight >= 0 && distRight <= EDGE) {\n plusX = coverRect.right;\n }\n if (plusX === null) {\n hideVisuals();\n return;\n }\n state.doc = coverEl;\n state.target = { kind: 'between-rows', beforeRow: null, parent: coverEl };\n state.clientX = clientX;\n state.clientY = clientY;\n state.geometry = {\n left: coverRect.left,\n right: coverRect.right,\n y: clamp(clientY, coverRect.top + 8, coverRect.bottom - 8),\n plusOnly: true, // cover: show only the \"+\", the line is distracting\n plusX,\n };\n showVisuals(state.geometry);\n return;\n }\n\n const result = resolveTarget(doc, clientX, clientY, eventTarget);\n if (!result?.target) {\n hideVisuals();\n return;\n }\n\n const geometry = computeGeometry(result.target, doc, clientX, clientY);\n if (!geometry) {\n hideVisuals();\n return;\n }\n\n state.doc = doc;\n state.target = result.target;\n state.clientX = clientX;\n state.clientY = clientY;\n state.geometry = geometry;\n showVisuals(geometry);\n };\n\n const chooseItem = (item) => {\n if (!enabled) return;\n if (!state.doc || !state.target) return;\n FC.insertPayloadAtTarget?.({\n payload: {\n blockType: item.type,\n label: item.label,\n },\n activeDoc: state.doc,\n target: state.target,\n clientX: state.clientX,\n clientY: state.clientY,\n });\n closeMenu();\n };\n\n renderMenuSections(menuEl, chooseItem);\n\n plusEl.addEventListener('click', (event) => {\n event.preventDefault();\n event.stopPropagation();\n if (!enabled) return;\n if (!state.target || !state.geometry) return;\n state.open = !state.open;\n plusEl.classList.toggle('is-open', state.open);\n menuEl.classList.toggle('is-open', state.open);\n lineEl.classList.toggle('is-active', state.open);\n if (state.open) {\n positionMenu();\n }\n });\n\n plusEl.addEventListener('mouseenter', () => {\n if (!enabled) return;\n lineEl.classList.add('is-active');\n });\n\n plusEl.addEventListener('mouseleave', () => {\n if (!enabled || state.open) return;\n lineEl.classList.remove('is-active');\n });\n\n document.addEventListener('pointermove', (event) => {\n if (!enabled || state.open) return;\n refreshHover(event.clientX, event.clientY, event.target);\n }, true);\n\n document.addEventListener('scroll', () => {\n if (state.open) positionMenu();\n else hideVisuals();\n }, true);\n\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape') {\n closeMenu();\n }\n });\n\n document.addEventListener('pointerdown', (event) => {\n const clickedInsideMenu = event.target.closest?.('.cs-inline-insert-menu, .cs-inline-insert');\n if (clickedInsideMenu) return;\n if (state.open) closeMenu();\n }, true);\n\n document.addEventListener('dragstart', () => closeMenu(), true);\n document.addEventListener('dragenter', () => closeMenu(), true);\n document.addEventListener('drop', () => closeMenu(), true);\n\n const docsObserver = new MutationObserver(() => {\n if (!document.contains(state.doc)) {\n closeMenu();\n }\n });\n docsObserver.observe(paper, { childList: true, subtree: true });\n\n const applyEnabledState = (nextEnabled) => {\n enabled = !!nextEnabled;\n if (window.CanvasConfig?.inlineInsert) {\n window.CanvasConfig.inlineInsert.enabled = enabled;\n }\n if (!enabled) {\n closeMenu();\n hideVisuals();\n }\n };\n\n applyEnabledState(enabled);\n\n Object.assign(window.FlowCanvas, {\n isInlineInsertEnabled: () => enabled,\n setInlineInsertEnabled: (nextEnabled) => {\n applyEnabledState(nextEnabled);\n return enabled;\n },\n // Let other modules force the insert indicator away immediately — e.g.\n // the editor calls this the moment a block enters edit mode, so the line\n // and \"+\" handle vanish without waiting for the next pointermove.\n hideInlineInsert: () => closeMenu(),\n });\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/copy-paste.js\">\n/**\n * @fileoverview Block copy / paste for the flow canvas (Ctrl+C / Ctrl+V).\n *\n * UX:\n * - Select a block (single click → selected state, not editing).\n * - Ctrl+C → the selected block (markup + content + styles) is copied to an\n * internal clipboard.\n * - Ctrl+V → a fresh copy is inserted into the SAME column, right after the\n * currently selected block — the same place a freshly added block\n * would land. The new copy then becomes the selected block.\n *\n * If there is no selected block at paste time, the copy is appended as a new row\n * at the end of the active document.\n *\n * While the user is editing text inside a block (Froala active) we defer to the\n * browser's native copy/paste so normal text editing keeps working.\n *\n * Exposes:\n * window.FlowCanvas.initCopyPaste(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n // Internal clipboard. Holds the cleaned outerHTML of the last copied block so\n // each paste is an independent, fully-detached copy.\n let clipboardHtml = null;\n\n // The page the user last interacted with (content page or cover page). Used so\n // a paste with no selected block lands on the ACTIVE page — not always page 1.\n let activePage = null;\n\n const hash = () => (window.FlowCanvas.generateHash\n ? window.FlowCanvas.generateHash()\n : Math.random().toString(16).slice(2));\n\n // Strip editor chrome + transient state from a clone so the pasted block\n // starts clean. Chrome (grips, badges, resize handles) is re-added on demand.\n const cleanClone = (clone) => {\n clone\n .querySelectorAll('[data-cs-chrome], .cs-block-grip, .cs-block-badge, .cs-resize-handle, .cs-overflow-mark')\n .forEach((el) => el.remove());\n\n const scrub = (el) => {\n el.classList?.remove('cs-selected', 'cs-editing', 'cs-block--dragging');\n // Drop contenteditable so the copy isn't stuck in an editing state, but\n // KEEP Froala's fr-view / fr-element classes — they carry the rendering\n // styles (e.g. table borders/layout) the block needs to display.\n if (el.hasAttribute?.('contenteditable')) el.removeAttribute('contenteditable');\n };\n scrub(clone);\n clone.querySelectorAll('*').forEach(scrub);\n return clone;\n };\n\n // Give the clone (and every descendant carrying an id) a brand-new id so we\n // never end up with duplicate ids in the DOM. The prefix is preserved so the\n // twig generator / style code keeps recognising the element kind.\n const regenerateIds = (root) => {\n const reassign = (el) => {\n if (!el.id) return;\n const prefix = el.id.includes('_') ? el.id.slice(0, el.id.lastIndexOf('_')) : el.id;\n el.id = `${prefix}_${hash()}`;\n };\n reassign(root);\n root.querySelectorAll('[id]').forEach(reassign);\n };\n\n // The whole multi-page board. Pages (content pages AND cover pages) live in\n // separate `.custom-form-design` wrappers under one `.cs_paper`, so a single\n // page's canvas does NOT contain blocks on the other pages. Containment checks\n // must use the board, or copy/paste from a cover page falls back to page 1.\n const boardOf = (canvas) =>\n canvas?.closest?.('.cs_paper') || document.querySelector('.cs_paper') || canvas;\n\n const isFlowBlock = (el, canvas) => (\n el && el.matches?.('.cs_block_s, .canvas-block') &&\n boardOf(canvas).contains(el) &&\n !el.dataset.csInSection &&\n el.parentElement?.matches?.('.col-item')\n );\n\n // Containers that hold absolutely-positioned (\"free\") children: a flexible\n // box, a cover page, or a group. A block's free parent is its IMMEDIATE such\n // container — so paste/duplicate lands back in the SAME place (same cover\n // page, same group, same flexible box).\n const FREE_PARENT_SEL = '.cs-flexible-content, .cs-group-block, [data-cs-cover=\"1\"]';\n const freeParentOf = (el, canvas) => {\n if (!el || !boardOf(canvas).contains(el)) return null;\n const p = el.parentElement;\n return p && p.matches?.(FREE_PARENT_SEL) ? p : null;\n };\n\n // A block that lives inside any free-positioning container (flexible / cover /\n // group). Its paste/duplicate should land back inside that same container.\n const isFlexibleChild = (el, canvas) => !!freeParentOf(el, canvas);\n\n const copySelected = () => {\n const EM = window.EditorManager;\n const block = EM?.getSelected?.();\n if (!block) return false;\n\n const clone = cleanClone(block.cloneNode(true));\n clipboardHtml = clone.outerHTML;\n\n // Also overwrite the SYSTEM clipboard with this block's text. The paste\n // handler treats \"an image sits on the clipboard\" as a newer external copy\n // that should out-rank the in-memory block — so a picture copied earlier\n // must not linger and hijack a fresh block copy. Writing here clears it.\n // Best-effort: if clipboard-write isn't permitted we still have clipboardHtml.\n try {\n const text = (block.innerText || '').trim() || ' ';\n navigator.clipboard?.writeText?.(text)?.catch?.(() => { });\n } catch (e) { /* clipboard API unavailable — ignore */ }\n return true;\n };\n\n // Build a detached block element from the stored clipboard markup.\n const buildPasteBlock = () => {\n if (!clipboardHtml) return null;\n const tmp = document.createElement('div');\n tmp.innerHTML = clipboardHtml;\n const block = tmp.firstElementChild;\n if (!block) return null;\n regenerateIds(block);\n return block;\n };\n\n const colsOfRow = (row) => (\n row ? Array.from(row.children).filter((c) => c.matches?.('.col-item')) : []\n );\n\n // Where should a new block land, relative to an anchor block?\n // - anchor's row has MULTIPLE columns → place in the same column, right\n // after the anchor (keeps the multi-column layout).\n // - anchor's row has a SINGLE column → create a brand-new row right after\n // the current one (like adding a fresh block to a row).\n // Fall back to a new row at the end of the doc when there is no anchor.\n const resolvePasteTarget = (canvas, anchor) => {\n // Anchor inside a free-positioning container (flexible / cover / group) →\n // paste back into that SAME container as an absolute child.\n const freeParent = freeParentOf(anchor, canvas);\n if (freeParent) {\n return { freeParent, target: { kind: 'in-free', parent: freeParent } };\n }\n\n if (isFlowBlock(anchor, canvas)) {\n const col = anchor.closest('.col-item');\n const row = anchor.closest('.row-item');\n const doc = anchor.closest('.cs_margin');\n if (col && row && doc) {\n if (colsOfRow(row).length > 1) {\n return { doc, target: { kind: 'in-col', col, beforeBlock: anchor.nextElementSibling || null } };\n }\n return {\n doc,\n target: { kind: 'between-rows', parent: row.parentElement || doc, beforeRow: row.nextElementSibling || null },\n };\n }\n }\n\n // No usable anchor: drop onto the ACTIVE page (the last page the user\n // touched), honouring cover pages (absolute) vs content pages (flow).\n const board = boardOf(canvas);\n const page = activePage && board.contains(activePage) ? activePage : null;\n if (page && page.matches('[data-cs-cover=\"1\"]')) {\n return { freeParent: page, target: { kind: 'in-free', parent: page } };\n }\n const doc = (page && page.matches('.cs_margin') ? page : null) || board.querySelector('.cs_margin');\n if (!doc) return null;\n return { doc, target: { kind: 'between-rows', parent: doc, beforeRow: null } };\n };\n\n // Place a ready-built block next to an anchor, then select it (immediate\n // feedback + becomes the next paste/duplicate anchor).\n const placeAndSelect = (canvas, newBlock, anchor) => {\n if (!newBlock) return null;\n\n // ---- Container-selected paste: paste INTO the container, not next to it ----\n\n // List container (the col itself) is selected → create a new synced block\n // in this col and all sibling cols, same as adding a block to the list.\n if (anchor?.classList?.contains('cs-synclist__col') && window.SyncList?.pasteIntoCol) {\n const placed = window.SyncList.pasteIntoCol(anchor, newBlock);\n if (placed) {\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(placed)) return;\n placed.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n placed.click();\n });\n return placed;\n }\n }\n\n // Group block selected → paste as a free child inside the group.\n if (anchor?.classList?.contains('cs-group-block')) {\n newBlock.dataset.csInSection = '1';\n newBlock.style.position = 'absolute';\n newBlock.style.left = '8px';\n newBlock.style.top = '8px';\n anchor.appendChild(newBlock);\n window.FlowCanvas?.refitGroupToChildren?.(anchor);\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(newBlock)) return;\n newBlock.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n newBlock.click();\n });\n return newBlock;\n }\n\n // Flexible block or Section container selected → paste into the inner free\n // canvas (cs-flexible-content) or the section's row/col content area.\n const innerCanvas = anchor?.querySelector?.(':scope > .cs-flexible-content');\n const innerSection = !innerCanvas && anchor?.querySelector?.(':scope > .section-container-content');\n if (innerCanvas) {\n newBlock.dataset.csInSection = '1';\n newBlock.style.position = 'absolute';\n newBlock.style.left = '8px';\n newBlock.style.top = '8px';\n innerCanvas.appendChild(newBlock);\n window.FlowCanvas?.syncFlexibleContentBounds?.(anchor);\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(newBlock)) return;\n newBlock.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n newBlock.click();\n });\n return newBlock;\n }\n if (innerSection) {\n const doc = anchor.closest('.cs_margin');\n window.FlowCanvas?.placeBlock?.(doc, newBlock,\n { kind: 'between-rows', parent: innerSection, beforeRow: null });\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(newBlock)) return;\n newBlock.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n newBlock.click();\n });\n return newBlock;\n }\n\n // ---- List-aware paste for child blocks already inside a col ----\n // Two cases, both keep the synced behaviour:\n // - a whole Container (column) was copied → add it as a new column whose\n // children clone into the existing sync groups (handleColumnPaste);\n // - a content block was copied while a child-block anchor → paste into\n // that column + clone across the others as a new group (handlePaste).\n if (anchor?.closest?.('.cs-synclist__col') && window.SyncList) {\n const isColumn = newBlock.classList?.contains('cs-synclist__col');\n const fn = isColumn ? window.SyncList.handleColumnPaste : window.SyncList.handlePaste;\n const placed = fn && fn(anchor, newBlock);\n if (placed) {\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(placed)) return;\n placed.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n placed.click();\n });\n return placed;\n }\n }\n\n const placement = resolvePasteTarget(canvas, anchor);\n if (!placement) return null;\n\n if (placement.freeParent) {\n // Append as an absolutely-positioned child of the SAME container (cover\n // page / group / flexible box), offset 10px so the copy is visible.\n const parent = placement.freeParent;\n newBlock.dataset.csInSection = '1';\n newBlock.style.position = 'absolute';\n const left = parseFloat(newBlock.style.left) || 0;\n const top = parseFloat(newBlock.style.top) || 0;\n newBlock.style.left = `${left + 10}px`;\n newBlock.style.top = `${top + 10}px`;\n parent.appendChild(newBlock);\n const wrapper = parent.closest('.cs-flexible-block') || parent;\n window.FlowCanvas?.syncFlexibleContentBounds?.(wrapper);\n // Pasted into a group → grow the group so it wraps the new child.\n if (parent.classList.contains('cs-group-block')) {\n window.FlowCanvas?.refitGroupToChildren?.(parent);\n }\n } else {\n window.FlowCanvas?.placeBlock?.(placement.doc, newBlock, placement.target);\n }\n\n requestAnimationFrame(() => {\n if (!boardOf(canvas).contains(newBlock)) return;\n newBlock.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n newBlock.click();\n });\n return newBlock;\n };\n\n // The block a freshly-pasted block should anchor next to (or into, for\n // container selections). Returns null only when there's nothing useful selected.\n const currentAnchor = (canvas) => {\n const anchor = window.EditorManager?.getSelected?.();\n if (!anchor) return null;\n // Synclist col selected (paste INTO col) or child block inside a col.\n if (anchor.closest?.('.cs-synclist__col')) return anchor;\n // Regular flow block or flexible child (paste next to it).\n if (isFlowBlock(anchor, canvas) || isFlexibleChild(anchor, canvas)) return anchor;\n // Group selected directly (paste inside group).\n if (anchor.classList?.contains('cs-group-block')) return anchor;\n return null;\n };\n\n const pasteBlock = (canvas) => (\n !!placeAndSelect(canvas, buildPasteBlock(), currentAnchor(canvas))\n );\n\n /* ------------------- external clipboard → new block ----------------------- */\n // Pasting content copied from another site/app (when NOT editing a block)\n // auto-creates the matching block, just like adding it from the sidebar and\n // then filling in the content:\n // - image data → an Image block showing the pasted picture;\n // - text → a Textarea (body-text) block holding the pasted text.\n\n // Escape plain text for safe innerHTML and keep line breaks visible (newlines\n // → <br>) so a multi-line paste reads the same as when it was copied.\n const textToHtml = (text) => text\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\\r\\n?|\\n/g, '<br>');\n\n // Build an Image block populated with the pasted picture. Mirrors the\n // image-upload handler in flow-canvas.js: drop the \"click to select\"\n // placeholder and append a real <img> the container styles fill.\n const buildPastedImageBlock = (dataUrl) => {\n const block = window.FlowCanvas.createBlock?.('image');\n if (!block) return null;\n const container = block.querySelector('.image-container');\n if (container) {\n container.querySelector('.img-btn')?.remove();\n container.querySelector('img')?.remove();\n const img = document.createElement('img');\n img.src = dataUrl;\n img.alt = 'Pasted image';\n container.appendChild(img);\n }\n return block;\n };\n\n // Build a Textarea (body-text) block holding the pasted text.\n const buildPastedTextBlock = (text) => {\n const block = window.FlowCanvas.createBlock?.('body-text');\n if (!block) return null;\n const editable = block.querySelector('.edit_me');\n if (editable) editable.innerHTML = textToHtml(text);\n return block;\n };\n\n // Place the clipboard's image as a NEW Image block on the canvas, next to\n // `anchor`. Used both for plain canvas paste AND to push an image OUT of a\n // text block (a picture must never live inside a Title/Textarea — it lands in\n // the parent instead). Returns true when an image was found and placement\n // kicked off.\n //\n // A pasted image file (\"Copy image\") is always handled. An <img> embedded in\n // copied rich HTML (e.g. a web-page region) is only handled when\n // `includeHtmlImg` is set — on the canvas a text+image region stays a text\n // block (keeps the text); ejecting from a text block extracts the picture.\n const placePastedImageBlock = (canvas, clipboardData, anchor, includeHtmlImg = false) => {\n if (!clipboardData) return false;\n\n const imageItem = Array.from(clipboardData.items || [])\n .find((it) => it.kind === 'file' && it.type.startsWith('image/'));\n if (imageItem) {\n const file = imageItem.getAsFile();\n if (file) {\n const reader = new FileReader();\n reader.onload = (e) => {\n placeAndSelect(canvas, buildPastedImageBlock(e.target.result), anchor);\n };\n reader.readAsDataURL(file);\n return true;\n }\n }\n\n if (includeHtmlImg) {\n const html = clipboardData.getData('text/html') || '';\n const src = html.match(/<img\\b[^>]*\\bsrc\\s*=\\s*[\"']([^\"']+)[\"']/i)?.[1];\n if (src) {\n placeAndSelect(canvas, buildPastedImageBlock(src), anchor);\n return true;\n }\n }\n return false;\n };\n\n // Public: duplicate a specific block (used by the badge \"duplicate\" action).\n // Clones the live block (content + styles) and drops the copy next to it.\n window.FlowCanvas.duplicateBlock = (block) => {\n if (!block) return null;\n const canvas = block.closest('.cs-flow-canvas') || document.querySelector('.cs-flow-canvas');\n if (!canvas) return null;\n const clone = cleanClone(block.cloneNode(true));\n regenerateIds(clone);\n const anchor = (isFlowBlock(block, canvas) || isFlexibleChild(block, canvas)) ? block : null;\n return placeAndSelect(canvas, clone, anchor);\n };\n\n /* ----------------------- reusable component library ----------------------- */\n // Capture/insert reuse the exact clone/clean/regenerate/place pipeline so a\n // saved component behaves like a freshly-dropped block (single OR a container\n // block such as a section/flexible that groups several children).\n const getCanvasEl = () =>\n document.querySelector('.cs-flow-canvas') || document.querySelector('.custom-form-design');\n\n // Build a fresh, id-unique block element from stored component HTML.\n window.FlowCanvas.buildComponentBlock = (html) => {\n if (!html) return null;\n const tmp = document.createElement('div');\n tmp.innerHTML = html;\n const block = tmp.firstElementChild;\n if (!block) return null;\n regenerateIds(block);\n return block;\n };\n\n // Snapshot the currently selected block as a reusable component.\n window.FlowCanvas.captureComponent = () => {\n const block = window.EditorManager?.getSelected?.();\n if (!block) return null;\n const clone = cleanClone(block.cloneNode(true));\n const isGroup = !!clone.querySelector('.cs_block_s, .canvas-block, .row-item');\n const thumbnail = (block.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 40)\n || block.getAttribute('custom-name') || block.dataset.blockType || 'Component';\n return { html: clone.outerHTML, kind: isGroup ? 'group' : 'single', thumbnail };\n };\n\n // Insert a component (click path) next to the current selection.\n window.FlowCanvas.insertComponentHtml = (html) => {\n const canvas = getCanvasEl();\n const block = window.FlowCanvas.buildComponentBlock(html);\n if (!canvas || !block) return false;\n const anchor = window.EditorManager?.getSelected?.();\n const useAnchor = (isFlowBlock(anchor, canvas) || isFlexibleChild(anchor, canvas)) ? anchor : null;\n return !!placeAndSelect(canvas, block, useAnchor);\n };\n\n window.FlowCanvas.initCopyPaste = function (canvas) {\n if (!canvas || canvas.dataset.copyPasteInit === '1') return;\n canvas.dataset.copyPasteInit = '1';\n\n // Track the page the user last pressed on, so a paste with no selection\n // lands on the active page (cover or content) instead of always page 1.\n document.addEventListener('pointerdown', (e) => {\n const p = e.target?.closest?.('.cs_margin, .cs_page[data-cs-cover=\"1\"]');\n if (p) activePage = p;\n }, true);\n\n // Expose the active page so other features (e.g. the per-page background\n // shape designer) can target the page the user is currently working on.\n window.FlowCanvas.getActivePage = () =>\n (activePage && document.contains(activePage) ? activePage : null);\n\n document.addEventListener('keydown', (event) => {\n const ctrl = event.ctrlKey || event.metaKey;\n if (!ctrl) return;\n\n const key = event.key.toLowerCase();\n if (key !== 'c' && key !== 'v' && key !== 'd') return;\n\n // While editing text inside a block, defer to native copy/paste.\n if (window.EditorManager?.getEditing?.()) return;\n\n const target = event.target;\n const inEditable = target?.isContentEditable ||\n target?.tagName === 'INPUT' ||\n target?.tagName === 'TEXTAREA';\n if (inEditable) return;\n\n // Ctrl/Cmd+D → duplicate the selected block in place.\n if (key === 'd') {\n const sel = window.EditorManager?.getSelected?.();\n if (sel) {\n event.preventDefault();\n window.FlowCanvas.duplicateBlock?.(sel);\n }\n return;\n }\n\n // Only handle COPY here. Paste (Ctrl+V) is deliberately left to the native\n // `paste` listener below, so it can inspect the REAL system clipboard.\n // That's what lets a freshly-copied external image/text win over a\n // previously-copied internal block — hijacking paste here would always\n // re-insert the in-memory block and never even look at the clipboard.\n if (key === 'c') {\n // Only hijack copy when a block is actually selected; otherwise let the\n // browser copy any plain text selection normally.\n if (copySelected()) event.preventDefault();\n }\n });\n\n // Native paste. Two contexts:\n // 1. Editing a text block (or focused in a field) → text blocks accept\n // TEXT ONLY. A pasted picture never lands inside a Title/Textarea; it\n // is ejected to the parent as its own Image block instead, while any\n // accompanying text still pastes into the block.\n // 2. Not editing (canvas) → drop the highest-priority clipboard content\n // as a new block: external image → Image, else internal block, else\n // external text → Textarea.\n //\n // CAPTURE phase (the `true` below): this must run BEFORE the active text\n // editor's (Froala's) own paste handler so we can stop it from inserting an\n // image into a text block. Cases we don't handle return early WITHOUT\n // stopping propagation, so the editor / table handlers still run normally.\n document.addEventListener('paste', (event) => {\n const target = event.target;\n const clipboardData = event.clipboardData;\n\n const inEditable = target?.isContentEditable ||\n target?.tagName === 'INPUT' ||\n target?.tagName === 'TEXTAREA';\n\n // --- text-editing context: keep images out of text blocks ---\n if (window.EditorManager?.getEditing?.() || inEditable) {\n // Plain fields can't hold images, and the Table block runs its own\n // text-only paste handler — leave both to their native behaviour.\n if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return;\n if (target?.closest?.('table')) return;\n\n // Does the clipboard carry an image? Either a pasted file (\"Copy\n // image\") or an <img> embedded in copied rich HTML (a web-page region).\n const html = clipboardData?.getData('text/html') || '';\n const hasImageFile = Array.from(clipboardData?.items || [])\n .some((it) => it.kind === 'file' && it.type.startsWith('image/'));\n const htmlHasImage = /<img\\b/i.test(html);\n\n // No image → let the native (rich) text paste run untouched.\n if (!hasImageFile && !htmlHasImage) return;\n\n // Image present → block the paste so no picture is inserted into the\n // text block. preventDefault alone is NOT enough: the active editor\n // (Froala) inserts the image programmatically from its OWN paste\n // handler, immune to the default-action cancel. stopImmediatePropagation\n // (this listener runs at capture phase, before the editor's handler)\n // stops that handler from ever firing. Then keep any accompanying plain\n // text in the block and eject the image to the parent as an Image block.\n event.preventDefault();\n event.stopImmediatePropagation();\n const text = clipboardData?.getData('text/plain') || '';\n if (text) {\n try { document.execCommand('insertText', false, text); } catch (e) { /* */ }\n }\n\n // Anchor the new Image block next to this text block so it lands right\n // in the parent (column / row), not somewhere unrelated.\n const editingBlock = window.EditorManager?.getEditing?.();\n const board = boardOf(canvas);\n const onCanvas = board.contains(target) ||\n (editingBlock && board.contains(editingBlock));\n if (onCanvas) {\n const anchor = currentAnchor(canvas) ||\n ((editingBlock && (isFlowBlock(editingBlock, canvas) || isFlexibleChild(editingBlock, canvas)))\n ? editingBlock : null);\n placePastedImageBlock(canvas, clipboardData, anchor, true);\n }\n return;\n }\n\n // --- canvas context: decide what Ctrl+V drops in ---\n // Priority:\n // 1. An external IMAGE on the clipboard. It can only have come from a\n // copy made AFTER any internal block copy (an internal copy never\n // writes to the system clipboard), so it reflects the latest intent.\n // This is the fix for \"copy a block, copy an image elsewhere, paste →\n // the block came back\": now the image wins.\n // 2. A previously-copied internal block (held in memory).\n // 3. External text.\n const anchor = currentAnchor(canvas);\n if (placePastedImageBlock(canvas, clipboardData, anchor)) {\n event.preventDefault();\n return;\n }\n if (clipboardHtml && pasteBlock(canvas)) {\n event.preventDefault();\n return;\n }\n const text = clipboardData?.getData('text/plain');\n if (text && text.trim()) {\n placeAndSelect(canvas, buildPastedTextBlock(text), anchor);\n event.preventDefault();\n }\n }, true);\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/active-page.js\">\n/**\n * @fileoverview Scroll-driven \"active page\" tracker.\n *\n * As the user scrolls (or clicks) through the document, the page currently in\n * view is marked as the active/selected page by adding a class to its `.cs_page`\n * wrapper — so the rest of the editor can identify which page the user is\n * working on:\n *\n * - Cover page (.cs_page[data-cs-cover=\"1\"]) → `cs_selected`\n * - Content page (.cs_page wrapping a .cs_margin) → `cs_selected_border`\n *\n * Only ONE page carries a selection class at a time. These classes are\n * editor-only and are stripped from the exported markup by the Twig generator\n * (common-twig-generator.js).\n *\n * Exposes:\n * window.FlowCanvas.getSelectedPage() — the selected `.cs_page` element\n * window.FlowCanvas.getSelectedDrawablePage() — cover `.cs_page`, or the content\n * page's inner `.cs_margin`\n * (what per-page features target)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const PAGE_SEL = '.cs_page';\n const SEL_CLASSES = ['cs_selected', 'cs_selected_border'];\n\n let selectedPage = null;\n let rafId = 0;\n\n // This script runs inside the editor iframe, but the iframe is sized to its\n // full content height — so it never scrolls itself; the HOST (Angular shell)\n // scroll container is what actually moves. We must therefore measure page\n // visibility against the HOST viewport, mapping each page's iframe-local rect\n // into host coordinates via the iframe element's position in the host.\n const hostWin = (() => {\n try { return window.parent && window.parent !== window ? window.parent : window; } catch (e) { return window; }\n })();\n const isEmbedded = hostWin !== window;\n const frameEl = (() => { try { return window.frameElement || null; } catch (e) { return null; } })();\n\n const allPages = () => Array.from(document.querySelectorAll(PAGE_SEL));\n\n // Viewport height + the vertical offset to add to an iframe-local rect to get\n // its position in the visible (host) viewport.\n const viewport = () => {\n if (isEmbedded && frameEl) {\n let frameTop = 0;\n try { frameTop = frameEl.getBoundingClientRect().top; } catch (e) { frameTop = 0; }\n const vh = hostWin.innerHeight || hostWin.document?.documentElement?.clientHeight || 0;\n return { vh, offset: frameTop };\n }\n return { vh: window.innerHeight || document.documentElement.clientHeight || 0, offset: 0 };\n };\n\n // The page whose visible area best covers the viewport centre wins. A page\n // that straddles the centre line always beats one that's merely partly\n // visible, so the \"current\" page is stable while scrolling.\n const pickMostVisible = () => {\n const pages = allPages();\n if (!pages.length) return null;\n const { vh, offset } = viewport();\n if (!vh) return pages[0];\n const centerY = vh / 2;\n let best = null, bestScore = -Infinity;\n for (const p of pages) {\n const r = p.getBoundingClientRect();\n if (r.height === 0) continue;\n const top = r.top + offset; // host-viewport coordinates\n const bottom = r.bottom + offset;\n if (bottom <= 0 || top >= vh) continue; // off-screen\n const overlap = Math.max(0, Math.min(bottom, vh) - Math.max(top, 0));\n const containsCenter = top <= centerY && bottom >= centerY;\n const score = overlap + (containsCenter ? 1e7 : 0);\n if (score > bestScore) { bestScore = score; best = p; }\n }\n return best || pages[0];\n };\n\n // Report \"page X of Y\" to the host shell, but only when it actually changes\n // (apply() runs on every scroll frame, so guard against spamming postMessage).\n let lastPostedKey = '';\n const postActivePage = (page) => {\n const pages = allPages();\n const index = pages.indexOf(page) + 1; // 1-based; 0 if not found\n const total = pages.length;\n // This page's own background image (set per-page by flow-canvas) so the host\n // panel preview reflects whichever page is in view.\n const drawable = page.matches('[data-cs-cover=\"1\"]') ? page : (page.querySelector(':scope > .cs_margin') || page);\n const bgImage = (drawable && drawable.dataset.csBgImage) || '';\n const key = `${index}/${total}`;\n if (key === lastPostedKey) return;\n lastPostedKey = key;\n try {\n hostWin.postMessage({ source: 'custom-form-twig', type: 'page:active', index, total, bgImage }, '*');\n } catch (e) { /* parent on different origin — ignore */ }\n };\n\n const apply = (page) => {\n if (!page) return;\n selectedPage = page;\n allPages().forEach((p) => {\n if (p !== page) p.classList.remove(...SEL_CLASSES);\n });\n const isCover = page.matches('[data-cs-cover=\"1\"]');\n page.classList.toggle('cs_selected', isCover);\n page.classList.toggle('cs_selected_border', !isCover);\n postActivePage(page);\n };\n\n // While we're deliberately scrolling to a just-added page, suppress the\n // scroll-driven recompute so it can't momentarily reselect the old page\n // (the page-add MutationObserver fires before the scroll has moved).\n let focusLock = false;\n let focusLockTimer = 0;\n\n const update = () => { if (!focusLock) apply(pickMostVisible()); };\n\n const scheduleUpdate = () => {\n if (rafId) return;\n rafId = requestAnimationFrame(() => { rafId = 0; update(); });\n };\n\n /* ------------------------- scroll to a given page ------------------------- */\n\n // The nearest scrollable ancestor of the iframe in the HOST document.\n const findScrollable = (node) => {\n let n = node ? node.parentElement : null;\n while (n && n.nodeType === 1) {\n let oy = '';\n try { oy = hostWin.getComputedStyle(n).overflowY; } catch (e) { /* */ }\n if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1) return n;\n n = n.parentElement;\n }\n return null;\n };\n\n const hostScroller = () => {\n if (isEmbedded && frameEl) {\n try { return frameEl.closest('.canvas-stage') || findScrollable(frameEl); } catch (e) { /* */ }\n }\n return null;\n };\n\n // Scroll the host so `page` sits near the top of the viewport, then keep it\n // selected. The iframe is resized by the host asynchronously after a page is\n // added, so the target may be momentarily out of scroll range — retry until\n // it's reachable (or attempts run out).\n const scrollHostToPage = (page) => {\n const scroller = hostScroller();\n if (!scroller) {\n try { page.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { /* */ }\n return;\n }\n const PAD = 24;\n let tries = 0;\n const attempt = () => {\n tries += 1;\n let frameTop = 0;\n try { frameTop = frameEl.getBoundingClientRect().top; } catch (e) { frameTop = 0; }\n const sRect = scroller.getBoundingClientRect();\n const pageTopInScroller =\n scroller.scrollTop + (frameTop + page.getBoundingClientRect().top) - sRect.top;\n const target = Math.max(0, pageTopInScroller - PAD);\n const maxTop = scroller.scrollHeight - scroller.clientHeight;\n // Iframe hasn't grown to include the new page yet → wait and retry.\n if (target > maxTop + 2 && tries < 15) { hostWin.setTimeout(attempt, 40); return; }\n try { scroller.scrollTo({ top: Math.min(target, maxTop), behavior: 'smooth' }); }\n catch (e) { scroller.scrollTop = Math.min(target, maxTop); }\n };\n attempt();\n };\n\n /* -------------------------------- public --------------------------------- */\n\n window.FlowCanvas.getSelectedPage = () =>\n (selectedPage && document.contains(selectedPage) ? selectedPage : null);\n\n // The element per-page features should target: a cover IS its own page; a\n // content wrapper exposes its inner .cs_margin.\n window.FlowCanvas.getSelectedDrawablePage = () => {\n const sel = window.FlowCanvas.getSelectedPage();\n if (!sel) return null;\n if (sel.matches('[data-cs-cover=\"1\"]')) return sel;\n return sel.querySelector(':scope > .cs_margin') || sel;\n };\n\n // Scroll to a page (e.g. a freshly added one) and mark it selected. `el` may\n // be the `.cs_page` wrapper itself or any element inside it (e.g. a .cs_margin).\n window.FlowCanvas.focusPage = (el) => {\n if (!el) return;\n const page = el.closest(PAGE_SEL) || el;\n apply(page); // select immediately, don't wait for scroll\n focusLock = true; // hold the selection through the scroll\n if (focusLockTimer) hostWin.clearTimeout(focusLockTimer);\n focusLockTimer = hostWin.setTimeout(() => {\n focusLock = false; focusLockTimer = 0; update();\n }, 1200);\n scrollHostToPage(page);\n };\n\n /* --------------------------------- init ---------------------------------- */\n\n const init = () => {\n // `true` (capture) catches scrolls of any inner scroll container too, since\n // the scroll event doesn't bubble. The REAL scroller is in the host (the\n // iframe is full-height and never scrolls itself), so listen there too.\n window.addEventListener('scroll', scheduleUpdate, true);\n window.addEventListener('resize', scheduleUpdate);\n if (isEmbedded) {\n try {\n hostWin.addEventListener('scroll', scheduleUpdate, true);\n hostWin.addEventListener('resize', scheduleUpdate);\n } catch (e) { /* cross-origin host — fall back to iframe listeners */ }\n }\n\n // Clicking into a page selects it immediately (don't wait for a scroll).\n document.addEventListener('pointerdown', (e) => {\n const p = e.target?.closest?.(PAGE_SEL);\n if (p) apply(p);\n }, true);\n\n // Re-evaluate when pages are added / removed. Pages attach as direct\n // children of .cs_paper, so a shallow childList watch is enough (and avoids\n // firing on every nested content edit).\n const root = document.querySelector('.cs_paper') || document.body;\n if (root) new MutationObserver(scheduleUpdate).observe(root, { childList: true });\n\n update();\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', init);\n } else {\n init();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/image-zoom.js\">\n/**\n * @fileoverview Zoom + pan for image blocks while they're being edited.\n *\n * When an image block is in edit mode (`.cs-image-block.cs-editing`):\n * - Mouse wheel / trackpad scroll over the image zooms in/out, anchored to\n * the pointer (focal zoom).\n * - Once zoomed past 1x, dragging the image pans it within the block.\n *\n * The image stays clipped by `.image-container { overflow: hidden }`; we never\n * resize the block — we only scale/translate the <img> via a CSS transform.\n * State is written back to the <img> (inline `transform` + `data-cs-zoom/-pan-*`)\n * so it survives re-render, persists when editing stops, and serializes on\n * export (the inline style is cloned with the DOM).\n *\n * Exposes:\n * window.FlowCanvas.initImageZoom(canvas)\n */\n(function () {\n window.FlowCanvas = window.FlowCanvas || {};\n\n const MIN_ZOOM = 1; // 1x = the default object-fit: cover framing\n const MAX_ZOOM = 5; // hard cap so users can't lose the image entirely\n const ZOOM_STEP = 0.0015; // wheel delta → multiplicative zoom sensitivity\n\n const clamp = (v, min, max) => Math.min(Math.max(v, min), max);\n\n // Resolve the editable image under an event target, or null. Requires the\n // block to actually be in edit mode and to hold a real <img> (not the upload\n // placeholder button), so a fresh/empty image block is left alone.\n const resolveEditingImg = (target) => {\n const container = target?.closest?.('.image-container');\n if (!container) return null;\n const block = container.closest('.cs-image-block');\n if (!block || !block.classList.contains('cs-editing')) return null;\n const img = container.querySelector('img');\n if (!img) return null;\n return { block, container, img };\n };\n\n const readState = (img) => ({\n zoom: parseFloat(img.dataset.csZoom) || MIN_ZOOM,\n x: parseFloat(img.dataset.csPanX) || 0,\n y: parseFloat(img.dataset.csPanY) || 0,\n });\n\n // Clamp + write the transform. Pan is bounded so the scaled image always\n // keeps covering the container (no empty gaps at the edges). Returns the\n // values actually applied so callers can chain off the clamped result.\n const applyState = (img, container, next) => {\n const block = container.closest('.cs-image-block');\n const zoom = clamp(next.zoom, MIN_ZOOM, MAX_ZOOM);\n const rect = container.getBoundingClientRect();\n const maxX = (rect.width * (zoom - 1)) / 2;\n const maxY = (rect.height * (zoom - 1)) / 2;\n const x = clamp(next.x || 0, -maxX, maxX);\n const y = clamp(next.y || 0, -maxY, maxY);\n\n img.dataset.csZoom = zoom.toFixed(4);\n img.dataset.csPanX = x.toFixed(2);\n img.dataset.csPanY = y.toFixed(2);\n img.style.transformOrigin = 'center center';\n img.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;\n img.draggable = false; // kill the native image drag-ghost while interacting\n if (block) block.classList.toggle('cs-img-zoomed', zoom > MIN_ZOOM + 0.001);\n return { zoom, x, y };\n };\n\n // Re-apply (and re-clamp) the stored zoom/pan for a container's image. Called\n // after the frame shape changes, since a new shape can change the container's\n // size/aspect-ratio and therefore the valid pan range.\n window.FlowCanvas.refreshImageZoom = function (container) {\n const img = container?.querySelector?.('img');\n if (img) applyState(img, container, readState(img));\n };\n\n window.FlowCanvas.initImageZoom = function (canvas) {\n if (!canvas || canvas.dataset.imageZoomInit === '1') return;\n canvas.dataset.imageZoomInit = '1';\n\n /* ----------------------------- wheel = zoom ----------------------------- */\n const onWheel = (event) => {\n const ctx = resolveEditingImg(event.target);\n if (!ctx) return; // not over an editing image → let the page scroll\n event.preventDefault();\n event.stopPropagation();\n\n const { container, img } = ctx;\n const cur = applyState(img, container, readState(img)); // normalise first\n const rect = container.getBoundingClientRect();\n\n // Pointer offset from the container centre (the transform's origin).\n const u = event.clientX - (rect.left + rect.width / 2);\n const v = event.clientY - (rect.top + rect.height / 2);\n\n // The image-space point currently under the cursor — kept fixed so the\n // zoom grows/shrinks around the pointer instead of the centre.\n const focalX = (u - cur.x) / cur.zoom;\n const focalY = (v - cur.y) / cur.zoom;\n\n const factor = Math.exp(-event.deltaY * ZOOM_STEP);\n const zoom = clamp(cur.zoom * factor, MIN_ZOOM, MAX_ZOOM);\n\n applyState(img, container, {\n zoom,\n x: u - zoom * focalX,\n y: v - zoom * focalY,\n });\n };\n\n /* ------------------------------ drag = pan ------------------------------ */\n let pan = null;\n\n const onPointerDown = (event) => {\n if (event.pointerType === 'mouse' && event.button !== 0) return;\n const ctx = resolveEditingImg(event.target);\n if (!ctx) return;\n const cur = applyState(ctx.img, ctx.container, readState(ctx.img));\n if (cur.zoom <= MIN_ZOOM + 0.001) return; // no room to pan until zoomed\n\n // Own the gesture: stop the inline-editor's block-move/resize handlers\n // from also reacting to this press.\n event.preventDefault();\n event.stopPropagation();\n\n pan = {\n block: ctx.block,\n img: ctx.img,\n container: ctx.container,\n startX: event.clientX,\n startY: event.clientY,\n baseX: cur.x,\n baseY: cur.y,\n pointerId: event.pointerId,\n };\n try { ctx.img.setPointerCapture(event.pointerId); } catch (e) { /* */ }\n ctx.block.classList.add('cs-img-panning');\n };\n\n const onPointerMove = (event) => {\n if (!pan) return;\n event.preventDefault();\n applyState(pan.img, pan.container, {\n zoom: readState(pan.img).zoom,\n x: pan.baseX + (event.clientX - pan.startX),\n y: pan.baseY + (event.clientY - pan.startY),\n });\n };\n\n const endPan = () => {\n if (!pan) return;\n try { pan.img.releasePointerCapture(pan.pointerId); } catch (e) { /* */ }\n pan.block.classList.remove('cs-img-panning');\n pan = null;\n };\n\n // Bind to the whole board (.cs_paper) — not just page 1's canvas — so image\n // zoom/pan also works for images on added pages and cover pages, which live\n // in their own sibling `.custom-form-design` wrappers.\n const board = canvas.closest('.cs_paper') || canvas;\n // wheel must be non-passive so preventDefault can stop page scroll.\n board.addEventListener('wheel', onWheel, { passive: false });\n // Capture phase so we claim the press before the block move/resize logic.\n board.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('pointermove', onPointerMove, true);\n document.addEventListener('pointerup', endPan, true);\n document.addEventListener('pointercancel', endPan, true);\n // Belt-and-braces: suppress the browser's native image drag inside an\n // editing image (otherwise a pan can start a ghost-drag of the picture).\n board.addEventListener('dragstart', (event) => {\n if (resolveEditingImg(event.target)) event.preventDefault();\n }, true);\n };\n})();\n\n<\/script>\n <script data-src=\"./js/flow/page-shape-designer.js\">\n/**\n * @fileoverview Per-page background shape designer.\n *\n * Opens a full-screen modal whose drawing stage matches the real page's\n * width × height (aspect ratio). The user designs a vector shape with the\n * SAME pen tool used by the Pen Shape block (reused via window.PenShape).\n *\n * Shapes are PAGE-SPECIFIC: each page (a content page `.cs_margin` or a cover\n * page `.cs_page[data-cs-cover]`) carries its own `.cs-page-shape-bg` layer.\n * The designer targets ONE page at a time (defaulting to the page the user is\n * working on) and a page selector in the modal lets the user switch between\n * pages and add / edit / remove a shape on each independently. Saving applies\n * the design only to the pages edited in that session; other pages are left\n * untouched and newly-added pages start blank.\n *\n * The injected layer (.cs-page-shape-bg) is plain DOM inside the page — NOT\n * marked [data-cs-chrome] — so the Twig generator clones it and it exports to\n * the PDF. Critical styles are inlined so it renders even if a stylesheet is\n * missing.\n *\n * Opened from the Angular \"Style → Page Settings\" button via postMessage\n * (page-shape:open), wired in flow-canvas.js.\n *\n * Exposes:\n * window.PageShapeDesigner.open() — open the designer on the active page\n * window.PageShapeDesigner.removeFromActive() — remove the shape from the active page\n * window.PageShapeDesigner.clearAll() — remove the shape from every page\n */\n(function () {\n window.PageShapeDesigner = window.PageShapeDesigner || {};\n\n const LAYER_CLASS = 'cs-page-shape-bg';\n const PAGE_SEL = '.cs_margin, .cs_page[data-cs-cover=\"1\"]';\n const DEFAULT_W = 794, DEFAULT_H = 1123; // A4 @96dpi fallback\n\n let modal = null;\n let block = null;\n let targetPage = null; // the page currently shown in the designer\n let pageList = []; // pages captured when the modal opened (select order)\n let sessionDesigns = null; // Map<pageEl, design|null> edited during this session\n let uidSeq = 0; // ensures every injected layer gets globally-unique def ids\n\n // The modal is rendered in the HOST document (the Angular shell), NOT inside\n // this iframe — so it reads as a true root-level modal (like the save-as\n // modal) instead of being clipped to the canvas panel. Pages still live in\n // THIS document, so the page helpers keep using `document`.\n const hostWin = (() => { try { return window.parent && window.parent !== window ? window.parent : window; } catch (e) { return window; } })();\n const hostDoc = hostWin.document;\n\n // The modal + pen styling lives in editor.css, which the iframe loads but the\n // host page does not. Inject it into the host once so the modal is styled.\n const ensureHostStyles = () => {\n if (hostDoc === document) return; // standalone (not embedded) → already has it\n if (hostDoc.getElementById('cs-pen-host-styles')) return;\n const ownLink = document.querySelector('link[href*=\"editor.css\"]');\n const href = ownLink ? ownLink.getAttribute('href') : './editor/editor.css';\n const link = hostDoc.createElement('link');\n link.id = 'cs-pen-host-styles';\n link.rel = 'stylesheet';\n // Resolve relative to THIS iframe's document so the host can find the file.\n link.href = new URL(href, document.baseURI).href;\n hostDoc.head.appendChild(link);\n };\n\n /* ------------------------------ page helpers ------------------------------ */\n\n const getPageDims = () => {\n const cs = getComputedStyle(document.documentElement);\n const w = parseFloat(cs.getPropertyValue('--cs-page-width')) || DEFAULT_W;\n const h = parseFloat(cs.getPropertyValue('--cs-page-min-height')) || DEFAULT_H;\n return { w, h };\n };\n\n // Every page (content + cover) in document order.\n const getAllPages = () => Array.from(document.querySelectorAll(PAGE_SEL));\n const getPagesRoot = () => document.querySelector('.cs_paper')\n || document.querySelector('.cs_page')\n || document.querySelector('.custom-form-design');\n\n // A human label for the page selector, e.g. \"Cover Page 2\" / \"Content Page 1\".\n const labelPages = (pages) => {\n let cover = 0, content = 0;\n return pages.map((p) => {\n if (p.matches('[data-cs-cover=\"1\"]')) { cover += 1; return `Cover Page ${cover}`; }\n content += 1; return `Content Page ${content}`;\n });\n };\n\n // The page the user is currently working on — used as the default target.\n // Prefer the scroll-driven selection (the page in view), then the last page\n // the user clicked, then the first page.\n const resolveActivePage = () => {\n const sel = window.FlowCanvas?.getSelectedDrawablePage?.();\n if (sel && document.contains(sel) && sel.matches(PAGE_SEL)) return sel;\n const ap = window.FlowCanvas?.getActivePage?.();\n if (ap && document.contains(ap) && ap.matches(PAGE_SEL)) return ap;\n return getAllPages()[0] || null;\n };\n\n /* ---------------------- inject / read the bg layer ----------------------- */\n\n // Clone an <svg> and make every def id unique so multiple pages don't clash\n // (duplicate ids in one document make all gradients/patterns resolve to the\n // first one). Rewrites url(#id) references in fill/stroke too.\n const uniquifyIds = (svg, suffix) => {\n svg.querySelectorAll('[id]').forEach((el) => {\n const oldId = el.id;\n const newId = `${oldId}_${suffix}`;\n el.id = newId;\n svg.querySelectorAll('[fill],[stroke]').forEach((node) => {\n ['fill', 'stroke'].forEach((attr) => {\n const v = node.getAttribute(attr);\n if (v && v.includes(`#${oldId}`)) {\n node.setAttribute(attr, v.replace(`#${oldId})`, `#${newId})`));\n }\n });\n });\n });\n };\n\n // Inject the given design into ONE page (or, when design is empty, remove any\n // existing layer from that page). `design` = { svg, penPath, penStyle } | null.\n const injectLayer = (pageEl, design) => {\n pageEl.querySelectorAll(`:scope > .${LAYER_CLASS}`).forEach((el) => el.remove());\n if (!design || !design.svg) return;\n\n const layer = document.createElement('div');\n layer.className = LAYER_CLASS;\n layer.setAttribute('aria-hidden', 'true');\n // Inline the critical styles so the layer renders in the exported PDF even\n // if editor.css isn't loaded. z-index:0 keeps it above the page background\n // but below page content (which is forced to z-index:1 in custom-form.css).\n // Negative z-index + isolation are NOT used here because some PDF engines\n // (wkhtmltopdf) don't honour them and the shape would vanish.\n layer.style.cssText =\n 'position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden;';\n // Stash the editable model so re-opening the designer restores the shape.\n layer.dataset.penPath = design.penPath || '';\n layer.dataset.penStyle = design.penStyle || '';\n\n const wrap = document.createElement('div');\n wrap.innerHTML = design.svg;\n const svg = wrap.querySelector('svg');\n if (!svg) return;\n svg.setAttribute('preserveAspectRatio', 'none');\n svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;';\n uniquifyIds(svg, `pg${uidSeq += 1}`);\n layer._csShapeUniq = true; // mark so the new-page watcher won't re-uniquify\n layer.appendChild(svg);\n\n // Insert first so it paints first; z-index keeps it under content anyway.\n pageEl.insertBefore(layer, pageEl.firstChild);\n };\n\n // Read the design currently stored on a page (so the designer can restore it).\n const readDesignFromPage = (pageEl) => {\n if (!pageEl) return null;\n const layer = pageEl.querySelector(`:scope > .${LAYER_CLASS}`);\n if (!layer) return null;\n const svg = layer.querySelector('svg');\n return {\n svg: svg ? svg.outerHTML : '',\n penPath: layer.dataset.penPath || '',\n penStyle: layer.dataset.penStyle || '',\n };\n };\n\n /* --------------------------- block <-> design ----------------------------- */\n\n // Capture whatever is drawn in the editor right now as a design (or null when\n // nothing is drawn → treated as \"remove the shape\"). Ends the pen session so\n // the final <path>/<defs> are written + rendered.\n const captureBlock = () => {\n if (!block) return null;\n try { window.PenShape?.deactivate?.(); } catch (e) { /* */ }\n const svg = block.querySelector('.cs-pen-svg');\n const hasShape = svg && Array.from(svg.querySelectorAll('.cs-pen-fill'))\n .some((p) => (p.getAttribute('d') || '').trim().length > 0);\n if (!hasShape) return null;\n const clean = svg.cloneNode(true);\n return {\n svg: clean.outerHTML,\n penPath: block.dataset.penPath || '',\n penStyle: block.dataset.penStyle || '',\n };\n };\n\n // Load a design (or a blank shape) into the editor block, then repaint.\n const loadBlock = (design) => {\n if (!block) return;\n if (design && design.penPath) {\n block.dataset.penPath = design.penPath;\n block.dataset.penStyle = design.penStyle || '';\n } else {\n block.dataset.penPath = JSON.stringify({ paths: [] });\n block.dataset.penStyle = '';\n }\n try { window.PenShape.renderShape(block); } catch (e) { /* */ }\n };\n\n // (Re)start the pen session on the block and hand the engine the modal's\n // side panels. Deferred a frame so the stage has real dimensions.\n const activateBlock = () => {\n requestAnimationFrame(() => {\n if (!block || !modal) return;\n window.PenShape.activate(block);\n window.PenShape.setLayersPanel?.(modal.querySelector('[data-layers-list]'));\n window.PenShape.setPropsPanel?.(modal.querySelector('[data-props-host]'));\n layoutStage();\n });\n };\n\n /* --------------------------------- modal ---------------------------------- */\n\n const buildModal = (dims) => {\n const el = hostDoc.createElement('div');\n el.className = 'cs-page-shape-modal';\n el.innerHTML = `\n <div class=\"cs-page-shape-modal__backdrop\"></div>\n <div class=\"cs-page-shape-modal__panel\">\n <header class=\"cs-page-shape-modal__header\">\n <div class=\"cs-page-shape-modal__title\">\n Design Page Background\n <span class=\"cs-page-shape-modal__dims\">${Math.round(dims.w)} × ${Math.round(dims.h)} px</span>\n </div>\n <label class=\"cs-page-shape-modal__pagepick\">\n Page\n <select data-page-select></select>\n </label>\n <div class=\"cs-page-shape-modal__actions\">\n <button type=\"button\" data-act=\"clear\" class=\"cs-page-shape-btn cs-page-shape-btn--ghost\">Clear</button>\n <button type=\"button\" data-act=\"cancel\" class=\"cs-page-shape-btn cs-page-shape-btn--ghost\">Cancel</button>\n <button type=\"button\" data-act=\"save\" class=\"cs-page-shape-btn cs-page-shape-btn--primary\">Save &amp; Apply</button>\n </div>\n </header>\n <div class=\"cs-page-shape-modal__body\">\n <aside class=\"cs-page-shape-layers\">\n <div class=\"cs-page-shape-layers__title\">Layers</div>\n <div class=\"cs-page-shape-layers__list\" data-layers-list></div>\n <div class=\"cs-page-shape-layers__actions\">\n <button type=\"button\" data-layers-act=\"merge\" title=\"Merge selected layers\">Merge</button>\n <button type=\"button\" data-layers-act=\"lock\" title=\"Lock / unlock selected\">Lock</button>\n </div>\n <div class=\"cs-page-shape-layers__hint\">Ctrl/Cmd-click to multi-select · drag to reorder (top = front)</div>\n </aside>\n <div class=\"cs-page-shape-stagewrap\">\n <div class=\"cs-page-shape-stage\"></div>\n <div class=\"cs-page-shape-zoom\">\n <button type=\"button\" data-zoom=\"out\" title=\"Zoom out\">−</button>\n <button type=\"button\" data-zoom=\"fit\" class=\"cs-page-shape-zoom__val\" title=\"Reset to fit\">100%</button>\n <button type=\"button\" data-zoom=\"in\" title=\"Zoom in\">+</button>\n </div>\n </div>\n <aside class=\"cs-page-shape-shapes\" data-shapes-panel>\n <div class=\"cs-page-shape-shapes__title\">Trace reference</div>\n <div class=\"cs-page-shape-ref\">\n <label class=\"cs-page-shape-ref__btn\">\n <input type=\"file\" accept=\"image/*\" data-ref-file>\n <span>⬆&nbsp; Upload image</span>\n </label>\n <label class=\"cs-page-shape-ref__op\">\n <span>Dim</span>\n <input type=\"range\" min=\"5\" max=\"100\" value=\"45\" data-ref-op>\n </label>\n <label class=\"cs-page-shape-ref__chk\">\n <input type=\"checkbox\" data-trace-outline>\n <span>Outline only — mark without fill (so the image stays visible)</span>\n </label>\n <button type=\"button\" data-ref-clear class=\"cs-page-shape-ref__clear\">Remove reference</button>\n <p class=\"cs-page-shape-ref__hint\">Drop an image, dim it, then trace it with the pen tool. It's only a guide — it is NOT saved with the shape.</p>\n </div>\n <div class=\"cs-page-shape-shapes__title\">Properties</div>\n <div class=\"cs-page-shape-props\" data-props-host></div>\n <div class=\"cs-page-shape-shapes__title\">Shapes</div>\n <div class=\"cs-page-shape-size\">\n <label>W <input type=\"number\" data-shape-w min=\"10\" step=\"10\" value=\"220\"></label>\n <label>H <input type=\"number\" data-shape-h min=\"10\" step=\"10\" value=\"160\"></label>\n </div>\n <div class=\"cs-page-shape-shapes__grid\">\n <button type=\"button\" data-preset=\"rectangle\" title=\"Rectangle\">▭</button>\n <button type=\"button\" data-preset=\"square\" title=\"Square\">◻</button>\n <button type=\"button\" data-preset=\"rounded-rect\" title=\"Rounded rectangle\">▢</button>\n <button type=\"button\" data-preset=\"pill\" title=\"Pill / capsule\">⬭</button>\n <button type=\"button\" data-preset=\"ellipse\" title=\"Ellipse / circle\">◯</button>\n <button type=\"button\" data-preset=\"triangle\" title=\"Triangle\">△</button>\n <button type=\"button\" data-preset=\"triangle-down\" title=\"Triangle down\">▽</button>\n <button type=\"button\" data-preset=\"right-triangle\" title=\"Right triangle\">◣</button>\n <button type=\"button\" data-preset=\"diamond\" title=\"Diamond\">◇</button>\n <button type=\"button\" data-preset=\"pentagon\" title=\"Pentagon\">⬠</button>\n <button type=\"button\" data-preset=\"hexagon\" title=\"Hexagon\">⬡</button>\n <button type=\"button\" data-preset=\"heptagon\" title=\"Heptagon\">⬣</button>\n <button type=\"button\" data-preset=\"octagon\" title=\"Octagon\">⯃</button>\n <button type=\"button\" data-preset=\"parallelogram\" title=\"Parallelogram\">▰</button>\n <button type=\"button\" data-preset=\"trapezoid\" title=\"Trapezoid\">⏢</button>\n <button type=\"button\" data-preset=\"star\" title=\"Star (5)\">★</button>\n <button type=\"button\" data-preset=\"star-4\" title=\"Star (4)\">✦</button>\n <button type=\"button\" data-preset=\"star-6\" title=\"Star (6)\">✶</button>\n <button type=\"button\" data-preset=\"star-12\" title=\"Star (12)\">✺</button>\n <button type=\"button\" data-preset=\"burst\" title=\"Burst / seal\">❉</button>\n <button type=\"button\" data-preset=\"arrow-right\" title=\"Arrow right\">➜</button>\n <button type=\"button\" data-preset=\"arrow-left\" title=\"Arrow left\">⬅</button>\n <button type=\"button\" data-preset=\"arrow-up\" title=\"Arrow up\">⬆</button>\n <button type=\"button\" data-preset=\"arrow-down\" title=\"Arrow down\">⬇</button>\n <button type=\"button\" data-preset=\"arrow-h\" title=\"Double arrow (horizontal)\">↔</button>\n <button type=\"button\" data-preset=\"arrow-v\" title=\"Double arrow (vertical)\">↕</button>\n <button type=\"button\" data-preset=\"chevron\" title=\"Chevron\">❯</button>\n <button type=\"button\" data-preset=\"plus\" title=\"Plus / cross\">✚</button>\n <button type=\"button\" data-preset=\"heart\" title=\"Heart\">♥</button>\n <button type=\"button\" data-preset=\"speech\" title=\"Speech bubble\">💬</button>\n <button type=\"button\" data-preset=\"banner\" title=\"Banner / ribbon\">⚑</button>\n <button type=\"button\" data-preset=\"cloud\" title=\"Cloud\">☁</button>\n </div>\n <div class=\"cs-page-shape-shapes__title\">Page backgrounds</div>\n <div class=\"cs-page-shape-shapes__grid\">\n <button type=\"button\" data-preset=\"corner\" title=\"Corner wedge (full bleed)\">◣</button>\n <button type=\"button\" data-preset=\"diagonal\" title=\"Diagonal band (full bleed)\">◹</button>\n <button type=\"button\" data-preset=\"header\" title=\"Header bar (full bleed)\">▀</button>\n <button type=\"button\" data-preset=\"footer\" title=\"Footer bar (full bleed)\">▄</button>\n </div>\n </aside>\n </div>\n </div>`;\n return el;\n };\n\n // Fill the page selector with one option per page, marking the target page.\n const populatePageSelect = () => {\n const sel = modal?.querySelector('[data-page-select]');\n if (!sel) return;\n const labels = labelPages(pageList);\n sel.innerHTML = pageList\n .map((p, i) => `<option value=\"${i}\"${p === targetPage ? ' selected' : ''}>${labels[i]}</option>`)\n .join('');\n };\n\n // Fit the page (dims) inside the available modal body area, preserving aspect.\n // Sized against the HOST window (full app), since the modal lives there.\n const fitStageSize = (dims) => {\n const maxW = Math.max(200, hostWin.innerWidth - 460); // leave room for both side panels\n const maxH = Math.max(200, hostWin.innerHeight - 180);\n const scale = Math.min(maxW / dims.w, maxH / dims.h, 1);\n return { w: Math.round(dims.w * scale), h: Math.round(dims.h * scale) };\n };\n\n // Zoom multiplier on top of the fit size (1 = fit-to-window). Lets the user\n // zoom into the trace reference for precise anchor/handle placement; the\n // stagewrap scrolls when the stage grows past the viewport.\n let zoom = 1;\n\n const updateZoomLabel = () => {\n const el = modal && modal.querySelector('.cs-page-shape-zoom__val');\n if (el) el.textContent = `${Math.round(zoom * 100)}%`;\n };\n\n const setZoom = (z) => {\n zoom = Math.max(0.25, Math.min(6, z));\n layoutStage();\n };\n\n // Size the stage + drawing block to fit the host window, preserving the page\n // aspect ratio, then apply the zoom factor. Re-run on host window resize.\n const layoutStage = () => {\n if (!modal || !block) return;\n const dims = getPageDims();\n const fit = fitStageSize(dims);\n const w = Math.round(fit.w * zoom);\n const h = Math.round(fit.h * zoom);\n const stage = modal.querySelector('.cs-page-shape-stage');\n if (stage) { stage.style.width = `${w}px`; stage.style.height = `${h}px`; }\n block.style.width = `${w}px`;\n block.style.height = `${h}px`;\n updateZoomLabel();\n };\n let onResize = null;\n\n /* --------------------------- trace reference image ------------------------ */\n // A faint image behind the pen block that the user traces over (Photoshop\n // \"template layer\" style). It lives inside the stage, behind the pen overlay,\n // with pointer-events:none so every click still reaches the pen tool. It is\n // purely a guide — Save reads only the pen SVG, so the image never ends up in\n // the saved shape or the exported PDF.\n\n const refEl = () => modal && modal.querySelector('[data-ref-img]');\n\n const setReference = (url) => {\n const el = refEl();\n if (!el) return;\n if (url) { el.style.backgroundImage = `url(\"${url}\")`; el.classList.add('is-on'); }\n else { el.style.backgroundImage = 'none'; el.classList.remove('is-on'); }\n };\n\n const setReferenceOpacity = (pct) => {\n const el = refEl();\n if (el) el.style.opacity = String(Math.max(0, Math.min(1, (Number(pct) || 0) / 100)));\n };\n\n const loadReferenceFile = (file) => {\n if (!file || !/^image\\//.test(file.type || '')) return;\n const reader = new FileReader();\n reader.onload = () => setReference(reader.result);\n reader.readAsDataURL(file);\n };\n\n const close = () => {\n if (!modal) return;\n try { window.PenShape?.deactivate?.(); } catch (e) { /* */ }\n if (onResize) { hostWin.removeEventListener('resize', onResize); onResize = null; }\n modal.remove();\n modal = null;\n block = null;\n targetPage = null;\n pageList = [];\n sessionDesigns = null;\n };\n\n // Move to a different page: stash the current page's edits, then load the\n // selected page's design into the editor.\n const switchToPage = (pageEl) => {\n if (!pageEl || pageEl === targetPage) return;\n sessionDesigns.set(targetPage, captureBlock());\n targetPage = pageEl;\n const design = sessionDesigns.has(pageEl) ? sessionDesigns.get(pageEl) : readDesignFromPage(pageEl);\n loadBlock(design);\n activateBlock();\n };\n\n const save = () => {\n if (!block || !sessionDesigns) { close(); return; }\n // Capture the page currently open, then flush every page edited this\n // session. Pages never visited keep their existing layer untouched.\n sessionDesigns.set(targetPage, captureBlock());\n sessionDesigns.forEach((design, pageEl) => {\n if (document.contains(pageEl)) injectLayer(pageEl, design);\n });\n close();\n };\n\n const open = () => {\n if (modal) return;\n if (!window.PenShape || typeof window.PenShape.createBlock !== 'function') {\n console.warn('[PageShapeDesigner] PenShape engine not available');\n return;\n }\n\n pageList = getAllPages();\n targetPage = resolveActivePage();\n if (!targetPage) {\n console.warn('[PageShapeDesigner] no page to design');\n return;\n }\n if (!pageList.includes(targetPage)) pageList = getAllPages();\n sessionDesigns = new Map();\n\n // Render the modal in the HOST document (root) so it covers the whole app\n // like the save-as modal — no iframe resizing needed.\n ensureHostStyles();\n\n const dims = getPageDims();\n modal = buildModal(dims);\n hostDoc.body.appendChild(modal);\n populatePageSelect();\n\n const stage = modal.querySelector('.cs-page-shape-stage');\n zoom = 1;\n\n // Build a clean pen-shape block; layoutStage() sizes it to the stage.\n block = window.PenShape.createBlock();\n block.classList.add('cs-page-shape-block');\n block.style.margin = '0';\n\n // Trace-reference layer sits BEHIND the pen block (inserted first).\n const refImg = document.createElement('div');\n refImg.className = 'cs-page-shape-ref-img';\n refImg.setAttribute('data-ref-img', '');\n refImg.setAttribute('aria-hidden', 'true');\n refImg.style.opacity = '0.45';\n stage.appendChild(refImg);\n\n // Restore the target page's existing design (if any) into the editor.\n loadBlock(readDesignFromPage(targetPage));\n\n stage.appendChild(block);\n layoutStage();\n\n modal.addEventListener('change', (e) => {\n if (e.target.matches('[data-page-select]')) {\n const next = pageList[Number(e.target.value)];\n switchToPage(next);\n return;\n }\n if (e.target.matches('[data-ref-file]')) {\n loadReferenceFile(e.target.files && e.target.files[0]);\n return;\n }\n if (e.target.matches('[data-trace-outline]')) {\n const st = modal.querySelector('.cs-page-shape-stage');\n if (st) st.classList.toggle('cs-trace-outline', e.target.checked);\n }\n });\n\n modal.addEventListener('input', (e) => {\n if (e.target.matches('[data-ref-op]')) setReferenceOpacity(e.target.value);\n });\n\n modal.addEventListener('click', (e) => {\n const preset = e.target.closest('[data-preset]')?.dataset.preset;\n if (preset) {\n try {\n // Convert the W/H (page px) into viewBox units so the shape drops in\n // at the chosen size instead of filling the page.\n const dims = getPageDims();\n const VBU = window.PenShape?.VIEWBOX || 1000;\n const wpx = Number(modal.querySelector('[data-shape-w]')?.value) || 0;\n const hpx = Number(modal.querySelector('[data-shape-h]')?.value) || 0;\n const opts = (wpx > 0 && hpx > 0)\n ? { w: (wpx / dims.w) * VBU, h: (hpx / dims.h) * VBU }\n : null;\n window.PenShape?.loadPreset?.(preset, opts);\n } catch (err) { /* */ }\n return;\n }\n const zc = e.target.closest('[data-zoom]')?.dataset.zoom;\n if (zc) {\n if (zc === 'in') setZoom(zoom * 1.25);\n else if (zc === 'out') setZoom(zoom / 1.25);\n else setZoom(1);\n return;\n }\n if (e.target.closest('[data-ref-clear]')) {\n setReference(null);\n const f = modal.querySelector('[data-ref-file]');\n if (f) f.value = '';\n return;\n }\n const lact = e.target.closest('[data-layers-act]')?.dataset.layersAct;\n if (lact === 'merge') { try { window.PenShape?.mergeSelected?.(); } catch (err) { /* */ } return; }\n if (lact === 'lock') { try { window.PenShape?.toggleLockSelected?.(); } catch (err) { /* */ } return; }\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act === 'cancel') return close();\n if (act === 'save') return save();\n if (act === 'clear') { try { window.PenShape?.clearAllPaths?.(); } catch (err) { /* */ } return; }\n if (e.target.classList.contains('cs-page-shape-modal__backdrop')) return close();\n });\n\n // Ctrl/Cmd + wheel zooms the stage (the pen engine's ResizeObserver redraws\n // the overlay to the new size; the stagewrap scrolls when it overflows).\n const stagewrap = modal.querySelector('.cs-page-shape-stagewrap');\n if (stagewrap) {\n stagewrap.addEventListener('wheel', (e) => {\n if (!e.ctrlKey && !e.metaKey) return;\n e.preventDefault();\n setZoom(zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12));\n }, { passive: false });\n }\n\n // Re-fit when the host window resizes. The pen engine's ResizeObserver\n // redraws the overlay to the new size.\n onResize = () => layoutStage();\n hostWin.addEventListener('resize', onResize);\n\n // Activate the pen session once the stage has real dimensions.\n activateBlock();\n };\n\n // Remove the shape from the page the user is currently working on.\n const removeFromActive = () => {\n const page = resolveActivePage();\n if (!page) return;\n page.querySelectorAll(`:scope > .${LAYER_CLASS}`).forEach((el) => el.remove());\n };\n\n // Remove the shape from every page (used by tooling, not the per-page UI).\n const clearAll = () => {\n getAllPages().forEach((page) => {\n page.querySelectorAll(`:scope > .${LAYER_CLASS}`).forEach((el) => el.remove());\n });\n };\n\n /* -------------------- keep cloned pages' def ids unique ------------------- */\n\n // Page shapes are per-page, so newly-added pages do NOT inherit any design.\n // But duplicating a page that already has a shape clones its <svg> verbatim —\n // duplicate gradient/pattern ids in one document make them all resolve to the\n // first. Re-uniquify any cloned layer's ids so each page renders its own.\n const watchNewPages = () => {\n const root = getPagesRoot();\n if (!root) return;\n const obs = new MutationObserver((muts) => {\n for (const m of muts) {\n for (const node of m.addedNodes) {\n if (node.nodeType !== 1) continue;\n const pages = node.matches?.(PAGE_SEL)\n ? [node]\n : Array.from(node.querySelectorAll?.(PAGE_SEL) || []);\n pages.forEach((pageEl) => {\n const layer = pageEl.querySelector(`:scope > .${LAYER_CLASS}`);\n const svg = layer && layer.querySelector('svg');\n // _csShapeUniq is a JS property (not an attribute) so it is NOT\n // copied by cloneNode — a freshly-cloned layer lacks it and gets\n // re-uniquified exactly once.\n if (svg && !layer._csShapeUniq) {\n uniquifyIds(svg, `pg${uidSeq += 1}`);\n layer._csShapeUniq = true;\n }\n });\n }\n }\n });\n obs.observe(root, { childList: true, subtree: true });\n };\n\n Object.assign(window.PageShapeDesigner, { open, removeFromActive, clearAll });\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', watchNewPages);\n } else {\n watchNewPages();\n }\n})();\n\n<\/script>\n <script data-src=\"./js/flow/collab.js\">\n/**\n * @fileoverview Real-time collaboration: presence + comments & mentions.\n *\n * Runs INSIDE the canvas iframe and renders its own floating UI over the canvas\n * (a toolbar, remote cursors, an avatar stack, comment pins + threads). It needs\n * no Angular changes.\n *\n * Transport: connects to the server relay at ws(s)://<host>/collab?doc=<id>.\n * If the WebSocket can't open (e.g. running under `ng serve` without the SSR\n * server), it falls back to a same-origin BroadcastChannel so presence +\n * comments still work live across browser TABS — handy for testing. The two\n * transports speak the exact same JSON messages, so the WS backend is a drop-in.\n *\n * Identity: a lightweight local user { id, name, color } in localStorage\n * (name is editable). This is identity-only — no passwords yet.\n *\n * Message types (all fan-out via the relay):\n * presence:hello | presence:cursor | presence:select | presence:leave\n * comment:add | comment:reply | comment:resolve | comment:delete\n */\n(function () {\n 'use strict';\n const DOC_ID = (new URLSearchParams(location.search).get('doc')) || 'default';\n const USER_KEY = 'cs-collab-user';\n const COMMENTS_KEY = 'cs-collab-comments-' + DOC_ID;\n const COLORS = ['#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];\n const uid = (p) => p + Math.random().toString(16).slice(2, 10);\n\n // The persistent toolbar must render in the HOST window — this canvas iframe\n // is a tall element scrolled by the host, so a `position:fixed` toolbar inside\n // it lands at the bottom of the tall iframe (off-screen). Pins/cursors/popovers\n // stay in the iframe (they're anchored to content, which scrolls with it).\n const hostWin = (() => { try { return (window.parent && window.parent !== window) ? window.parent : window; } catch (e) { return window; } })();\n const hostDoc = hostWin.document;\n\n // Feature flags, driven by the host settings toggles (collab:config message).\n // Default ON; the host pushes the real values on load + whenever toggled.\n let cfg = { comments: true, presence: true };\n\n /* ------------------------------- identity -------------------------------- */\n const loadUser = () => {\n try { const u = JSON.parse(localStorage.getItem(USER_KEY)); if (u && u.id) return u; } catch (e) { /* */ }\n const u = { id: uid('u_'), name: 'Guest ' + Math.floor(100 + Math.random() * 900), color: COLORS[Math.floor(Math.random() * COLORS.length)] };\n localStorage.setItem(USER_KEY, JSON.stringify(u));\n return u;\n };\n let me = loadUser();\n const saveUser = () => localStorage.setItem(USER_KEY, JSON.stringify(me));\n\n /* ------------------------------- transport ------------------------------- */\n let send = () => { };\n const listeners = [];\n const onMsg = (fn) => listeners.push(fn);\n const dispatch = (msg) => { if (msg) listeners.forEach((fn) => { try { fn(msg); } catch (e) { /* */ } }); };\n\n const initTransport = () => {\n let ws = null, bc = null, wsOk = false;\n const startBC = () => {\n if (bc) return;\n try {\n bc = new BroadcastChannel('cs-collab-' + DOC_ID);\n send = (m) => { try { bc.postMessage(m); } catch (e) { /* */ } };\n bc.onmessage = (e) => dispatch(e.data);\n hello();\n } catch (e) { /* */ }\n };\n try {\n const proto = location.protocol === 'https:' ? 'wss' : 'ws';\n ws = new WebSocket(`${proto}://${location.host}/collab?doc=${encodeURIComponent(DOC_ID)}`);\n ws.onopen = () => { wsOk = true; send = (m) => { try { ws.send(JSON.stringify(m)); } catch (e) { /* */ } }; hello(); };\n ws.onmessage = (e) => { try { dispatch(JSON.parse(e.data)); } catch (err) { /* */ } };\n ws.onclose = () => { if (!wsOk) startBC(); };\n ws.onerror = () => { if (!wsOk) startBC(); };\n } catch (e) { startBC(); }\n setTimeout(() => { if (!wsOk && !bc) startBC(); }, 1500);\n };\n const hello = () => send({ type: 'presence:hello', user: me });\n\n /* --------------------------- coordinate mapping -------------------------- */\n // Cursors are shared in page-relative fractions so they line up regardless of\n // each peer's scroll position / window size.\n const docs = () => Array.from(document.querySelectorAll('.cs_margin'));\n const docAt = (cx, cy) => docs().find((d) => { const r = d.getBoundingClientRect(); return cy >= r.top && cy <= r.bottom && cx >= r.left && cx <= r.right; });\n const toPageFrac = (cx, cy) => {\n const all = docs(); const d = docAt(cx, cy) || all[0]; if (!d) return null;\n const r = d.getBoundingClientRect();\n return { page: all.indexOf(d), nx: (cx - r.left) / r.width, ny: (cy - r.top) / r.height };\n };\n const fromPageFrac = (p) => {\n const all = docs(); const d = all[p.page] || all[0]; if (!d) return null;\n const r = d.getBoundingClientRect();\n return { x: r.left + p.nx * r.width, y: r.top + p.ny * r.height };\n };\n // Comments anchor to a block id + fractional offset, so they follow the block.\n const blockAnchor = (cx, cy) => {\n const el = document.elementFromPoint(cx, cy);\n const block = el && el.closest && el.closest('.cs_block_s, .canvas-block');\n if (block) {\n if (!block.id) block.id = uid('block_');\n const r = block.getBoundingClientRect();\n return { blockId: block.id, relX: (cx - r.left) / r.width, relY: (cy - r.top) / r.height };\n }\n const pf = toPageFrac(cx, cy);\n return pf ? { page: pf.page, nx: pf.nx, ny: pf.ny } : null;\n };\n const anchorToViewport = (a) => {\n if (!a) return null;\n if (a.blockId) {\n const b = document.getElementById(a.blockId);\n if (!b) return null;\n const r = b.getBoundingClientRect();\n return { x: r.left + (a.relX || 0) * r.width, y: r.top + (a.relY || 0) * r.height };\n }\n return fromPageFrac(a);\n };\n\n /* --------------------------------- styles -------------------------------- */\n const STYLE = `\n .cs-collab-cursor{position:fixed;z-index:99000;pointer-events:none;transform:translate(-2px,-2px);transition:left .08s linear,top .08s linear}\n .cs-collab-cursor svg{display:block;filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}\n .cs-collab-cursor span{position:absolute;left:14px;top:10px;white-space:nowrap;font:600 11px/1 Inter,sans-serif;color:#fff;padding:2px 6px;border-radius:4px}\n .cs-collab-selout{position:fixed;z-index:98000;pointer-events:none;border:2px solid;border-radius:4px}\n .cs-collab-selout span{position:absolute;top:-18px;left:-2px;font:600 10px/1 Inter,sans-serif;color:#fff;padding:2px 5px;border-radius:3px}\n .cs-collab-bar{position:fixed;left:12px;bottom:12px;z-index:99500;display:flex;align-items:center;gap:8px;background:#111827;color:#fff;border-radius:10px;padding:6px 8px;box-shadow:0 6px 20px rgba(0,0,0,.3);font:500 12px/1 Inter,sans-serif}\n .cs-collab-bar button{border:none;background:#374151;color:#fff;border-radius:6px;padding:6px 9px;font-size:12px;cursor:pointer}\n .cs-collab-bar button.on{background:#248567}\n .cs-collab-avatars{display:flex}\n .cs-collab-av{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;color:#fff;font:700 10px/1 Inter,sans-serif;border:2px solid #111827;margin-left:-6px;cursor:default}\n .cs-collab-av:first-child{margin-left:0}\n .cs-collab-pin{position:fixed;z-index:98500;width:26px;height:26px;border-radius:50% 50% 50% 2px;display:grid;place-items:center;color:#fff;font-size:13px;cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,.35);border:2px solid #fff}\n .cs-collab-pin.resolved{opacity:.45}\n .cs-collab-pop{position:fixed;z-index:99600;width:300px;max-height:60vh;overflow:auto;background:#fff;border:1px solid #e5e7eb;border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.25);font:13px/1.4 Inter,sans-serif;color:#1f2937}\n .cs-collab-pop__hd{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid #eee;font-weight:600}\n .cs-collab-pop__msgs{padding:8px 12px;display:flex;flex-direction:column;gap:10px}\n .cs-collab-msg__a{font-weight:600;font-size:12px}\n .cs-collab-msg__t{font-size:11px;color:#9ca3af;margin-left:6px}\n .cs-collab-msg__b{font-size:13px;margin-top:2px;white-space:pre-wrap}\n .cs-collab-msg__b .men{color:#2563eb;font-weight:600}\n .cs-collab-comp{position:relative;padding:8px 12px;border-top:1px solid #eee}\n .cs-collab-comp textarea{width:100%;box-sizing:border-box;border:1px solid #e5e7eb;border-radius:6px;padding:6px 8px;font:13px Inter,sans-serif;resize:vertical;min-height:42px}\n .cs-collab-comp__row{display:flex;justify-content:flex-end;gap:6px;margin-top:6px}\n .cs-collab-comp__row button{border:none;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer}\n .cs-collab-btn-primary{background:#248567;color:#fff}\n .cs-collab-btn-ghost{background:#f3f4f6;color:#374151}\n .cs-collab-ment{position:absolute;left:12px;right:12px;bottom:60px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.18);max-height:160px;overflow:auto}\n .cs-collab-ment div{padding:7px 10px;cursor:pointer;display:flex;align-items:center;gap:8px}\n .cs-collab-ment div:hover,.cs-collab-ment div.sel{background:#eef2ff}\n .cs-collab-dot{width:16px;height:16px;border-radius:50%;color:#fff;display:grid;place-items:center;font:700 8px/1 Inter}\n body.cs-comment-mode .cs_paper{cursor:crosshair}`;\n const injectStyle = () => {\n const targets = hostDoc === document ? [document] : [document, hostDoc];\n targets.forEach((d) => {\n if (!d || d.getElementById('cs-collab-style')) return;\n const s = d.createElement('style'); s.id = 'cs-collab-style'; s.textContent = STYLE; d.head.appendChild(s);\n });\n };\n\n /* ------------------------------- presence -------------------------------- */\n const peers = new Map(); // userId → { user, lastSeen }\n const cursorEls = new Map(); // userId → element\n const selEls = new Map(); // userId → element\n const knownUsers = () => { const m = new Map(); m.set(me.id, me); peers.forEach((p) => m.set(p.user.id, p.user)); return Array.from(m.values()); };\n\n const initial = (name) => (name || '?').trim().charAt(0).toUpperCase();\n let avatarsEl;\n const renderAvatars = () => {\n if (!avatarsEl) return;\n const users = knownUsers();\n avatarsEl.innerHTML = '';\n users.forEach((u) => {\n const a = document.createElement('div');\n a.className = 'cs-collab-av'; a.style.background = u.color; a.textContent = initial(u.name);\n a.title = u.id === me.id ? `${u.name} (you)` : u.name;\n avatarsEl.appendChild(a);\n });\n };\n\n const showCursor = (user, pos) => {\n if (!pos) return;\n let el = cursorEls.get(user.id);\n if (!el) {\n el = document.createElement('div'); el.className = 'cs-collab-cursor';\n el.innerHTML = `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M1 1l5 13 2-5 5-2z\" fill=\"${user.color}\"/></svg><span style=\"background:${user.color}\">${user.name}</span>`;\n document.body.appendChild(el); cursorEls.set(user.id, el);\n }\n el.style.left = pos.x + 'px'; el.style.top = pos.y + 'px';\n };\n const showRemoteSelection = (user, blockId) => {\n let el = selEls.get(user.id);\n const b = blockId && document.getElementById(blockId);\n if (!b) { if (el) { el.remove(); selEls.delete(user.id); } return; }\n if (!el) {\n el = document.createElement('div'); el.className = 'cs-collab-selout';\n el.style.borderColor = user.color;\n el.innerHTML = `<span style=\"background:${user.color}\">${user.name}</span>`;\n document.body.appendChild(el); selEls.set(user.id, el);\n }\n el._blockId = blockId;\n const r = b.getBoundingClientRect();\n el.style.left = r.left + 'px'; el.style.top = r.top + 'px';\n el.style.width = r.width + 'px'; el.style.height = r.height + 'px';\n };\n const dropPeer = (userId) => {\n peers.delete(userId);\n [cursorEls, selEls].forEach((m) => { const e = m.get(userId); if (e) e.remove(); m.delete(userId); });\n renderAvatars();\n };\n const touchPeer = (user) => {\n const isNew = !peers.has(user.id);\n peers.set(user.id, { user, lastSeen: performance.now() });\n if (isNew) { renderAvatars(); hello(); /* let the new peer learn us */ }\n };\n // Reap peers we haven't heard from in a while (covers BroadcastChannel, which\n // has no disconnect event).\n setInterval(() => {\n const now = performance.now();\n peers.forEach((p, id) => { if (now - p.lastSeen > 15000) dropPeer(id); });\n }, 5000);\n\n let lastCursorSent = 0;\n const onPointerMove = (e) => {\n if (!cfg.presence) return;\n const now = performance.now();\n if (now - lastCursorSent < 60) return;\n lastCursorSent = now;\n const pf = toPageFrac(e.clientX, e.clientY);\n if (pf) send({ type: 'presence:cursor', user: me, pos: pf });\n };\n\n /* -------------------------------- comments ------------------------------- */\n let comments = [];\n const loadComments = () => { try { comments = JSON.parse(localStorage.getItem(COMMENTS_KEY) || '[]'); } catch (e) { comments = []; } };\n const persistComments = () => {\n try { localStorage.setItem(COMMENTS_KEY, JSON.stringify(comments)); } catch (e) { /* */ }\n // Best-effort server persistence (no-op under ng serve).\n try { fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doc: DOC_ID, comments }) }).catch(() => { }); } catch (e) { /* */ }\n };\n const fetchServerComments = () => {\n try {\n fetch('/api/comments?doc=' + encodeURIComponent(DOC_ID)).then((r) => r.json()).then((d) => {\n if (Array.isArray(d.comments) && d.comments.length) { comments = mergeComments(comments, d.comments); renderPins(); }\n }).catch(() => { });\n } catch (e) { /* */ }\n };\n const mergeComments = (a, b) => { const m = new Map();[...a, ...b].forEach((c) => m.set(c.id, c)); return Array.from(m.values()); };\n\n const fmtTime = (ts) => { const d = (new Date(ts)).getTime?.() ? new Date(ts) : new Date(); const diff = Date.now() - ts; const mn = Math.floor(diff / 60000); if (mn < 1) return 'just now'; if (mn < 60) return mn + 'm'; const h = Math.floor(mn / 60); if (h < 24) return h + 'h'; return Math.floor(h / 24) + 'd'; };\n const escapeHtml = (s) => (s || '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));\n const renderBody = (text) => escapeHtml(text).replace(/@([A-Za-z0-9 _-]{1,24})/g, '<span class=\"men\">@$1</span>');\n\n let commentMode = false, panelOpen = false, openThreadId = null;\n const pinEls = new Map();\n\n const renderPins = () => {\n if (!cfg.comments) { pinEls.forEach((el) => { el.style.display = 'none'; }); return; }\n const live = new Set();\n comments.forEach((c) => {\n if (c.parentId) return; // only roots get pins\n live.add(c.id);\n const pos = anchorToViewport(c.anchor);\n let pin = pinEls.get(c.id);\n if (!pos) { if (pin) { pin.style.display = 'none'; } return; }\n if (!pin) {\n pin = document.createElement('div'); pin.className = 'cs-collab-pin';\n pin.addEventListener('click', (e) => { e.stopPropagation(); openThread(c.id); });\n document.body.appendChild(pin); pinEls.set(c.id, pin);\n }\n pin.style.display = '';\n pin.style.background = c.color || '#248567';\n pin.classList.toggle('resolved', !!c.resolved);\n pin.textContent = c.resolved ? '✓' : '💬';\n pin.style.left = pos.x + 'px'; pin.style.top = (pos.y - 26) + 'px';\n });\n pinEls.forEach((el, id) => { if (!live.has(id)) { el.remove(); pinEls.delete(id); } });\n };\n\n const threadOf = (rootId) => comments.filter((c) => c.id === rootId || c.parentId === rootId)\n .sort((a, b) => a.createdAt - b.createdAt);\n\n let popEl = null;\n const closePopover = () => { if (popEl) { popEl.remove(); popEl = null; } openThreadId = null; };\n const openThread = (rootId) => {\n closePopover();\n const root = comments.find((c) => c.id === rootId); if (!root) return;\n openThreadId = rootId;\n const pos = anchorToViewport(root.anchor) || { x: 100, y: 100 };\n popEl = document.createElement('div'); popEl.className = 'cs-collab-pop';\n popEl.style.left = Math.min(window.innerWidth - 312, pos.x + 16) + 'px';\n popEl.style.top = Math.min(window.innerHeight - 280, Math.max(8, pos.y - 20)) + 'px';\n const msgs = threadOf(rootId).map((c) => `\n <div data-mid=\"${c.id}\">\n <div><span class=\"cs-collab-msg__a\" style=\"color:${c.color || '#1f2937'}\">${escapeHtml(c.author)}</span><span class=\"cs-collab-msg__t\">${fmtTime(c.createdAt)}</span></div>\n <div class=\"cs-collab-msg__b\">${renderBody(c.body)}</div>\n </div>`).join('');\n popEl.innerHTML = `\n <div class=\"cs-collab-pop__hd\">\n <span>Comment ${root.resolved ? '· resolved' : ''}</span>\n <span>\n <button class=\"cs-collab-btn-ghost\" data-act=\"resolve\" style=\"padding:4px 8px;border:none;border-radius:5px;cursor:pointer;font-size:11px\">${root.resolved ? 'Reopen' : 'Resolve'}</button>\n <button class=\"cs-collab-btn-ghost\" data-act=\"del\" style=\"padding:4px 8px;border:none;border-radius:5px;cursor:pointer;font-size:11px;color:#ef4444\">Delete</button>\n <button class=\"cs-collab-btn-ghost\" data-act=\"close\" style=\"padding:4px 8px;border:none;border-radius:5px;cursor:pointer;font-size:11px\">✕</button>\n </span>\n </div>\n <div class=\"cs-collab-pop__msgs\">${msgs}</div>\n <div class=\"cs-collab-comp\">\n <textarea placeholder=\"Reply… use @ to mention\"></textarea>\n <div class=\"cs-collab-comp__row\">\n <button class=\"cs-collab-btn-primary\" data-act=\"reply\">Reply</button>\n </div>\n </div>`;\n document.body.appendChild(popEl);\n const ta = popEl.querySelector('textarea');\n wireMentions(ta, popEl.querySelector('.cs-collab-comp'));\n popEl.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act === 'close') return closePopover();\n if (act === 'resolve') { toggleResolve(rootId); return; }\n if (act === 'del') { deleteThread(rootId); return; }\n if (act === 'reply') { const body = ta.value.trim(); if (body) addComment(root.anchor, body, rootId); ta.value = ''; }\n });\n };\n\n // @mention autocomplete\n const wireMentions = (ta, container) => {\n let menu = null, items = [], sel = 0, atPos = -1;\n const close = () => { if (menu) { menu.remove(); menu = null; } atPos = -1; };\n const apply = (u) => {\n const before = ta.value.slice(0, atPos);\n const after = ta.value.slice(ta.selectionStart);\n ta.value = before + '@' + u.name + ' ' + after;\n close(); ta.focus();\n };\n ta.addEventListener('input', () => {\n const caret = ta.selectionStart;\n const upto = ta.value.slice(0, caret);\n const m = /@([A-Za-z0-9 _-]*)$/.exec(upto);\n if (!m) return close();\n atPos = caret - m[0].length;\n const q = m[1].toLowerCase();\n items = knownUsers().filter((u) => u.name.toLowerCase().includes(q)).slice(0, 6);\n if (!items.length) return close();\n sel = 0;\n if (!menu) { menu = document.createElement('div'); menu.className = 'cs-collab-ment'; container.appendChild(menu); }\n menu.innerHTML = items.map((u, i) => `<div data-i=\"${i}\" class=\"${i === sel ? 'sel' : ''}\"><span class=\"cs-collab-dot\" style=\"background:${u.color}\">${initial(u.name)}</span>${escapeHtml(u.name)}</div>`).join('');\n menu.querySelectorAll('[data-i]').forEach((d) => d.addEventListener('mousedown', (e) => { e.preventDefault(); apply(items[+d.dataset.i]); }));\n });\n ta.addEventListener('keydown', (e) => {\n if (!menu) return;\n if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; }\n else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; }\n else if (e.key === 'Enter') { e.preventDefault(); apply(items[sel]); return; }\n else if (e.key === 'Escape') { close(); return; }\n else return;\n menu.querySelectorAll('[data-i]').forEach((d, i) => d.classList.toggle('sel', i === sel));\n });\n };\n\n const extractMentions = (body) => {\n const ids = []; const names = knownUsers();\n (body.match(/@([A-Za-z0-9 _-]{1,24})/g) || []).forEach((tok) => {\n const nm = tok.slice(1).trim();\n const u = names.find((x) => nm.startsWith(x.name) || x.name === nm);\n if (u) ids.push(u.id);\n });\n return Array.from(new Set(ids));\n };\n\n const addComment = (anchor, body, parentId) => {\n const c = {\n id: uid('c_'), docId: DOC_ID, anchor, body,\n author: me.name, authorId: me.id, color: me.color,\n mentions: extractMentions(body), parentId: parentId || null,\n resolved: false, createdAt: Date.now(),\n };\n comments.push(c); persistComments(); renderPins();\n send({ type: parentId ? 'comment:reply' : 'comment:add', comment: c });\n openThread(parentId || c.id);\n notifyMentions(c);\n };\n const toggleResolve = (rootId) => {\n const c = comments.find((x) => x.id === rootId); if (!c) return;\n c.resolved = !c.resolved; persistComments(); renderPins();\n send({ type: 'comment:resolve', id: rootId, resolved: c.resolved });\n openThread(rootId);\n };\n const deleteThread = (rootId) => {\n comments = comments.filter((c) => c.id !== rootId && c.parentId !== rootId);\n persistComments(); renderPins(); closePopover();\n send({ type: 'comment:delete', id: rootId });\n };\n const notifyMentions = (c) => {\n if (c.mentions && c.mentions.includes(me.id) && c.authorId !== me.id) toast(`${c.author} mentioned you`);\n };\n\n let toastEl;\n const toast = (text) => {\n if (!toastEl) { toastEl = hostDoc.createElement('div'); toastEl.className = 'cs-collab-toast'; toastEl.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:99999;background:#111827;color:#fff;padding:10px 14px;border-radius:8px;font:600 13px Inter,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.3)'; hostDoc.body.appendChild(toastEl); }\n toastEl.textContent = '🔔 ' + text; toastEl.style.opacity = '1';\n clearTimeout(toastEl._t); toastEl._t = setTimeout(() => { toastEl.style.opacity = '0'; }, 4000);\n };\n\n /* --------------------------- incoming messages --------------------------- */\n onMsg((m) => {\n if (m.type === 'presence:hello') { if (cfg.presence) touchPeer(m.user); }\n else if (m.type === 'presence:cursor') { if (cfg.presence) { touchPeer(m.user); showCursor(m.user, fromPageFrac(m.pos)); } }\n else if (m.type === 'presence:select') { if (cfg.presence) { touchPeer(m.user); showRemoteSelection(m.user, m.blockId); } }\n else if (m.type === 'presence:leave') { dropPeer(m.userId); }\n else if (m.type === 'comment:add' || m.type === 'comment:reply') {\n if (!comments.find((c) => c.id === m.comment.id)) { comments.push(m.comment); persistComments(); renderPins(); notifyMentions(m.comment); if (openThreadId && (m.comment.parentId === openThreadId)) openThread(openThreadId); }\n }\n else if (m.type === 'comment:resolve') { const c = comments.find((x) => x.id === m.id); if (c) { c.resolved = m.resolved; persistComments(); renderPins(); } }\n else if (m.type === 'comment:delete') { comments = comments.filter((c) => c.id !== m.id && c.parentId !== m.id); persistComments(); renderPins(); if (openThreadId === m.id) closePopover(); }\n });\n\n /* --------------------------------- toolbar ------------------------------- */\n let commentBtn = null;\n let bar = null;\n const setCommentMode = (on) => {\n commentMode = cfg.comments && !!on;\n if (commentBtn) commentBtn.classList.toggle('on', commentMode);\n document.body.classList.toggle('cs-comment-mode', commentMode); // iframe body → crosshair\n };\n\n const clearRemoteVisuals = () => {\n cursorEls.forEach((e) => e.remove()); cursorEls.clear();\n selEls.forEach((e) => e.remove()); selEls.clear();\n };\n\n // Apply host settings: show/hide comments + presence UI.\n const applyConfig = (c) => {\n cfg = Object.assign({}, cfg, c || {});\n if (commentBtn) commentBtn.style.display = cfg.comments ? '' : 'none';\n if (!cfg.comments) { setCommentMode(false); closePopover(); }\n if (avatarsEl) avatarsEl.style.display = cfg.presence ? '' : 'none';\n if (!cfg.presence) clearRemoteVisuals();\n if (bar) bar.style.display = (cfg.comments || cfg.presence) ? '' : 'none';\n renderPins();\n };\n\n // Exposed so the host topbar button + settings toggles can drive collab.\n window.Collab = window.Collab || {};\n window.Collab.toggleCommentMode = () => { if (cfg.comments) setCommentMode(!commentMode); };\n window.Collab.applyConfig = applyConfig;\n\n const buildBar = () => {\n // Remove any stale bars/toasts left in the HOST by a previous iframe load\n // (the iframe reloads, but host-appended elements persist → duplicates).\n hostDoc.querySelectorAll('.cs-collab-bar, .cs-collab-toast').forEach((e) => e.remove());\n bar = hostDoc.createElement('div'); bar.className = 'cs-collab-bar';\n bar.innerHTML = `\n <span class=\"cs-collab-avatars\"></span>\n <button data-c=\"comment\" title=\"Comment mode — click the canvas to drop a comment\">💬 Comment</button>\n <button data-c=\"me\" title=\"Rename yourself\">You: <b>${escapeHtml(me.name)}</b></button>`;\n hostDoc.body.appendChild(bar);\n avatarsEl = bar.querySelector('.cs-collab-avatars');\n const meBtn = bar.querySelector('[data-c=\"me\"]');\n commentBtn = bar.querySelector('[data-c=\"comment\"]');\n commentBtn.addEventListener('click', () => setCommentMode(!commentMode));\n meBtn.addEventListener('click', () => {\n const nm = prompt('Your display name', me.name);\n if (nm && nm.trim()) { me.name = nm.trim(); saveUser(); meBtn.innerHTML = `You: <b>${escapeHtml(me.name)}</b>`; renderAvatars(); hello(); }\n });\n renderAvatars();\n };\n\n // Click on the canvas in comment mode → start a new comment thread.\n const onCanvasClick = (e) => {\n if (!commentMode) return;\n if (e.target.closest('.cs-collab-pop, .cs-collab-pin, .cs-collab-bar')) return;\n const anchor = blockAnchor(e.clientX, e.clientY);\n if (!anchor) return;\n e.preventDefault(); e.stopPropagation();\n // Create an empty draft thread by opening a composer popover at the point.\n openDraft(anchor, e.clientX, e.clientY);\n setCommentMode(false);\n };\n const openDraft = (anchor, x, y) => {\n closePopover();\n popEl = document.createElement('div'); popEl.className = 'cs-collab-pop';\n popEl.style.left = Math.min(window.innerWidth - 312, x + 12) + 'px';\n popEl.style.top = Math.min(window.innerHeight - 200, y) + 'px';\n popEl.innerHTML = `\n <div class=\"cs-collab-pop__hd\"><span>New comment</span><button class=\"cs-collab-btn-ghost\" data-act=\"close\" style=\"border:none;border-radius:5px;cursor:pointer;padding:4px 8px\">✕</button></div>\n <div class=\"cs-collab-comp\">\n <textarea placeholder=\"Comment… use @ to mention\"></textarea>\n <div class=\"cs-collab-comp__row\">\n <button class=\"cs-collab-btn-ghost\" data-act=\"close\">Cancel</button>\n <button class=\"cs-collab-btn-primary\" data-act=\"add\">Comment</button>\n </div>\n </div>`;\n document.body.appendChild(popEl);\n const ta = popEl.querySelector('textarea'); ta.focus();\n wireMentions(ta, popEl.querySelector('.cs-collab-comp'));\n popEl.addEventListener('click', (e) => {\n const act = e.target.closest('[data-act]')?.dataset.act;\n if (act === 'close') return closePopover();\n if (act === 'add') { const body = ta.value.trim(); if (body) addComment(anchor, body, null); }\n });\n };\n\n // Reposition pins + remote selection outlines on scroll & resize (their\n // fixed/viewport coords otherwise drift as the canvas moves).\n const reposition = () => {\n renderPins();\n selEls.forEach((el, id) => { const p = peers.get(id); if (p && el._blockId) showRemoteSelection(p.user, el._blockId); });\n };\n\n /* --------------------------------- wiring -------------------------------- */\n const init = () => {\n injectStyle();\n loadComments();\n buildBar();\n initTransport();\n fetchServerComments();\n renderPins();\n\n document.addEventListener('pointermove', onPointerMove, true);\n document.addEventListener('click', onCanvasClick, true);\n document.addEventListener('scroll', () => requestAnimationFrame(reposition), true);\n window.addEventListener('resize', () => requestAnimationFrame(reposition));\n\n // Broadcast which block I have selected (presence).\n if (window.EditorManager) {\n let last = null;\n setInterval(() => {\n if (!cfg.presence) return;\n const b = window.EditorManager.getSelected?.();\n const id = b ? (b.id || (b.id = uid('block_'))) : null;\n if (id !== last) { last = id; send({ type: 'presence:select', user: me, blockId: id }); }\n }, 400);\n }\n hello();\n // When THIS iframe instance goes away, remove its host-appended UI so the\n // next load doesn't stack a duplicate bar/toast.\n window.addEventListener('pagehide', () => { try { bar && bar.remove(); toastEl && toastEl.remove(); } catch (e) { /* */ } });\n // Tell the host we're ready so it can push the current settings (collab:config).\n try { window.parent && window.parent.postMessage({ source: 'custom-form-twig', type: 'collab:ready' }, '*'); } catch (e) { /* */ }\n };\n\n if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);\n else init();\n})();\n\n<\/script>\n <script data-src=\"./js/flow-canvas.js\">\n/**\n * @fileoverview Flow Canvas — entry point.\n *\n * Wires together the feature modules under /flow/:\n * - block-factory.js — creates block elements\n * - row-col-builder.js — DOM scaffolding + placeBlock\n * - drop-zones.js — find target + visual indicator\n * - col-resize.js — draggable column divider\n * - section-canvas.js — in-section absolute placement\n * - cleanup-observer.js — auto-remove empty cols/rows\n *\n * This file:\n * 1. Bootstraps the .cs_margin root inside the canvas.\n * 2. Attaches drag/drop listeners that route through findDropTarget → placeBlock.\n * 3. Initializes column resize + cleanup observer.\n *\n * All shared helpers live on `window.FlowCanvas`.\n */\n(function () {\n // Feature flag: set to true to enable header/footer rendering & sync.\n // When false, pages render without header/footer regions.\n let ENABLE_HEADER_FOOTER = false;\n\n const CANVAS_SELECTOR = '.custom-form-design';\n\n const canvas = document.querySelector(CANVAS_SELECTOR);\n if (!canvas) {\n console.warn('flow-canvas: canvas not found');\n return;\n }\n\n // Guard against double-initialization (HMR / accidental double-load).\n if (canvas.dataset.flowCanvasInit === '1') {\n console.warn('flow-canvas: already initialized, skipping');\n return;\n }\n canvas.dataset.flowCanvasInit = '1';\n\n canvas.classList.add('cs-flow-canvas');\n\n const FC = window.FlowCanvas || {};\n\n // -------------------------------------------------------------------------\n // Document model:\n // canvas (.custom-form-design)\n // └─ .cs_paper — multi-page container\n // ├─ .cs_margin[data-page=\"1\"] — first page (always has header/footer)\n // ├─ .cs_margin[data-page=\"2\"] — additional pages (with or without)\n // └─ ...\n // -------------------------------------------------------------------------\n // The host page owns the outer .cs_paper wrapper (see custom-form.html);\n // we must NOT inject a second .cs_paper inside the canvas. Pages\n // (.cs_margin) attach directly under the canvas so the existing drag /\n // drop listeners (mounted on the canvas) keep working. From the rest\n // of the file's point of view, `paper` is \"the element pages live in\" —\n // here, that's the canvas itself.\n //\n // If a legacy DOM still has a nested .cs_paper inside the canvas, lift\n // its docs up to canvas level and drop the empty wrapper.\n const legacyPaper = canvas.querySelector(':scope > .cs_paper');\n if (legacyPaper) {\n legacyPaper.querySelectorAll(':scope > .cs_margin').forEach((d) => canvas.appendChild(d));\n legacyPaper.remove();\n }\n const paper = canvas.closest('.cs_paper') || canvas;\n\n const makeRegion = (region) => {\n const el = (FC.makeRow && FC.makeRow()) || document.createElement('div');\n el.className = `row-item cs-page-${region}`;\n FC.assignNodeId?.(el, 'row');\n el.setAttribute('data-cs-page-region', region);\n el.setAttribute('data-cs-region-label', region.toUpperCase());\n const placeholder = `Double-click to edit ${region}`;\n el.setAttribute('data-cs-placeholder', placeholder);\n\n if (region === 'header') {\n const col1 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col1.classList.contains('col-item')) col1.classList.add('col-item');\n col1.style.flex = '6';\n col1.style.maxWidth = '100%';\n const imgBlock = FC.createBlock && FC.createBlock('image');\n if (imgBlock) {\n imgBlock.style.position = '';\n imgBlock.style.left = '';\n imgBlock.style.top = '';\n imgBlock.style.maxWidth = '100%';\n const wrapper = imgBlock.querySelector('.image-container');\n if (wrapper) wrapper.style.setProperty('height', '60px', 'important');\n col1.appendChild(imgBlock);\n }\n el.appendChild(col1);\n\n const colGap = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!colGap.classList.contains('col-item')) colGap.classList.add('col-item');\n colGap.style.flex = '1';\n colGap.style.maxWidth = '100%';\n el.appendChild(colGap);\n\n const col2 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col2.classList.contains('col-item')) col2.classList.add('col-item');\n col2.style.flex = '3';\n col2.style.maxWidth = '100%';\n const textBlock = FC.createBlock && FC.createBlock('body-text');\n if (textBlock) {\n textBlock.style.position = '';\n textBlock.style.left = '';\n textBlock.style.top = '';\n textBlock.style.maxWidth = '100%';\n col2.appendChild(textBlock);\n }\n el.appendChild(col2);\n\n setTimeout(() => { if (FC.rebuildDividers) FC.rebuildDividers(el); }, 0);\n } else if (region === 'footer') {\n const col1 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col1.classList.contains('col-item')) col1.classList.add('col-item');\n col1.style.flex = '6';\n col1.style.maxWidth = '100%';\n const textBlock = FC.createBlock && FC.createBlock('body-text');\n if (textBlock) {\n textBlock.style.position = '';\n textBlock.style.left = '';\n textBlock.style.top = '';\n textBlock.style.maxWidth = '100%';\n col1.appendChild(textBlock);\n }\n el.appendChild(col1);\n\n const colGap = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!colGap.classList.contains('col-item')) colGap.classList.add('col-item');\n colGap.style.flex = '1';\n colGap.style.maxWidth = '100%';\n el.appendChild(colGap);\n\n const col2 = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col2.classList.contains('col-item')) col2.classList.add('col-item');\n col2.style.flex = '3';\n col2.style.maxWidth = '100%';\n const imgBlock = FC.createBlock && FC.createBlock('image');\n if (imgBlock) {\n imgBlock.style.position = '';\n imgBlock.style.left = '';\n imgBlock.style.top = '';\n imgBlock.style.maxWidth = '100%';\n const wrapper = imgBlock.querySelector('.image-container');\n if (wrapper) wrapper.style.setProperty('height', '60px', 'important');\n col2.appendChild(imgBlock);\n }\n el.appendChild(col2);\n\n setTimeout(() => { if (FC.rebuildDividers) FC.rebuildDividers(el); }, 0);\n } else {\n const col = (FC.makeCol && FC.makeCol()) || document.createElement('div');\n if (!col.classList.contains('col-item')) col.classList.add('col-item');\n col.setAttribute('data-cs-placeholder', placeholder);\n el.appendChild(col);\n }\n\n return el;\n };\n\n const ensurePageRegions = (docEl) => {\n if (docEl.dataset.csNoHeaderFooter === '1') return; // blank page\n let header = docEl.querySelector(':scope > .cs-page-header');\n let footer = docEl.querySelector(':scope > .cs-page-footer');\n let main = docEl.querySelector(':scope > .body-main-content');\n\n if (!main) {\n main = document.createElement('div');\n main.className = 'body-main-content';\n main.style.flex = '1';\n main.style.display = 'flex';\n main.style.flexDirection = 'column';\n }\n\n if (!header) {\n header = makeRegion('header');\n docEl.prepend(header);\n }\n\n if (!main.parentNode) {\n Array.from(docEl.querySelectorAll(':scope > .row-item:not(.cs-page-header):not(.cs-page-footer)')).forEach(r => main.appendChild(r));\n }\n\n if (!footer) {\n footer = makeRegion('footer');\n docEl.appendChild(footer);\n }\n\n if (header.nextElementSibling !== main) docEl.insertBefore(main, header.nextSibling);\n if (main.nextElementSibling !== footer) docEl.insertBefore(footer, main.nextSibling);\n\n return { header, footer, main };\n };\n\n const setRegionActive = (docEl, region) => {\n // Clear active state across ALL pages.\n paper.querySelectorAll('.cs_margin').forEach((d) => {\n d.classList.remove('editing-header', 'editing-footer');\n d.querySelectorAll('.cs-page-header, .cs-page-footer')\n .forEach((el) => el.classList.remove('is-active'));\n });\n if (!docEl || !region) return;\n const header = docEl.querySelector(':scope > .cs-page-header');\n const footer = docEl.querySelector(':scope > .cs-page-footer');\n if (region === 'header' && header) { header.classList.add('is-active'); docEl.classList.add('editing-header'); }\n else if (region === 'footer' && footer) { footer.classList.add('is-active'); docEl.classList.add('editing-footer'); }\n };\n\n const wireRegionEvents = (docEl) => {\n const header = docEl.querySelector(':scope > .cs-page-header');\n const footer = docEl.querySelector(':scope > .cs-page-footer');\n header?.addEventListener('dblclick', (e) => { e.stopPropagation(); setRegionActive(docEl, 'header'); });\n footer?.addEventListener('dblclick', (e) => { e.stopPropagation(); setRegionActive(docEl, 'footer'); });\n };\n\n const wireRegionOrderObserver = (docEl) => {\n let reordering = false;\n const obs = new MutationObserver(() => {\n if (reordering) return;\n const header = docEl.querySelector(':scope > .cs-page-header');\n const footer = docEl.querySelector(':scope > .cs-page-footer');\n if (!header && !footer) return;\n if (docEl.firstElementChild === header && docEl.lastElementChild === footer) return;\n reordering = true;\n if (header && docEl.firstElementChild !== header) docEl.prepend(header);\n if (footer && docEl.lastElementChild !== footer) docEl.appendChild(footer);\n requestAnimationFrame(() => { reordering = false; });\n });\n obs.observe(docEl, { childList: true });\n };\n\n // -------------------------------------------------------------------------\n // Header/footer sync across pages\n //\n // Any page's header/footer is editable. After the user finishes editing\n // (focus leaves the region, or typing stops for 400ms), the content is\n // copied to every other page's matching region.\n //\n // The non-destructive part: we don't overwrite innerHTML on every\n // keystroke. We only sync once the user pauses or moves focus away.\n // While the user is actively editing a region, mirror updates are\n // suspended for the page being edited so the cursor and selection\n // stay intact.\n // -------------------------------------------------------------------------\n let regionSyncing = false;\n const editingState = { region: null, docEl: null };\n\n // After cloning header/footer content to a mirror page we must rewrite\n // every `id` attribute so each page's blocks are still unique. The\n // editor and block IDs are used as keys by block-creator, inline-editor\n // and Froala — duplicate IDs break selection and editing.\n const rewriteIds = (root, suffix) => {\n root.querySelectorAll('[id]').forEach((el) => {\n const oldId = el.id;\n el.id = `${oldId}__p${suffix}`;\n });\n };\n\n const syncRegion = (region, sourceDocEl) => {\n if (regionSyncing) return;\n const source = sourceDocEl.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (!source) return;\n const html = source.innerHTML;\n regionSyncing = true;\n paper.querySelectorAll('.cs_margin').forEach((d) => {\n if (d === sourceDocEl) return;\n // Don't clobber the page the user is actively typing into.\n if (editingState.docEl === d && editingState.region === region) return;\n const target = d.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (target && target.innerHTML !== html) {\n target.innerHTML = html;\n rewriteIds(target, d.dataset.page || 'x');\n }\n });\n requestAnimationFrame(() => { regionSyncing = false; });\n };\n\n const wireRegionSync = (docEl) => {\n ['header', 'footer'].forEach((region) => {\n const regionEl = docEl.querySelector(`:scope > .cs-page-${region}`);\n const col = docEl.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (!regionEl || !col) return;\n\n // Track when this region is the one being actively edited so the\n // sync routine knows to leave it alone.\n regionEl.addEventListener('focusin', () => {\n editingState.region = region;\n editingState.docEl = docEl;\n });\n regionEl.addEventListener('focusout', (e) => {\n // If focus moved to another element in the SAME region, stay editing.\n if (regionEl.contains(e.relatedTarget)) return;\n if (editingState.docEl === docEl && editingState.region === region) {\n editingState.region = null;\n editingState.docEl = null;\n // On blur, push final content to all other pages.\n syncRegion(region, docEl);\n }\n });\n\n // Debounced sync while typing — runs 400ms after the last mutation\n // so we don't fight the user's cursor.\n let debounceTimer = null;\n const obs = new MutationObserver(() => {\n if (regionSyncing) return;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => syncRegion(region, docEl), 400);\n });\n obs.observe(col, { childList: true, subtree: true, characterData: true, attributes: true });\n });\n };\n\n // Pull current header/footer content from the canonical page into a\n // freshly-created doc. No-ops when there's no canonical page (it was deleted\n // and not yet re-established) or when seeding the canonical into itself.\n const seedRegionsFromCanonical = (docEl) => {\n if (!firstDoc || !document.contains(firstDoc) || docEl === firstDoc) return;\n ['header', 'footer'].forEach((region) => {\n const src = firstDoc.querySelector(`:scope > .cs-page-${region} > .col-item`);\n const dst = docEl.querySelector(`:scope > .cs-page-${region} > .col-item`);\n if (src && dst) {\n regionSyncing = true;\n dst.innerHTML = src.innerHTML;\n rewriteIds(dst, docEl.dataset.page || 'x');\n requestAnimationFrame(() => { regionSyncing = false; });\n }\n });\n };\n\n // Bootstrap page 1.\n let firstDoc = paper.querySelector('.cs_margin[data-page=\"1\"]') || paper.querySelector('.cs_margin');\n if (!firstDoc) {\n firstDoc = document.createElement('div');\n firstDoc.className = 'cs_margin';\n firstDoc.dataset.page = '1';\n const firstPageWrapper = paper.querySelector('.cs_page') || paper;\n firstPageWrapper.appendChild(firstDoc);\n } else if (!firstDoc.dataset.page) {\n firstDoc.dataset.page = '1';\n }\n if (ENABLE_HEADER_FOOTER) {\n ensurePageRegions(firstDoc);\n wireRegionEvents(firstDoc);\n wireRegionOrderObserver(firstDoc);\n wireRegionSync(firstDoc);\n } else {\n firstDoc.dataset.csNoHeaderFooter = '1';\n }\n\n // Backwards-compat alias for the rest of the file (drag handlers\n // expect a single `doc`). It now always refers to page 1.\n const doc = firstDoc;\n\n // -------------------------------------------------------------------------\n // Add Page API — callable from the host shell or a future \"+\" button.\n // window.FlowCanvas.addPage({ headerFooter: true | false }) → docEl\n // -------------------------------------------------------------------------\n const renumberPages = () => {\n paper.querySelectorAll('.cs_margin').forEach((d, i) => { d.dataset.page = String(i + 1); });\n };\n\n // Total pages = every `.cs_page` directly under the paper (content wrappers +\n // cover pages). Reported to the host shell so the footer / Delete-page button\n // stay in sync with add/remove.\n const countPages = () => paper.querySelectorAll(':scope > .cs_page').length;\n const postPageCount = () => {\n try {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:count', count: countPages() }, '*');\n } catch (e) { /* parent on different origin — ignore */ }\n };\n const postRemoveResult = (ok, reason) => {\n try {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:removed', ok: !!ok, reason: reason || null, count: countPages() }, '*');\n } catch (e) { /* ignore */ }\n return !!ok;\n };\n\n FC.addPage = function (opts) {\n const withHF = ENABLE_HEADER_FOOTER && (!opts || opts.headerFooter !== false);\n const newDoc = document.createElement('div');\n newDoc.className = 'cs_margin';\n if (!withHF) newDoc.dataset.csNoHeaderFooter = '1';\n\n if (paper.classList.contains('cs_paper')) {\n const pageWrapper = document.createElement('div');\n pageWrapper.className = 'cs_page custom-form-design centercontent cs-flow-canvas';\n pageWrapper.style.visibility = 'visible';\n pageWrapper.appendChild(newDoc);\n paper.appendChild(pageWrapper);\n } else {\n paper.appendChild(newDoc);\n }\n if (withHF) {\n ensurePageRegions(newDoc);\n // If the canonical page was deleted (no content page left to copy from),\n // this new page becomes the canonical source. Otherwise seed its\n // header/footer from the canonical, then start two-way sync.\n if (!firstDoc || !document.contains(firstDoc)) firstDoc = newDoc;\n seedRegionsFromCanonical(newDoc); // no-ops when newDoc IS the canonical\n wireRegionSync(newDoc);\n wireRegionEvents(newDoc);\n wireRegionOrderObserver(newDoc);\n }\n renumberPages();\n postPageCount();\n // Scroll to the new page and mark it active. focusPage handles the host\n // scroll container + waits for the iframe to resize; plain scrollIntoView\n // can't cross the iframe→host boundary reliably.\n if (FC.focusPage) FC.focusPage(newDoc);\n else newDoc.scrollIntoView({ behavior: 'smooth', block: 'start' });\n return newDoc;\n };\n\n FC.removePage = function (docEl) {\n if (!docEl || docEl === firstDoc) return false; // can't remove page 1\n docEl.remove();\n renumberPages();\n postPageCount();\n return true;\n };\n\n // Remove the page the user is currently viewing (scroll-tracked active page),\n // covering both content pages (`.cs_page` wrapping a `.cs_margin`) and cover\n // pages (`.cs_page[data-cs-cover]`). Falls back to the last page when nothing\n // is selected. Page 1 (the header/footer canonical source) and the very last\n // remaining page are protected. Posts a `page:removed` result so the host can\n // surface why a delete was refused.\n FC.removeActivePage = function () {\n const pages = Array.from(paper.querySelectorAll(':scope > .cs_page'));\n if (pages.length <= 1) return postRemoveResult(false, 'last');\n\n const page = (FC.getSelectedPage && FC.getSelectedPage()) || pages[pages.length - 1];\n if (!page || !pages.includes(page)) return postRemoveResult(false, 'none');\n\n const isCover = page.matches('[data-cs-cover=\"1\"]');\n\n // Any page can be deleted as long as one page (of any type) remains. If this\n // page held the header/footer canonical source (firstDoc), hand that role to\n // another content page when one exists; otherwise leave it empty — the next\n // content page added re-establishes it (see addPage). Every content page\n // already carries identical, synced header/footer content, so any other\n // `.cs_margin` is a valid replacement.\n if (!isCover && firstDoc && page.contains(firstDoc)) {\n firstDoc = Array.from(paper.querySelectorAll('.cs_margin')).find((m) => m !== firstDoc) || null;\n }\n\n // Pick a neighbour to scroll to once this page is gone.\n const i = pages.indexOf(page);\n const neighbor = pages[i - 1] || pages[i + 1] || null;\n\n page.remove();\n renumberPages();\n if (neighbor && FC.focusPage) FC.focusPage(neighbor);\n postPageCount();\n return postRemoveResult(true);\n };\n\n // Clear the content of the page the user is viewing WITHOUT deleting the page:\n // every dropped block / row is removed, but the header & footer regions, the\n // page-number / overflow chrome and any designed background-shape layer are\n // kept. Works on content pages (clears the `.body-main-content`) and cover\n // pages (clears the absolutely-positioned blocks). Falls back to the last\n // page when nothing is scrolled into view.\n FC.clearActivePage = function () {\n const pages = Array.from(paper.querySelectorAll(':scope > .cs_page'));\n const page = (FC.getSelectedPage && FC.getSelectedPage()) || pages[pages.length - 1];\n if (!page) return false;\n const doc = page.matches('[data-cs-cover=\"1\"]')\n ? page\n : (page.querySelector(':scope > .cs_margin') || page);\n\n // Drop any selection / inline-edit chrome first so no overlay is orphaned\n // when its block is detached.\n try { window.EditorManager?.clearAll?.(); } catch (e) { /* */ }\n\n // Content page: empty the main body region (keeps header/footer in place).\n const main = doc.querySelector(':scope > .body-main-content');\n if (main) main.innerHTML = '';\n\n // Sweep any remaining top-level user content — covers / blank pages keep\n // their blocks directly on the doc, and legacy pages may have stray rows.\n Array.from(doc.children).forEach((c) => {\n if (c === main) return;\n if (c.matches('.cs-page-header, .cs-page-footer')) return; // shared regions\n if (c.matches('[data-cs-chrome]')) return; // page number / marks\n if (c.classList && c.classList.contains('cs-page-shape-bg')) return; // designed bg\n c.remove();\n });\n\n try {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:cleared', ok: true }, '*');\n } catch (e) { /* ignore */ }\n return true;\n };\n\n // -------------------------------------------------------------------------\n // Cover page — a free-move canvas.\n //\n // Unlike a normal page (rigid row/col flow), a cover page's body is one\n // full-page `.cs-flexible-content`. Every block dropped onto it is placed\n // with `position:absolute` (free move + resize), reusing the existing\n // flexible-container machinery in row-col-builder.js / inline-editor.js /\n // drop-zones.js. The `data-cs-cover=\"1\"` flag lets placeBlock relax the\n // `restrictInFlexible` rule so ALL block types are allowed here. No\n // header/footer regions — it always renders blank like an added page.\n //\n // window.FlowCanvas.addCoverPage() → docEl\n // -------------------------------------------------------------------------\n FC.addCoverPage = function () {\n // A cover page is a free-move canvas: the `.cs_page` IS the page and the\n // positioning context — dropped blocks become absolutely-positioned DIRECT\n // children of it. No `.cs_margin` and no inner `.cs-flexible-content`\n // wrapper (that's the structure the export/template layer expects).\n //\n // It carries `.custom-form-design` so the twig generator (which iterates\n // `.custom-form-design`) serialises it as its own sheet, and so the editor\n // surface (now `.cs_paper`-wide) covers it for selection/move/resize.\n // `data-cs-cover=\"1\"` is the single flag drop-zones / placeBlock key off to\n // treat it as a free canvas instead of a row/col flow root.\n const newDoc = document.createElement('div');\n newDoc.className = 'cs_page custom-form-design centercontent cs-flow-canvas cs-cover-canvas';\n newDoc.id = `cover_${FC.generateHash ? FC.generateHash() : Math.random().toString(16).slice(2)}`;\n newDoc.dataset.csCover = '1';\n newDoc.dataset.csNoHeaderFooter = '1';\n newDoc.style.visibility = 'visible';\n\n paper.appendChild(newDoc);\n renumberPages();\n postPageCount();\n if (FC.focusPage) FC.focusPage(newDoc);\n else newDoc.scrollIntoView({ behavior: 'smooth', block: 'start' });\n return newDoc;\n };\n\n // -------------------------------------------------------------------------\n // Page break splitter\n //\n // When the user drops a Page Break block, we split the source doc at\n // that location. Everything AFTER the break (including the block that\n // contains the break itself) is moved onto a freshly-created page so\n // the user's content naturally flows onto two pages. The break block\n // itself is discarded — its presence in the DOM was just a marker for\n // where to cut. Header/footer regions on the source page are\n // preserved on the source; the destination page is created without\n // them (matches the manual \"Add Page\" default).\n // -------------------------------------------------------------------------\n FC.splitPageAt = function (docEl, breakBlock) {\n if (!docEl || !breakBlock) return null;\n const breakRow = breakBlock.closest('.row-item');\n if (!breakRow || breakRow.parentElement !== docEl) return null;\n\n // Collect every row that comes AFTER the break row at the doc root,\n // skipping the footer (which always stays at the bottom of the\n // source page). Order is preserved because we walk forward.\n const rowsToMove = [];\n let cursor = breakRow.nextElementSibling;\n while (cursor) {\n const next = cursor.nextElementSibling;\n if (cursor.classList && cursor.classList.contains('cs-page-footer')) {\n cursor = next;\n continue;\n }\n rowsToMove.push(cursor);\n cursor = next;\n }\n\n // Remove the break marker row itself — its job is done. If the row\n // contained other siblings inside the same column, keep those by\n // detaching just the break block.\n const breakCol = breakBlock.closest('.col-item');\n if (breakCol && breakCol.children.length > 1) {\n breakBlock.remove();\n } else {\n breakRow.remove();\n }\n\n // Create the destination page and move rows over (in original\n // order, before the destination footer if it has one).\n const newDoc = FC.addPage({ headerFooter: false });\n if (!newDoc) return null;\n const newFooter = newDoc.querySelector(':scope > .cs-page-footer');\n rowsToMove.forEach((row) => {\n if (newFooter) newDoc.insertBefore(row, newFooter);\n else newDoc.appendChild(row);\n });\n return newDoc;\n };\n\n // -------------------------------------------------------------------------\n // A4 overflow indicator\n //\n // Adds .cs-overflowing to any .cs_margin whose content exceeds the\n // configured A4 height. CSS renders a dashed boundary at the A4 mark\n // with a hint suggesting the user drop a Page Break. We only TOGGLE\n // the class — the visible split is the user's call.\n // -------------------------------------------------------------------------\n const PAGE_TARGET_HEIGHT = (window.CanvasConfig?.page?.minHeight) ?? 1123;\n // Measure how tall the doc's actual children stretch. We can't rely on\n // d.scrollHeight because the `.cs-overflowing::after` pseudo-element is\n // positioned at top: 1123px and contributes to scrollHeight — meaning\n // once we add the class, scrollHeight is locked at >=1123 forever and\n // the class can never come back off when content shrinks.\n const measureContentBottom = (docEl) => {\n let bottom = 0;\n docEl.querySelectorAll(':scope > .row-item, :scope > .body-main-content').forEach((row) => {\n const rect = row.getBoundingClientRect();\n const docRect = docEl.getBoundingClientRect();\n const offset = (rect.bottom - docRect.top);\n if (offset > bottom) bottom = offset;\n });\n return bottom;\n };\n const ensureOverflowMark = (docEl) => {\n let mark = docEl.querySelector(':scope > .cs-overflow-mark');\n if (!mark) {\n mark = document.createElement('div');\n mark.className = 'cs-overflow-mark';\n mark.setAttribute('data-cs-chrome', '1');\n const label = document.createElement('span');\n label.className = 'cs-overflow-mark__label';\n label.textContent = 'Suggested page break — drag a Page Break here';\n mark.appendChild(label);\n docEl.appendChild(mark);\n }\n };\n const removeOverflowMark = (docEl) => {\n docEl.querySelectorAll(':scope > .cs-overflow-mark').forEach((m) => m.remove());\n };\n const updatePageNumbers = () => {\n const docs = Array.from(paper.querySelectorAll('.cs_margin'));\n const total = docs.length;\n docs.forEach((d, index) => {\n let pageNumEl = d.querySelector(':scope > .cs-page-number');\n if (!pageNumEl) {\n pageNumEl = document.createElement('div');\n pageNumEl.className = 'cs-page-number';\n pageNumEl.setAttribute('data-cs-chrome', '1');\n pageNumEl.style.fontSize = '12px';\n pageNumEl.style.color = '#505b65';\n pageNumEl.style.paddingLeft = '4px';\n d.appendChild(pageNumEl);\n }\n pageNumEl.textContent = `Page ${index + 1} of ${total}`;\n\n if (d.lastElementChild !== pageNumEl) {\n d.appendChild(pageNumEl);\n }\n });\n };\n\n const updateOverflowMarks = () => {\n paper.querySelectorAll('.cs_margin').forEach((d) => {\n const contentBottom = measureContentBottom(d);\n const overflowing = contentBottom > PAGE_TARGET_HEIGHT + 1;\n if (overflowing) ensureOverflowMark(d);\n else removeOverflowMark(d);\n });\n // updatePageNumbers();\n };\n // MutationObserver catches additions/removals (childList) and inline\n // style edits (attributes). We exclude our own .cs-overflowing class\n // flips from triggering re-runs by listing the attribute filter\n // explicitly — `class` is included so legitimate class changes still\n // re-check, but the guard above prevents loops.\n const overflowObs = new MutationObserver(() => requestAnimationFrame(updateOverflowMarks));\n overflowObs.observe(paper, {\n childList: true,\n subtree: true,\n characterData: true,\n attributes: true,\n attributeFilter: ['style', 'class'],\n });\n // ResizeObserver covers the case where content stays the same DOM but\n // its rendered height changes (image loads, text re-flows, etc.) so the\n // indicator hides as soon as the doc shrinks back under A4.\n if (typeof ResizeObserver !== 'undefined') {\n const ro = new ResizeObserver(() => requestAnimationFrame(updateOverflowMarks));\n const observeDocs = () => {\n paper.querySelectorAll('.cs_margin').forEach((d) => ro.observe(d));\n };\n observeDocs();\n // Re-observe whenever a new doc is added (FC.addPage).\n new MutationObserver(observeDocs).observe(paper, { childList: true });\n }\n requestAnimationFrame(updateOverflowMarks);\n\n // -------------------------------------------------------------------------\n // Auto-resize: tell the parent shell how tall our content is so the\n // iframe can grow to fit all stacked pages.\n // -------------------------------------------------------------------------\n const reportHeight = () => {\n // Measure the ACTUAL content (the paper), never document.body /\n // documentElement scrollHeight: those are pinned to the iframe's\n // host-forced height, so once the host grows the iframe they floor at\n // that value and never let it shrink again (e.g. after a page is\n // deleted the empty space stays). Walking the offset chain gives the\n // paper's absolute bottom independent of the iframe's current height.\n let top = 0;\n for (let el = paper; el; el = el.offsetParent) top += el.offsetTop;\n const contentH = Math.max(paper.offsetHeight, paper.scrollHeight);\n const h = Math.ceil(top + contentH + 64);\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'iframe:height',\n height: h,\n }, '*');\n } catch (e) { /* parent on different origin — ignore */ }\n };\n const heightObs = new MutationObserver(() => requestAnimationFrame(reportHeight));\n heightObs.observe(paper, { childList: true, subtree: true, attributes: true });\n window.addEventListener('load', reportHeight);\n requestAnimationFrame(reportHeight);\n // Tell the host how many pages exist on boot (e.g. a multi-page template\n // loaded) so the footer / Delete-page button start in sync.\n window.addEventListener('load', postPageCount);\n requestAnimationFrame(postPageCount);\n\n // -------------------------------------------------------------------------\n // postMessage listener — host shell can ask us to add/remove pages.\n // -------------------------------------------------------------------------\n window.addEventListener('message', (e) => {\n const msg = e.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n if (msg.type === 'page:add') {\n FC.addPage({ headerFooter: msg.headerFooter !== false });\n }\n if (msg.type === 'page:add-cover') {\n FC.addCoverPage();\n }\n if (msg.type === 'page:remove' && msg.pageNumber > 1) {\n const docEl = paper.querySelector(`.cs_margin[data-page=\"${msg.pageNumber}\"]`);\n FC.removePage(docEl);\n }\n if (msg.type === 'page:remove-active') {\n FC.removeActivePage();\n }\n if (msg.type === 'page:clear-active') {\n FC.clearActivePage();\n }\n if (msg.type === 'page-size:change' && msg.sizeKey) {\n if (typeof window.setCanvasPageSize === 'function') {\n window.setCanvasPageSize(msg.sizeKey);\n }\n }\n if (msg.type === 'page-bg:change') {\n if (typeof window.setCanvasPageBackground === 'function') {\n window.setCanvasPageBackground(msg.imageUrl || '');\n }\n }\n // Per-page background image: apply to the page the user is currently viewing\n // (content `.cs_margin` OR cover `.cs_page[data-cs-cover]`). Inline style wins\n // over the global `--cs-page-bg-image` var; `data-cs-bg-image` lets us read it\n // back (panel preview on page switch) and survives template save/export.\n if (msg.type === 'page-bg:set-active') {\n const page = FC.getSelectedDrawablePage ? FC.getSelectedDrawablePage() : null;\n if (page) {\n const url = msg.imageUrl || '';\n if (url) {\n page.style.backgroundImage = `url(\"${url}\")`;\n page.style.backgroundSize = 'cover';\n page.style.backgroundPosition = 'center';\n page.style.backgroundRepeat = 'no-repeat';\n page.dataset.csBgImage = url;\n } else {\n page.style.backgroundImage = '';\n delete page.dataset.csBgImage;\n }\n // Echo back so the panel preview matches this page immediately.\n try { window.parent?.postMessage({ source: 'custom-form-twig', type: 'page:active', bgImage: url }, '*'); } catch (e) { /* */ }\n }\n }\n if (msg.type === 'component:capture') {\n const data = window.FlowCanvas?.captureComponent?.() || null;\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'component:captured', data }, '*');\n }\n if (msg.type === 'component:insert') {\n window.FlowCanvas?.insertComponentHtml?.(msg.html);\n }\n if (msg.type === 'block:select' && msg.blockId) {\n // Panel asked us to select an ancestor block (the \"Choose parent\" button).\n const el = document.getElementById(msg.blockId);\n if (el) {\n if (window.EditorManager?.select) window.EditorManager.select(el);\n else el.click();\n }\n }\n if (msg.type === 'comment:toggle') {\n window.Collab?.toggleCommentMode?.();\n }\n if (msg.type === 'collab:config') {\n window.Collab?.applyConfig?.(msg.config);\n }\n if (msg.type === 'page-shape:open') {\n window.PageShapeDesigner?.open();\n }\n if (msg.type === 'page-shape:clear') {\n // Per-page: remove the shape only from the page the user is working on.\n window.PageShapeDesigner?.removeFromActive();\n }\n if (msg.type === 'page-margins:change') {\n const margins = msg.margins || {};\n const { top, right, bottom, left } = margins;\n paper.querySelectorAll('.cs_margin').forEach(docEl => {\n docEl.style.padding = `${top || 0}mm ${right || 0}mm ${bottom || 0}mm ${left || 0}mm`;\n });\n setTimeout(updateOverflowMarks, 50);\n }\n if (msg.type === 'header-footer:toggle') {\n ENABLE_HEADER_FOOTER = msg.enabled;\n paper.querySelectorAll('.cs_margin').forEach(p => {\n // Cover pages are always blank free-move canvases — never give them\n // header/footer regions, regardless of the global toggle.\n if (p.dataset.csCover === '1') return;\n if (msg.enabled) {\n delete p.dataset.csNoHeaderFooter;\n ensurePageRegions(p);\n seedRegionsFromCanonical(p);\n wireRegionSync(p);\n wireRegionEvents(p);\n wireRegionOrderObserver(p);\n } else {\n p.dataset.csNoHeaderFooter = '1';\n const h = p.querySelector(':scope > .cs-page-header');\n const f = p.querySelector(':scope > .cs-page-footer');\n if (h) h.remove();\n if (f) f.remove();\n }\n });\n // Optionally re-measure after structural changes\n setTimeout(updateOverflowMarks, 50);\n }\n if (msg.type === 'inline-insert:toggle') {\n const enabled = window.FlowCanvas.setInlineInsertEnabled?.(msg.enabled) !== false;\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'inline-insert:state',\n enabled\n }, '*');\n }\n if (msg.type === 'rich-toolbar:dock') {\n // Place the CustomRichEditor toolbar: docked (top sticky) vs inline float.\n if (typeof window.setRichToolbarDocked === 'function') {\n window.setRichToolbarDocked(!!msg.docked);\n } else if (window.CanvasConfig && window.CanvasConfig.editor) {\n window.CanvasConfig.editor.dockRichToolbar = !!msg.docked;\n }\n }\n if (msg.type === 'set-block-style') {\n const block = document.getElementById(msg.blockId);\n if (!block) return;\n\n // ===== HANDLE LAYOUT PROPERTIES (layoutStyle, layoutColumns, sectionColor) =====\n if (msg.prop === 'layoutColumns') {\n const contentArea = block.querySelector('.cs-flexible-content');\n if (contentArea) {\n contentArea.dataset.layoutColumns = msg.value;\n // Remove all layout classes\n contentArea.classList.remove('cs-layout--one-col', 'cs-layout--two-col-wave', 'cs-layout--two-col-diagonal', 'cs-layout--two-col-organic', 'cs-layout--three-col');\n // Add appropriate class\n const layoutStyle = contentArea.dataset.layoutStyle || 'wave';\n if (msg.value === '1') {\n contentArea.classList.add('cs-layout--one-col');\n } else if (msg.value === '2') {\n contentArea.classList.add(`cs-layout--two-col-${layoutStyle}`);\n } else if (msg.value === '3') {\n contentArea.classList.add('cs-layout--three-col');\n }\n }\n return;\n }\n\n if (msg.prop === 'sectionColor') {\n const contentArea = block.querySelector('.cs-flexible-content, .section-container-content');\n if (contentArea) {\n contentArea.style.backgroundColor = msg.value;\n }\n return;\n }\n\n // ===== HANDLE REGULAR STYLE PROPERTIES =====\n // Check if this block is currently in editing mode with Froala active\n const isEditing = block.classList.contains('cs-editing');\n const hasFroala = window.FroalaStyleHandler && window.FroalaStyleHandler.hasActiveEditor();\n\n // If block is editing and Froala is active, use Froala commands for typography\n if (isEditing && hasFroala) {\n const typographyCommands = {\n 'color': () => window.FroalaStyleHandler.applyColor(msg.value),\n 'fontSize': () => window.FroalaStyleHandler.applyFontSize(msg.value),\n 'fontWeight': () => window.FroalaStyleHandler.applyFontWeight(msg.value)\n };\n\n if (msg.prop in typographyCommands) {\n typographyCommands[msg.prop]();\n // Also set inline style as fallback\n const inner = block.querySelector('.edit_me, .canvas-block__content');\n if (inner) inner.style[msg.prop] = msg.value;\n return;\n }\n }\n\n // Fallback: Apply as inline styles (for non-editing blocks or non-Froala props)\n const typographyProps = ['color', 'fontSize', 'fontWeight'];\n const containerProps = ['backgroundColor', 'borderStyle', 'borderColor', 'borderWidth', 'borderRadius'];\n\n // For typography (color, fontSize, fontWeight), apply to inner editable element\n const typographyTarget = block.querySelector('.edit_me, .canvas-block__content');\n if (typographyProps.includes(msg.prop) && typographyTarget) {\n typographyTarget.style[msg.prop] = msg.value;\n return;\n }\n\n // For background/border on flexible/section containers, apply to the content area\n const isFlexible = block.classList.contains('cs-flexible-block');\n const isSection = block.dataset.blockType === 'section-container' || block.getAttribute('data') === 'Section Container';\n\n if ((isFlexible || isSection) && containerProps.includes(msg.prop)) {\n const contentArea = block.querySelector('.cs-flexible-content, .section-container-content');\n if (contentArea) {\n contentArea.style[msg.prop] = msg.value;\n return;\n }\n }\n\n // Default: Apply to outer block\n block.style[msg.prop] = msg.value;\n }\n });\n\n // -------------------------------------------------------------------------\n // Global click / keyboard: deactivate header/footer focus\n // -------------------------------------------------------------------------\n if (ENABLE_HEADER_FOOTER) {\n canvas.addEventListener('click', (e) => {\n if (!e.target.closest('.cs-page-header') && !e.target.closest('.cs-page-footer')) {\n setRegionActive(null);\n }\n });\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') setRegionActive(null);\n });\n }\n\n // -------------------------------------------------------------------------\n // Drag payload helpers\n // -------------------------------------------------------------------------\n const DRAG_STORE_KEY = '__BROCHURE_FLOW_DRAG__';\n\n const parsePayload = (value) => {\n if (!value) return null;\n try { return JSON.parse(value); } catch (e) { return null; }\n };\n\n const getDragPayload = (event) => {\n const direct =\n parsePayload(event.dataTransfer?.getData('application/x-brochure-block')) ||\n parsePayload(event.dataTransfer?.getData('text/plain'));\n if (direct?.blockType) {\n console.log('getDragPayload: got direct payload from dataTransfer');\n return direct;\n }\n try {\n const fallback = window.parent?.[DRAG_STORE_KEY] ?? null;\n if (fallback?.blockType) {\n console.log('getDragPayload: got fallback payload from parent window:', fallback);\n return fallback;\n }\n console.log('getDragPayload: no payload found');\n return null;\n } catch (e) {\n console.warn('getDragPayload: error accessing parent window:', e);\n return null;\n }\n };\n\n const createBlockFromPayload = (payload) => {\n // Reusable component: build from stored HTML instead of the block factory.\n if (payload?.blockType === 'component' && payload.componentHtml) {\n return FC.buildComponentBlock?.(payload.componentHtml) || null;\n }\n if (!payload?.blockType) return null;\n\n const block = FC.createBlock?.(payload.blockType);\n if (!block) {\n console.warn('BLOCK CREATE: failed for blockType:', payload.blockType);\n return null;\n }\n\n if (payload.blockType === 'fa-icon' && payload.class) {\n const iconEl = block.querySelector('i');\n if (iconEl) {\n iconEl.className = payload.class;\n block.dataset.iconName = payload.icon || 'star';\n block.dataset.iconClass = payload.class;\n }\n }\n\n return block;\n };\n\n const maybeOpenBindingModal = (payload, block) => {\n if (!payload?.blockType || !block) return;\n const REPEATER_TYPES = window.FormBlockRegistry?.repeaterTypes() ||\n ['section-container', 'table-repeater', 'list-repeater'];\n if (REPEATER_TYPES.includes(payload.blockType) &&\n typeof window.showSectionBindingModal === 'function') {\n window.showSectionBindingModal(block);\n }\n };\n\n const insertPayloadAtTarget = ({ payload, activeDoc, target, clientX, clientY }) => {\n if (!payload?.blockType || !activeDoc || !target) return null;\n\n const block = createBlockFromPayload(payload);\n if (!block) return null;\n\n // A reusable component carries blockType 'component', but for placement\n // rules (row/col wrap + the flexible-container restriction in placeBlock) it\n // must behave like its underlying block — e.g. a saved Section must bounce\n // out of a flexible container just like a real section-container would,\n // instead of being placed as a stray absolute child. Derive the real type\n // from the built block.\n const effectiveType = payload.blockType === 'component'\n ? (block.dataset.blockType || 'component')\n : payload.blockType;\n\n if (payload.blockType === 'page-break') {\n let beforeRow = null;\n if (target.kind === 'between-rows') {\n beforeRow = target.beforeRow || null;\n } else if (target.kind === 'col-edge' || target.kind === 'in-col') {\n const refRow = (target.row || target.col || target.beforeBlock)?.closest?.('.row-item');\n beforeRow = refRow?.nextElementSibling || null;\n }\n FC.placeBlock?.(activeDoc, block, { kind: 'between-rows', beforeRow }, clientX, clientY, payload.blockType);\n if (typeof FC.splitPageAt === 'function') FC.splitPageAt(activeDoc, block);\n return block;\n }\n\n FC.placeBlock?.(activeDoc, block, target, clientX, clientY, effectiveType);\n maybeOpenBindingModal(payload, block);\n return block;\n };\n\n // -------------------------------------------------------------------------\n // Drag-and-drop event wiring\n // -------------------------------------------------------------------------\n console.log('FLOW-CANVAS: drop listeners attached to element:', paper?.className || paper?.id || 'unknown');\n\n paper.addEventListener('dragenter', (event) => {\n console.log('FLOW-CANVAS: dragenter fired');\n if (getDragPayload(event)) {\n console.log('FLOW-CANVAS: dragenter has valid payload');\n event.preventDefault();\n const activeDoc = findActiveDoc(event.clientX, event.clientY);\n const page = activeDoc?.closest('.custom-form-design');\n if (page) page.classList.add('drop-surface--active');\n }\n });\n\n paper.addEventListener('dragover', (event) => {\n console.log('FLOW-CANVAS: dragover fired');\n if (getDragPayload(event)) {\n console.log('FLOW-CANVAS: dragover has valid payload, calling preventDefault');\n event.preventDefault();\n event.dataTransfer.dropEffect = 'copy';\n }\n });\n\n // Pick the page that's currently under the pointer (multi-page aware).\n // Includes cover pages, which are free-canvas `.cs_page[data-cs-cover]`\n // elements rather than `.cs_margin` flow pages.\n const findActiveDoc = (clientX, clientY) => {\n const docs = Array.from(paper.querySelectorAll('.cs_margin, .cs_page[data-cs-cover=\"1\"]'));\n for (const d of docs) {\n const r = d.getBoundingClientRect();\n if (clientY >= r.top && clientY <= r.bottom) return d;\n }\n return docs[0] || firstDoc;\n };\n\n paper.addEventListener('dragover', (event) => {\n const payload = getDragPayload(event);\n if (!payload) return;\n event.preventDefault();\n event.dataTransfer.dropEffect = 'copy';\n const activeDoc = findActiveDoc(event.clientX, event.clientY);\n\n paper.querySelectorAll('.drop-surface--active').forEach(el => el.classList.remove('drop-surface--active'));\n const page = activeDoc?.closest('.custom-form-design');\n if (page) page.classList.add('drop-surface--active');\n\n const result = FC.findDropTarget?.(activeDoc, paper, event.clientX, event.clientY, payload.blockType);\n if (result) {\n FC.showIndicator?.(result.indicator);\n paper._pendingDropTarget = result.target;\n paper._pendingDropDoc = activeDoc;\n }\n });\n\n paper.addEventListener('dragleave', (event) => {\n if (!paper.contains(event.relatedTarget)) {\n paper.querySelectorAll('.drop-surface--active').forEach(el => el.classList.remove('drop-surface--active'));\n FC.hideIndicator?.();\n paper._pendingDropTarget = null;\n }\n });\n\n let lastDropAt = 0;\n paper.addEventListener('drop', (event) => {\n console.log('DROP EVENT FIRED');\n const payload = getDragPayload(event);\n console.log('DROP: getDragPayload result:', payload);\n if (!payload) {\n console.warn('DROP: no payload found');\n return;\n }\n event.preventDefault();\n event.stopPropagation();\n paper.querySelectorAll('.drop-surface--active').forEach(el => el.classList.remove('drop-surface--active'));\n FC.hideIndicator?.();\n\n // De-dupe: some browsers / wrapper frames emit duplicate drop events.\n const now = performance.now();\n if (now - lastDropAt < 200) {\n paper._pendingDropTarget = null;\n return;\n }\n lastDropAt = now;\n\n const activeDoc = paper._pendingDropDoc || findActiveDoc(event.clientX, event.clientY);\n const result = paper._pendingDropTarget ||\n FC.findDropTarget?.(activeDoc, paper, event.clientX, event.clientY, payload.blockType)?.target;\n paper._pendingDropTarget = null;\n paper._pendingDropDoc = null;\n\n // Sidebar drop: build a fresh block.\n console.log('DROP: payload =', payload);\n const block = insertPayloadAtTarget({\n payload,\n activeDoc,\n target: result,\n clientX: event.clientX,\n clientY: event.clientY,\n });\n console.log('DROP: block inserted?', !!block);\n });\n\n // -------------------------------------------------------------------------\n // Feature modules\n // -------------------------------------------------------------------------\n FC.initColResize?.(canvas);\n FC.initFieldPanel?.(canvas);\n FC.initHistory?.(canvas);\n FC.initDimensionIndicator?.(canvas);\n FC.initInlineInsert?.(canvas);\n FC.initCopyPaste?.(canvas);\n FC.initImageZoom?.(canvas);\n // Per-doc feature wiring (cleanup observer, block reorder) — also run\n // these for any future docs added via FC.addPage().\n const wireDocFeatures = (docEl) => {\n FC.initCleanupObserver?.(docEl);\n FC.initBlockReorder?.(canvas, docEl);\n };\n wireDocFeatures(firstDoc);\n const _origAddPage = FC.addPage;\n FC.addPage = function (opts) {\n const newDoc = _origAddPage.call(FC, opts);\n if (newDoc) wireDocFeatures(newDoc);\n return newDoc;\n };\n const _origAddCoverPage = FC.addCoverPage;\n FC.addCoverPage = function () {\n const newDoc = _origAddCoverPage.call(FC);\n if (newDoc) wireDocFeatures(newDoc);\n return newDoc;\n };\n\n // -------------------------------------------------------------------------\n // Image upload handler\n // -------------------------------------------------------------------------\n const initImageUpload = () => {\n // Attach to the whole board (.cs_paper) — not just page 1's canvas — so the\n // image upload also fires for image blocks on added pages and cover pages,\n // which live in their own sibling `.custom-form-design` wrappers.\n paper.addEventListener('click', (e) => {\n const imgBtn = e.target.closest('.img-btn');\n if (!imgBtn) return;\n\n const imageBlock = imgBtn.closest('.cs_block_s');\n const isImageBlockArmed = !!imageBlock &&\n (imageBlock.classList.contains('cs-selected') || imageBlock.classList.contains('cs-editing'));\n\n // First click should only select the image block. We let the normal\n // inline-editor click state machine handle that. Only when the block\n // is already selected/editing do we intercept and open the upload modal.\n if (!isImageBlockArmed) return;\n\n e.preventDefault();\n e.stopPropagation();\n\n // Create a hidden file input\n const fileInput = document.createElement('input');\n fileInput.type = 'file';\n fileInput.accept = 'image/*';\n\n fileInput.addEventListener('change', (event) => {\n const files = event.target.files;\n if (!files || files.length === 0) return;\n const file = files[0];\n\n const reader = new FileReader();\n reader.onload = (e) => {\n const imageDataUrl = e.target.result;\n const imageContainer = imgBtn.closest('.image-container');\n\n if (imageContainer) {\n // Remove the existing button and img if present\n imgBtn.remove();\n const existingImg = imageContainer.querySelector('img');\n if (existingImg) existingImg.remove();\n\n // Create and add the image element\n const img = document.createElement('img');\n img.src = imageDataUrl;\n img.alt = 'Uploaded image';\n imageContainer.appendChild(img);\n }\n };\n reader.readAsDataURL(file);\n });\n\n fileInput.click();\n }, true); // Use capture phase to catch clicks before other handlers\n };\n\n initImageUpload();\n\n // -------------------------------------------------------------------------\n // Send initial header/footer state to parent\n // -------------------------------------------------------------------------\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'header-footer:state',\n enabled: ENABLE_HEADER_FOOTER\n }, '*');\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'inline-insert:state',\n enabled: window.FlowCanvas.isInlineInsertEnabled?.() !== false\n }, '*');\n\n // -------------------------------------------------------------------------\n // Debug surface\n // -------------------------------------------------------------------------\n window.__FLOW_CANVAS__ = { canvas, doc, FC };\n Object.assign(FC, {\n createBlockFromPayload,\n insertPayloadAtTarget,\n });\n console.log('flow-canvas: initialized');\n})();\n\n<\/script>\n\n <script data-src=\"./js/common-twig-generator.js\">\n/**\n * @fileoverview Common Twig Code Generator\n * Captures drag, drop, move, resize, etc. on the canvas and generates Twig code natively,\n * passing state and selections back to the Angular parent context.\n */\n(function () {\n const CANVAS_SELECTOR = '.custom-form-design';\n const BLOCK_SELECTOR = '.canvas-block, .cs_block_s';\n\n const state = {\n twig: '',\n };\n\n const notify = () => {\n try {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'twig:updated',\n data: { twig: state.twig }\n }, '*');\n } catch (e) { }\n };\n\n const getCanvas = () => document.querySelector(CANVAS_SELECTOR);\n\n const stripChrome = (root) => {\n const clone = root.cloneNode(true);\n clone.querySelectorAll('[data-cs-chrome], .section-binding-info').forEach((el) => el.remove());\n // Strip editor-only selection markers. `cs_selected` / `cs_selected_border`\n // are the scroll-driven \"active page\" highlight on .cs_page wrappers (see\n // active-page.js) and must never appear in the exported markup. The root\n // itself can carry them (a cover page IS the .custom-form-design root).\n [clone, ...clone.querySelectorAll('.cs-selected, .cs-editing, .canvas-block--selected, .cs_selected, .cs_selected_border, .cs-aiden--active, .cs-aiden--loading')]\n .forEach((el) => {\n el.classList?.remove('cs-selected', 'cs-editing', 'canvas-block--selected', 'cs_selected', 'cs_selected_border', 'cs-aiden--active', 'cs-aiden--loading');\n });\n // Aiden's empty-state hint is a `:empty:before` placeholder — drop it on\n // empty AI-writer blocks so the hint text never shows in the export.\n clone.querySelectorAll('.cs-aiden-block .edit_me[placeholder]').forEach((el) => {\n if (!(el.textContent || '').trim()) el.removeAttribute('placeholder');\n });\n\n // Section wrappers used to record their last manual resize as an\n // inline `height: NNNpx`. With flow layout that height clips growing\n // content (and worse — clips the rendered PDF), so we drop it from\n // the emitted markup whenever the block contains a flow section.\n const matchesSection = (el) => !!(el.querySelector && el.querySelector(':scope > .section-container-content'));\n const allBlocks = [clone, ...clone.querySelectorAll('.cs_block_s')];\n allBlocks.forEach((el) => {\n if (!el.style) return;\n if (matchesSection(el)) {\n el.style.height = '';\n el.style.minHeight = '';\n }\n });\n\n return clone;\n };\n\n const generateForCanvas = (canvas) => {\n if (!canvas) return '';\n\n const allBlocks = Array.from(canvas.querySelectorAll(BLOCK_SELECTOR));\n\n // Assign temp IDs\n allBlocks.forEach((b, i) => {\n if (!b.dataset.twigId) {\n b.dataset.twigId = 'tw_' + Math.random().toString(36).substr(2, 9);\n }\n });\n\n // Process from deepest to shallowest to gracefully replace inner HTML\n allBlocks.sort((a, b) => {\n let depthA = 0, currA = a; while (currA) { depthA++; currA = currA.parentElement; }\n let depthB = 0, currB = b; while (currB) { depthB++; currB = currB.parentElement; }\n return depthB - depthA;\n });\n\n const blockTwigMap = new Map();\n\n for (const block of allBlocks) {\n const clone = stripChrome(block);\n\n const subBlocks = clone.querySelectorAll(BLOCK_SELECTOR);\n subBlocks.forEach(sb => {\n // If a subblock was identified, we replace it in the clone\n // Note: the clone's subBlocks still have their dataset if they were on the live block\n const tid = sb.dataset.twigId;\n if (tid && blockTwigMap.has(tid)) {\n const marker = document.createComment(`__TWIG_ID_${tid}__`);\n sb.replaceWith(marker);\n }\n });\n\n // Row/cell-level conditions: <tr>/<td>/<th> carrying data-twig-if get\n // wrapped — whole element — in {% if %}...{% endif %}, so when the\n // condition is false the entire <tr>/<td> disappears from the output\n // (not just its content). NOTE: removing a single <td> shifts the\n // remaining columns in that row, so use cell conditions only when a\n // missing column is acceptable. The expressions are stashed and\n // replaced by numbered comment markers, then swapped for twig after\n // serialisation — that keeps comparison operators (<, >) in the\n // expression from being HTML-escaped by outerHTML.\n const elementConditions = [];\n clone.querySelectorAll('tr[data-twig-if], td[data-twig-if], th[data-twig-if]').forEach((el) => {\n const expr = (el.getAttribute('data-twig-if') || '').trim();\n el.removeAttribute('data-twig-if');\n if (!expr) return;\n const idx = elementConditions.length;\n elementConditions.push(expr);\n el.before(document.createComment(`__IFEL_START_${idx}__`));\n el.after(document.createComment(`__IFEL_END_${idx}__`));\n });\n\n let rawHTML = clone.outerHTML;\n rawHTML = rawHTML.replace(/<!--__TWIG_ID_([^>]+)__-->/g, (match, tid) => {\n return blockTwigMap.get(tid) || '';\n });\n if (elementConditions.length) {\n rawHTML = rawHTML\n .replace(/<!--__IFEL_START_(\\d+)__-->/g, (_, i) => `{% if ${elementConditions[i]} %}`)\n .replace(/<!--__IFEL_END_(\\d+)__-->/g, () => `{% endif %}`);\n }\n\n const repeatPath = block.dataset.repeatPath || '';\n const repeatAlias = block.dataset.repeatAlias || '';\n const ifExpr = block.dataset.twigIf || '';\n\n // Clean up custom twig attributes from the generated HTML\n rawHTML = rawHTML.replace(/\\s+data-twig-if=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-path=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-alias=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-chain=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-label=\"[^\"]*\"/g, '')\n .replace(/\\s+data-twig-id=\"[^\"]*\"/g, '')\n // Also handle single quotes just in case\n .replace(/\\s+data-twig-if='[^']*'/g, '')\n .replace(/\\s+data-repeat-path='[^']*'/g, '')\n .replace(/\\s+data-repeat-alias='[^']*'/g, '')\n .replace(/\\s+data-repeat-chain='[^']*'/g, '')\n .replace(/\\s+data-repeat-label='[^']*'/g, '')\n .replace(/\\s+data-twig-id='[^']*'/g, '');\n\n let twig = rawHTML;\n\n // Multi-level binding chain (set when the user picks a deeply nested\n // array in the modal). Each entry is one {% for %} loop, outermost\n // first. Single-level bindings keep using data-repeat-path/-alias.\n let chain = null;\n try {\n if (block.dataset.repeatChain) {\n chain = JSON.parse(block.dataset.repeatChain);\n }\n } catch (e) { chain = null; }\n\n // Strip leading chain steps that an ancestor block is ALREADY\n // looping over. Modal-saved chains include every outer loop needed\n // to reach the selected array, but when this block sits inside a\n // section that's already iterating the same outer scope, emitting\n // those steps again would produce nested duplicate {% for %} loops\n // (one from the ancestor block, one from this block) and the data\n // would multiply across both axes.\n if (Array.isArray(chain) && chain.length > 0) {\n const ancestorPaths = new Set();\n let anc = block.parentElement;\n while (anc) {\n if (anc.dataset?.repeatChain) {\n try {\n const ancChain = JSON.parse(anc.dataset.repeatChain);\n if (Array.isArray(ancChain)) ancChain.forEach((s) => s?.path && ancestorPaths.add(s.path));\n } catch (e) { /* ignore */ }\n } else if (anc.dataset?.repeatPath) {\n ancestorPaths.add(anc.dataset.repeatPath);\n }\n if (anc.matches?.('.cs_margin, .cs-flow-canvas') || anc.tagName === 'BODY') break;\n anc = anc.parentElement;\n }\n if (ancestorPaths.size) {\n chain = chain.filter((step) => !ancestorPaths.has(step.path));\n if (chain.length === 0) chain = null;\n }\n }\n\n // Build the {% for %} stack from a chain (multi-level) or the\n // single repeatPath/-alias pair. Returns the wrapped body, or the\n // body unchanged if there's no loop.\n const wrapInLoops = (body) => {\n if (Array.isArray(chain) && chain.length > 0) {\n let out = body;\n for (let i = chain.length - 1; i >= 0; i--) {\n const step = chain[i];\n if (step.kind === 'map' && step.keyAlias) {\n out = `{% for ${step.keyAlias}, ${step.alias} in ${step.path} %}\\n${out}\\n{% endfor %}`;\n } else {\n out = `{% for ${step.alias} in ${step.path} %}\\n${out}\\n{% endfor %}`;\n }\n }\n return out;\n }\n if (repeatPath) {\n const alias = repeatAlias || 'item';\n return `{% for ${alias} in ${repeatPath} %}\\n${body}\\n{% endfor %}`;\n }\n return body;\n };\n\n const hasLoop = (Array.isArray(chain) && chain.length > 0) || !!repeatPath;\n // Tables: by default the whole <table> would repeat, which means\n // <thead> (the column header row) repeats once per iteration too.\n // Almost always the header should appear ONCE and only the data\n // rows under <tbody> should repeat. We rewrite the block to wrap\n // just the <tbody> contents in the {% for %} stack.\n //\n // BUT: when the header itself uses loop-specific content (eg.\n // `Visit {{ loop.index }}` or `{{ item.engineer }}`), the header\n // is supposed to repeat alongside the body — that's effectively a\n // \"card per item\" layout rendered as a table. Detect that by\n // looking for any chain alias or `loop.` reference inside the\n // <thead>; if found, fall back to wrapping the entire block.\n //\n // For non-table loops we DEFER the {% for %} wrap to the canvas\n // finalisation pass: if the block sits alone in its row/col, the\n // wrap is hoisted up to wrap the row instead (so the rendered\n // markup contains one row per iteration, not one block-with-no-row\n // per iteration which would put multiple blocks under the same\n // col and trip duplicate IDs).\n const tbodyMatch = hasLoop ? rawHTML.match(/<tbody[^>]*>([\\s\\S]*?)<\\/tbody>/i) : null;\n const theadMatch = hasLoop ? rawHTML.match(/<thead[^>]*>([\\s\\S]*?)<\\/thead>/i) : null;\n const aliasList = Array.isArray(chain) && chain.length\n ? chain.map((s) => s.alias).concat(chain.filter((s) => s.keyAlias).map((s) => s.keyAlias))\n : (repeatAlias ? [repeatAlias] : []);\n const theadInner = theadMatch ? theadMatch[1] : '';\n const theadIsDynamic = theadInner.includes('loop.') ||\n aliasList.some((a) => a && new RegExp(`\\\\b${a}\\\\b`).test(theadInner));\n\n let wrappedAtBlockLevel = false;\n if (tbodyMatch && !theadIsDynamic) {\n const tbodyInner = tbodyMatch[1];\n // Within <tbody> the rows may mix static label rows (\"Part |\n // Quantity\") with data rows that reference loop aliases. Only\n // the dynamic rows should repeat — leave static rows outside\n // the {% for %} so they render once. We split on </tr> and\n // group consecutive dynamic rows together, then wrap each\n // dynamic group with the loop while leaving static rows as-is.\n const trMatches = tbodyInner.match(/<tr[\\s\\S]*?<\\/tr>/gi) || [];\n if (trMatches.length > 1 && aliasList.length) {\n const aliasRe = new RegExp(`\\\\b(?:${aliasList.map((a) => a.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|')})\\\\b|loop\\\\.`);\n let assembled = '';\n let buffer = '';\n const flushBuffer = () => {\n if (!buffer) return;\n assembled += wrapInLoops(buffer);\n buffer = '';\n };\n for (const tr of trMatches) {\n if (aliasRe.test(tr)) {\n buffer += tr;\n } else {\n flushBuffer();\n assembled += tr;\n }\n }\n flushBuffer();\n twig = rawHTML.replace(tbodyMatch[0], `<tbody>${assembled}</tbody>`);\n } else {\n const wrappedTbody = wrapInLoops(tbodyInner);\n twig = rawHTML.replace(tbodyMatch[0], `<tbody>${wrappedTbody}</tbody>`);\n }\n wrappedAtBlockLevel = true;\n }\n\n if (ifExpr && wrappedAtBlockLevel) {\n twig = `{% if ${ifExpr} %}\\n${twig}\\n{% endif %}`;\n }\n\n blockTwigMap.set(block.dataset.twigId, twig);\n // Carry the unwrapped loop info forward to the finalisation pass\n // so it can decide whether to wrap at the block, col, or row level.\n if (!wrappedAtBlockLevel && (hasLoop || ifExpr)) {\n blockTwigMap.set(block.dataset.twigId + '__wrap', {\n chain: Array.isArray(chain) && chain.length ? chain : null,\n repeatPath: !chain ? repeatPath : '',\n repeatAlias: !chain ? repeatAlias : '',\n ifExpr,\n wrapInLoops,\n });\n }\n }\n\n const canvasClone = stripChrome(canvas);\n const canvasSubBlocks = canvasClone.querySelectorAll(BLOCK_SELECTOR);\n\n // For each block that DEFERRED its {% for %} wrap, decide where the\n // wrap should land: ideally on the outermost ancestor that contains\n // ONLY this block (typically the row-item when the block is alone in\n // a single-col row). That way, each loop iteration produces a fresh\n // row/col stack instead of stuffing multiple blocks under the same\n // <col-item> (which leaves duplicate IDs and broken flex layout).\n //\n // We mark the hoist target with BEGIN/END comment sentinels — these\n // survive .innerHTML serialization, and we substitute them with the\n // actual {% for %} / {% endif %} text in the final string pass.\n // Top-level blocks of `el` = blocks inside el whose CLOSEST ancestor\n // block (excluding themselves) is NOT also inside el. Without this\n // filter, a section block's own nested children blocks would inflate\n // the count and prevent legitimate row-hoisting.\n const topLevelBlocksUnder = (el) => {\n const all = Array.from(el.querySelectorAll(BLOCK_SELECTOR));\n return all.filter((b) => {\n const outer = b.parentElement?.closest(BLOCK_SELECTOR);\n return !outer || !el.contains(outer);\n });\n };\n\n const hoistMap = new Map();\n canvasSubBlocks.forEach((sb) => {\n const tid = sb.dataset.twigId;\n if (!tid || !blockTwigMap.has(tid + '__wrap')) return;\n // Walk up while the ancestor's ONLY top-level descendant block is\n // this one. Stop at the cs_margin / section-container-content\n // boundary, and only hoist through structural row/col wrappers.\n let hoist = sb;\n let cursor = sb.parentElement;\n while (cursor) {\n if (cursor.matches?.('.cs_margin, .section-container-content, .cs-flow-canvas')) break;\n if (!cursor.matches?.('.row-item, .col-item')) break;\n const topBlocks = topLevelBlocksUnder(cursor);\n if (topBlocks.length !== 1 || topBlocks[0] !== sb) break;\n hoist = cursor;\n cursor = cursor.parentElement;\n }\n hoistMap.set(tid, hoist);\n });\n\n canvasSubBlocks.forEach((sb) => {\n const tid = sb.dataset.twigId;\n if (tid && blockTwigMap.has(tid)) {\n const marker = document.createComment(`__TWIG_ID_${tid}__`);\n sb.replaceWith(marker);\n }\n });\n\n // Insert hoist BEGIN/END markers around the chosen ancestor for each\n // deferred wrap. Done AFTER block replacement so the markers don't\n // accidentally get nuked by the replaceWith.\n hoistMap.forEach((hoistEl, tid) => {\n if (!hoistEl || !hoistEl.parentElement) return;\n const begin = document.createComment(`__TWIG_WRAP_BEGIN_${tid}__`);\n const end = document.createComment(`__TWIG_WRAP_END_${tid}__`);\n hoistEl.parentElement.insertBefore(begin, hoistEl);\n hoistEl.parentElement.insertBefore(end, hoistEl.nextSibling);\n });\n\n let finalHTML = canvasClone.outerHTML;\n finalHTML = finalHTML.replace(/<!--__TWIG_ID_([^>]+)__-->/g, (match, tid) => {\n return blockTwigMap.get(tid) || '';\n });\n // Substitute hoisted wraps. Each pair becomes {% for ... %} ... {% endfor %}.\n finalHTML = finalHTML.replace(\n /<!--__TWIG_WRAP_BEGIN_([^>]+)__-->([\\s\\S]*?)<!--__TWIG_WRAP_END_\\1__-->/g,\n (match, tid, body) => {\n const info = blockTwigMap.get(tid + '__wrap');\n if (!info) return body;\n let wrapped = info.wrapInLoops(body);\n if (info.ifExpr) wrapped = `{% if ${info.ifExpr} %}\\n${wrapped}\\n{% endif %}`;\n return wrapped;\n }\n );\n\n // Clean any remaining root-level custom attributes that leaked through\n finalHTML = finalHTML.replace(/\\s+data-twig-if=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-path=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-alias=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-chain=\"[^\"]*\"/g, '')\n .replace(/\\s+data-repeat-label=\"[^\"]*\"/g, '')\n .replace(/\\s+data-twig-id=\"[^\"]*\"/g, '');\n\n // Cleanup live DOM dataset twigIds\n allBlocks.forEach(b => delete b.dataset.twigId);\n\n return finalHTML;\n };\n\n // Serialise EVERY page canvas (.custom-form-design) on the board and\n // concatenate them, each on its own line. Each canvas is one A4 .cs_margin\n // page; emitting all of them (instead of only the first via getCanvas())\n // is what lets a multi-page design render past page 1 in the PDF.\n const generate = () => {\n const canvases = Array.from(document.querySelectorAll(CANVAS_SELECTOR));\n if (!canvases.length) return '';\n const html = canvases.map((c) => generateForCanvas(c)).join('\\n');\n state.twig = html;\n notify();\n return html;\n };\n\n const startObserver = () => {\n const canvas = getCanvas();\n if (!canvas) return;\n // Observe the whole multi-page board (.cs_paper) when present so edits\n // on ANY page — and newly added pages — regenerate the twig, not just\n // changes to page 1.\n const target = canvas.closest('.cs_paper') || canvas.parentElement || canvas;\n\n let scheduled = false;\n const scheduleRegen = () => {\n if (scheduled) return;\n scheduled = true;\n requestAnimationFrame(() => {\n scheduled = false;\n generate();\n });\n };\n\n const obs = new MutationObserver(scheduleRegen);\n obs.observe(target, {\n childList: true,\n subtree: true,\n characterData: true,\n attributes: true,\n attributeFilter: ['style', 'data-twig-if', 'data-repeat-path', 'data-repeat-alias', 'data-repeat-chain', 'class']\n });\n };\n\n // Convert RGB to Hex for consistent color display\n const rgbToHex = (rgb) => {\n if (!rgb) return '';\n if (rgb.startsWith('#')) return rgb;\n\n const match = rgb.match(/^rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)$/);\n if (!match) return rgb;\n\n const r = parseInt(match[1]);\n const g = parseInt(match[2]);\n const b = parseInt(match[3]);\n\n return \"#\" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);\n };\n\n const readBlockStyles = (block) => {\n // Use StyleManager if available\n if (typeof window.StyleManager !== 'undefined' && typeof window.StyleManager.readBlockStyles === 'function') {\n return window.StyleManager.readBlockStyles(block);\n }\n\n // Fallback implementation with RGB to Hex conversion\n const s = block.style;\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n const iS = inner ? inner.style : {};\n\n return {\n backgroundColor: rgbToHex(s.backgroundColor) || '',\n textColor: rgbToHex(iS.color || s.color) || '',\n fontSize: iS.fontSize || '',\n fontWeight: iS.fontWeight || '',\n borderStyle: s.borderStyle || '',\n borderColor: rgbToHex(s.borderColor) || '',\n borderWidth: s.borderWidth || '',\n borderRadius: s.borderRadius || '',\n paddingTop: s.paddingTop || '',\n paddingRight: s.paddingRight || '',\n paddingBottom: s.paddingBottom || '',\n paddingLeft: s.paddingLeft || '',\n marginTop: s.marginTop || '',\n marginRight: s.marginRight || '',\n marginBottom: s.marginBottom || '',\n marginLeft: s.marginLeft || '',\n opacity: s.opacity || '',\n boxShadow: s.boxShadow || '',\n width: s.width || '',\n height: s.height || '',\n };\n };\n\n // Walk UP from a selected block collecting every ancestor that is itself a\n // content block (.cs_block_s) — e.g. the Flexible / Section that wraps it.\n // Returns innermost-first ({id, name}) so the panel can offer a \"Choose\n // parent <name>\" button for each level. Ids are minted lazily so the panel\n // can target them with `block:select`.\n const getBlockParents = (block) => {\n const out = [];\n let cur = block && block.parentElement;\n while (cur && cur !== document.body) {\n if (cur.matches && cur.matches('.cs_margin, .cs-flow-canvas, .cs_paper, .cs_page')) break;\n if (cur.classList && cur.classList.contains('cs_block_s')) {\n if (!cur.id) cur.id = 'block_' + Math.random().toString(36).substr(2, 9);\n out.push({\n id: cur.id,\n name: cur.getAttribute('custom-name') || cur.dataset.blockType || cur.getAttribute('data') || 'Block'\n });\n }\n cur = cur.parentElement;\n }\n return out;\n };\n\n // The set of mutually-exclusive frame-shape classes an image container can\n // carry. Shared by the read (getImageFrame) and write (set-image-frame) paths\n // so they never drift. Mirrors the .image-container.<shape> rules in editor.css.\n const IMAGE_FRAME_SHAPES = [\n 'square-image',\n 'rounded-square-image',\n 'circle-image',\n 'diagonal-corners-image',\n 'polygon',\n 'star',\n 'rectangle-image',\n ];\n\n const getImageFrame = (container) => {\n for (const shape of IMAGE_FRAME_SHAPES) {\n if (container.classList.contains(shape)) return shape;\n }\n return 'square-image'; // default framing when no shape class is present\n };\n\n // Geometric frames need a 1:1 box — otherwise a percentage clip-path (star /\n // polygon / hexagon) stretches across the image's wide-and-short frame and\n // stops looking like the shape. They also need the image to COVER that box;\n // the global `.cs_block_s img { object-fit: contain }` rule otherwise\n // letterboxes the picture inside the shape. Rectangular frames keep the\n // default contain / full-width framing.\n //\n // These have to be driven via inline `!important` from JS: the container's\n // creation-time inline `aspect-ratio` carries `!important`, which no\n // stylesheet rule (even `!important`) can override — only another inline\n // declaration can.\n const SHAPED_FRAMES = ['circle-image', 'diagonal-corners-image', 'polygon', 'star'];\n\n const setImageFrame = (container, shape) => {\n if (!IMAGE_FRAME_SHAPES.includes(shape)) return;\n IMAGE_FRAME_SHAPES.forEach((s) => container.classList.remove(s));\n container.classList.add(shape);\n\n const img = container.querySelector('img');\n if (SHAPED_FRAMES.includes(shape)) {\n // Square, centred box (margin:auto on the container already centres it)\n // so the shape renders true-to-form, with the image filling it.\n container.style.setProperty('aspect-ratio', '1', 'important');\n container.style.setProperty('width', 'auto', 'important');\n img?.style.setProperty('object-fit', 'cover', 'important');\n } else {\n // Restore the default wide framing for square / rounded / rectangle.\n container.style.setProperty('aspect-ratio', 'auto', 'important');\n container.style.setProperty('width', '100%', 'important');\n img?.style.removeProperty('object-fit');\n }\n\n // The box size/aspect just changed, so re-clamp any active zoom/pan.\n window.FlowCanvas?.refreshImageZoom?.(container);\n };\n\n const broadcastSelection = () => {\n // Find the currently selected block, whether it is selected via inline-editor class or custom-form class\n let block = document.querySelector('.cs_block_s.cs-selected, .cs_block_s.cs-editing') ||\n document.querySelector('.canvas-block--selected');\n\n if (!block) {\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'selection:cleared'\n }, '*');\n return;\n }\n\n // Ensure it has an ID so we can apply properties back to it\n if (!block.id) {\n block.id = 'block_' + Math.random().toString(36).substr(2, 9);\n }\n\n // Image-block frame: surface whether this is an image and which frame\n // shape it currently uses, so the parent panel can show the shape picker\n // only for images and highlight the active shape.\n const imageContainer = block.querySelector('.image-container');\n\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'selection:changed',\n data: {\n blockId: block.id,\n blockType: block.dataset.blockType || block.getAttribute('data') || null,\n label: block.getAttribute('custom-name') || block.dataset.blockType || 'Block',\n twigIf: block.dataset.twigIf || '',\n tableBorderWidth: block.querySelector('table') ? (block.querySelector('table').dataset.borderWidth || '0') : '0',\n tableBorderColor: block.querySelector('table') ? (block.querySelector('table').dataset.borderColor || '#000000') : '#000000',\n isImage: !!imageContainer,\n imageFrame: imageContainer ? getImageFrame(imageContainer) : '',\n styles: readBlockStyles(block),\n parents: getBlockParents(block)\n }\n }, '*');\n };\n\n // When the user clicks inside a Table block we surface the clicked cell and\n // its row to the panel so each can carry its own show-condition. Cell/row\n // ids are minted lazily so the panel can target them with set-condition.\n const broadcastTableTarget = (target) => {\n const cell = target && target.closest ? target.closest('td, th') : null;\n const blockEl = target && target.closest ? target.closest('.cs_block_s, .canvas-block') : null;\n const isTable = !!(cell && blockEl && blockEl.querySelector('table'));\n if (!isTable) {\n window.parent?.postMessage({ source: 'custom-form-twig', type: 'table-target:cleared' }, '*');\n return;\n }\n const row = cell.closest('tr');\n if (!cell.id) cell.id = 'cell_' + Math.random().toString(36).substr(2, 9);\n if (row && !row.id) row.id = 'row_' + Math.random().toString(36).substr(2, 9);\n window.parent?.postMessage({\n source: 'custom-form-twig',\n type: 'table-target:changed',\n data: {\n cellId: cell.id,\n cellTag: cell.tagName.toLowerCase(),\n cellCondition: cell.dataset.twigIf || '',\n rowId: row ? row.id : '',\n rowCondition: row ? (row.dataset.twigIf || '') : ''\n }\n }, '*');\n };\n\n // Remove a block with a small shrink/fade animation, then prune empty\n // columns/rows and regenerate. Shared by the Delete key and the block badge\n // \"delete\" action.\n const deleteBlockWithAnimation = (block) => {\n if (!block) return;\n block.style.transition = 'transform 0.2s cubic-bezier(0.6, -0.28, 0.735, 0.045), opacity 0.2s ease-in';\n block.style.transform = 'scale(0.85)';\n block.style.opacity = '0';\n setTimeout(() => {\n block.remove();\n broadcastSelection();\n if (typeof window.FlowCanvas !== 'undefined' && typeof window.FlowCanvas.cleanupEmpty === 'function') {\n const c = getCanvas();\n if (c) window.FlowCanvas.cleanupEmpty(c);\n }\n if (typeof window.generate === 'function') window.generate();\n }, 200);\n };\n window.FlowCanvas = window.FlowCanvas || {};\n window.FlowCanvas.deleteBlock = deleteBlockWithAnimation;\n\n const startSelectionObserver = () => {\n document.addEventListener('click', (e) => {\n setTimeout(broadcastSelection, 50);\n broadcastTableTarget(e.target);\n });\n document.addEventListener('drop', () => {\n setTimeout(broadcastSelection, 50);\n setTimeout(generate, 100);\n });\n // The main observer watches for 'class' changes which covers selections too\n const canvas = getCanvas();\n if (canvas) {\n const classObs = new MutationObserver(() => {\n broadcastSelection();\n });\n classObs.observe(canvas, { subtree: true, attributes: true, attributeFilter: ['class'] });\n }\n\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Delete' || e.key === 'Backspace') {\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.isContentEditable || activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {\n return; // Let user natively edit text within block\n }\n\n const activeBlock = document.querySelector('.cs_block_s.cs-selected, .cs_block_s.cs-editing') || document.querySelector('.canvas-block--selected');\n if (activeBlock) {\n // Route through FlowCanvas.deleteBlock so wrappers (e.g. the List's\n // group-delete) run; it falls back to deleteBlockWithAnimation.\n (window.FlowCanvas?.deleteBlock || deleteBlockWithAnimation)(activeBlock);\n }\n }\n });\n };\n\n window.addEventListener('message', (event) => {\n const msg = event.data;\n if (!msg || msg.target !== 'custom-form-twig') return;\n\n if (msg.type === 'delete-block') {\n const block = document.getElementById(msg.blockId);\n if (block) {\n block.remove();\n // Manually trigger cleanup to remove empty cols/rows in sections\n if (window.FlowCanvas?.cleanupEmpty) {\n const doc = document.querySelector('.custom-form-design');\n if (doc) window.FlowCanvas.cleanupEmpty(doc);\n }\n broadcastSelection();\n generate();\n }\n }\n\n if (msg.type === 'set-condition') {\n const block = document.getElementById(msg.blockId);\n if (block) {\n if (msg.expr && msg.expr.trim()) {\n block.dataset.twigIf = msg.expr.trim();\n } else {\n delete block.dataset.twigIf;\n }\n generate();\n }\n }\n\n if (msg.type === 'set-table-border-params') {\n const block = document.getElementById(msg.blockId);\n if (block) {\n const table = block.querySelector('table');\n const cells = block.querySelectorAll('th, td');\n\n let bw = parseInt(msg.borderWidth) || 0;\n let color = msg.borderColor || '#000000';\n let borderStr = bw > 0 ? `${bw}px solid ${color}` : 'none';\n\n if (table) {\n table.dataset.borderWidth = bw.toString();\n table.dataset.borderColor = color;\n table.style.border = borderStr;\n table.style.borderCollapse = 'collapse';\n }\n cells.forEach(c => c.style.border = borderStr);\n generate();\n }\n }\n\n // Result from the parent-side binding modal: apply repeat-path/alias to\n // the block, or do nothing on skip.\n if (msg.type === 'binding-modal:apply') {\n const block = document.getElementById(msg.blockId);\n if (block && msg.path) {\n block.dataset.repeatPath = msg.path;\n block.dataset.repeatAlias = msg.alias || 'item';\n block.dataset.repeatLabel = msg.path;\n\n // Multi-level binding (deeply nested array picked in the modal):\n // persist the full chain so the twig generator can emit nested\n // {% for %} loops. Single-level bindings clear the chain so the\n // simpler code path is used.\n if (Array.isArray(msg.chain) && msg.chain.length > 1) {\n block.dataset.repeatChain = JSON.stringify(msg.chain);\n } else {\n delete block.dataset.repeatChain;\n }\n\n // Visual hint (matches old in-iframe modal behaviour)\n let info = block.querySelector('.section-binding-info');\n if (!info) {\n info = document.createElement('div');\n info.className = 'section-binding-info';\n block.appendChild(info);\n }\n const chainLen = Array.isArray(msg.chain) ? msg.chain.length : 1;\n info.textContent = chainLen > 1\n ? `Repeats ${msg.path} (${chainLen} nested loops)`\n : `Repeats ${msg.path}`;\n generate();\n }\n }\n\n // Handle style updates from the parent\n if (msg.type === 'set-block-style') {\n const block = document.getElementById(msg.blockId);\n if (block && msg.prop && msg.value !== undefined) {\n const { prop, value } = msg;\n\n // Handle different style properties\n if (prop === 'textColor') {\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n if (inner) {\n inner.style.color = value || '';\n }\n block.style.color = value || '';\n } else if (prop === 'fontSize') {\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n if (inner) {\n inner.style.fontSize = value || '';\n }\n } else if (prop === 'fontWeight') {\n const inner = block.querySelector('.edit_me, .section-container-content, .cs-flexible-content, .image-container');\n if (inner) {\n inner.style.fontWeight = value || '';\n }\n } else {\n // Apply style directly to the block\n if (value === '' || value === null) {\n const cssProp = prop === 'backgroundColor' ? 'background-color' : camelCaseToCssProp(prop);\n block.style.removeProperty(cssProp);\n } else {\n const cssProp = prop === 'backgroundColor' ? 'background-color' : camelCaseToCssProp(prop);\n block.style.setProperty(cssProp, value, 'important');\n }\n }\n\n // After applying styles, broadcast the selection to update the panel\n setTimeout(() => broadcastSelection(), 50);\n generate();\n }\n }\n\n // Change an image block's frame shape (square / rounded / circle / polygon\n // / star …). Only the .image-container's shape class is swapped — the <img>,\n // its src and any zoom/pan transform are untouched, so all other image\n // functionality keeps working; only the visible frame changes.\n if (msg.type === 'set-image-frame') {\n const block = document.getElementById(msg.blockId);\n const container = block?.querySelector('.image-container');\n if (container && msg.shape) {\n setImageFrame(container, msg.shape);\n broadcastSelection();\n generate();\n }\n }\n });\n\n // Helper function for style handler\n const camelCaseToCssProp = (camelCase) => {\n return camelCase.replace(/([A-Z])/g, (g) => `-${g.toLowerCase()}`);\n };\n\n document.addEventListener('DOMContentLoaded', () => {\n setTimeout(() => {\n startObserver();\n startSelectionObserver();\n generate();\n }, 100);\n });\n})();\n\n<\/script>\n</body>\n\n</html>";
183
183
  </script>
184
184
  <script data-src="main-W7C6A433.js" type="module">
185
185
  var vA=Object.defineProperty,bA=Object.defineProperties;var _A=Object.getOwnPropertyDescriptors;var uc=Object.getOwnPropertySymbols;var c_=Object.prototype.hasOwnProperty,u_=Object.prototype.propertyIsEnumerable;var l_=(n,e,t)=>e in n?vA(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t,I=(n,e)=>{for(var t in e||={})c_.call(e,t)&&l_(n,t,e[t]);if(uc)for(var t of uc(e))u_.call(e,t)&&l_(n,t,e[t]);return n},Z=(n,e)=>bA(n,_A(e));var d_=(n,e)=>{var t={};for(var i in n)c_.call(n,i)&&e.indexOf(i)<0&&(t[i]=n[i]);if(n!=null&&uc)for(var i of uc(n))e.indexOf(i)<0&&u_.call(n,i)&&(t[i]=n[i]);return t};var ut=null,dc=!1,Sh=1,wA=null,dt=Symbol("SIGNAL");function G(n){let e=ut;return ut=n,e}function gc(){return ut}var So={version:0,lastCleanEpoch:0,dirty:!1,producers:void 0,producersTail:void 0,consumers:void 0,consumersTail:void 0,recomputing:!1,consumerAllowSignalWrites:!1,consumerIsAlwaysLive:!1,kind:"unknown",producerMustRecompute:()=>!1,producerRecomputeValue:()=>{},consumerMarkedDirty:()=>{},consumerOnSignalRead:()=>{}};function Mo(n){if(dc)throw new Error("");if(ut===null)return;ut.consumerOnSignalRead(n);let e=ut.producersTail;if(e!==void 0&&e.producer===n)return;let t,i=ut.recomputing;if(i&&(t=e!==void 0?e.nextProducer:ut.producers,t!==void 0&&t.producer===n)){ut.producersTail=t,t.lastReadVersion=n.version;return}let r=n.consumersTail;if(r!==void 0&&r.consumer===ut&&(!i||xA(r,ut)))return;let o=Io(ut),s={producer:n,consumer:ut,nextProducer:t,prevConsumer:r,lastReadVersion:n.version,nextConsumer:void 0};ut.producersTail=s,e!==void 0?e.nextProducer=s:ut.producers=s,o&&g_(n,s)}function f_(){Sh++}function mc(n){if(!(Io(n)&&!n.dirty)&&!(!n.dirty&&n.lastCleanEpoch===Sh)){if(!n.producerMustRecompute(n)&&!yc(n)){pc(n);return}n.producerRecomputeValue(n),pc(n)}}function Mh(n){if(n.consumers===void 0)return;let e=dc;dc=!0;try{for(let t=n.consumers;t!==void 0;t=t.nextConsumer){let i=t.consumer;i.dirty||CA(i)}}finally{dc=e}}function Th(){return ut?.consumerAllowSignalWrites!==!1}function CA(n){n.dirty=!0,Mh(n),n.consumerMarkedDirty?.(n)}function pc(n){n.dirty=!1,n.lastCleanEpoch=Sh}function To(n){return n&&h_(n),G(n)}function h_(n){n.producersTail=void 0,n.recomputing=!0}function Zs(n,e){G(e),n&&p_(n)}function p_(n){n.recomputing=!1;let e=n.producersTail,t=e!==void 0?e.nextProducer:n.producers;if(t!==void 0){if(Io(n))do t=Ih(t);while(t!==void 0);e!==void 0?e.nextProducer=void 0:n.producers=void 0}}function yc(n){for(let e=n.producers;e!==void 0;e=e.nextProducer){let t=e.producer,i=e.lastReadVersion;if(i!==t.version||(mc(t),i!==t.version))return!0}return!1}function Js(n){if(Io(n)){let e=n.producers;for(;e!==void 0;)e=Ih(e)}n.producers=void 0,n.producersTail=void 0,n.consumers=void 0,n.consumersTail=void 0}function g_(n,e){let t=n.consumersTail,i=Io(n);if(t!==void 0?(e.nextConsumer=t.nextConsumer,t.nextConsumer=e):(e.nextConsumer=void 0,n.consumers=e),e.prevConsumer=t,n.consumersTail=e,!i)for(let r=n.producers;r!==void 0;r=r.nextProducer)g_(r.producer,r)}function Ih(n){let e=n.producer,t=n.nextProducer,i=n.nextConsumer,r=n.prevConsumer;if(n.nextConsumer=void 0,n.prevConsumer=void 0,i!==void 0?i.prevConsumer=r:e.consumersTail=r,r!==void 0)r.nextConsumer=i;else if(e.consumers=i,!Io(e)){let o=e.producers;for(;o!==void 0;)o=Ih(o)}return t}function Io(n){return n.consumerIsAlwaysLive||n.consumers!==void 0}function vc(n){wA?.(n)}function xA(n,e){let t=e.producersTail;if(t!==void 0){let i=e.producers;do{if(i===n)return!0;if(i===t)break;i=i.nextProducer}while(i!==void 0)}return!1}function bc(n,e){return Object.is(n,e)}function _c(n,e){let t=Object.create(DA);t.computation=n,e!==void 0&&(t.equal=e);let i=()=>{if(mc(t),Mo(t),t.value===Ys)throw t.error;return t.value};return i[dt]=t,vc(t),i}var fc=Symbol("UNSET"),hc=Symbol("COMPUTING"),Ys=Symbol("ERRORED"),DA=Z(I({},So),{value:fc,dirty:!0,error:null,equal:bc,kind:"computed",producerMustRecompute(n){return n.value===fc||n.value===hc},producerRecomputeValue(n){if(n.value===hc)throw new Error("");let e=n.value;n.value=hc;let t=To(n),i,r=!1;try{i=n.computation(),G(null),r=e!==fc&&e!==Ys&&i!==Ys&&n.equal(e,i)}catch(o){i=Ys,n.error=o}finally{Zs(n,t)}if(r){n.value=e;return}n.value=i,n.version++}});function EA(){throw new Error}var m_=EA;function y_(n){m_(n)}function Ah(n){m_=n}var SA=null;function kh(n,e){let t=Object.create(wc);t.value=n,e!==void 0&&(t.equal=e);let i=()=>v_(t);return i[dt]=t,vc(t),[i,s=>Ao(t,s),s=>Oh(t,s)]}function v_(n){return Mo(n),n.value}function Ao(n,e){Th()||y_(n),n.equal(n.value,e)||(n.value=e,MA(n))}function Oh(n,e){Th()||y_(n),Ao(n,e(n.value))}var wc=Z(I({},So),{equal:bc,value:void 0,kind:"signal"});function MA(n){n.version++,f_(),Mh(n),SA?.(n)}function J(n){return typeof n=="function"}function ko(n){let t=n(i=>{Error.call(i),i.stack=new Error().stack});return t.prototype=Object.create(Error.prototype),t.prototype.constructor=t,t}var Cc=ko(n=>function(t){n(this),this.message=t?`${t.length} errors occurred during unsubscription: