From 396cf911f171a319cb3dee1115609ebfdb9244f0 Mon Sep 17 00:00:00 2001 From: stravo1 Date: Wed, 29 Jul 2026 15:17:35 +0530 Subject: [PATCH 01/12] feat: prototype shadow DOM canvas isolation as an alternative to iframes Renders each breakpoint inside a shadow root instead of an iframe, for much less code than full DOM isolation via iframes: CSS and DOM queries stay encapsulated, coordinates and events need no cross-frame translation, and fonts need no extra handling. Includes emulation of page client scripts (CSS applied per breakpoint, JavaScript run against a document/window facade routed at the shadow root, since script isolation is not something shadow DOM provides). Behind a localStorage flag (builderShadowCanvas) until the trade-offs against the iframe approach are settled. --- frontend/components.d.ts | 1 + frontend/src/components/BlockEditor.vue | 5 +- frontend/src/components/BuilderCanvas.vue | 90 +++++++++-- .../components/BuilderCanvasShadowRoot.vue | 59 +++++++ frontend/src/components/TextBlock.vue | 3 +- frontend/src/stores/canvasStore.ts | 8 +- frontend/src/stores/pageStore.ts | 8 +- frontend/src/utils/canvasScriptFacade.ts | 149 ++++++++++++++++++ frontend/src/utils/canvasShadowDom.ts | 145 +++++++++++++++++ frontend/src/utils/canvasShadowStyles.ts | 50 ++++++ frontend/src/utils/guidesTracker.ts | 3 +- frontend/src/utils/helpers.ts | 18 +-- frontend/src/utils/pageScriptEmulation.ts | 103 ++++++++++++ frontend/src/utils/scriptSandbox.ts | 2 +- frontend/src/utils/trackTarget.ts | 53 ++++--- frontend/src/utils/useBlockReorder.ts | 17 +- frontend/src/utils/useBuilderEvents.ts | 6 +- frontend/src/utils/useCanvasDropZone.ts | 7 +- frontend/src/utils/useCanvasEvents.ts | 10 +- .../src/utils/useCanvasMarqueeSelection.ts | 10 +- 20 files changed, 673 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/BuilderCanvasShadowRoot.vue create mode 100644 frontend/src/utils/canvasScriptFacade.ts create mode 100644 frontend/src/utils/canvasShadowDom.ts create mode 100644 frontend/src/utils/canvasShadowStyles.ts create mode 100644 frontend/src/utils/pageScriptEmulation.ts diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 363ab1fea..1a44dc7d8 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -42,6 +42,7 @@ declare module 'vue' { BuilderBlock: typeof import('./src/components/BuilderBlock.vue')['default'] BuilderBlockTemplates: typeof import('./src/components/BuilderBlockTemplates.vue')['default'] BuilderCanvas: typeof import('./src/components/BuilderCanvas.vue')['default'] + BuilderCanvasShadowRoot: typeof import('./src/components/BuilderCanvasShadowRoot.vue')['default'] BuilderCommandPalette: typeof import('./src/components/BuilderCommandPalette.vue')['default'] BuilderLeftPanel: typeof import('./src/components/BuilderLeftPanel.vue')['default'] BuilderRightPanel: typeof import('./src/components/BuilderRightPanel.vue')['default'] diff --git a/frontend/src/components/BlockEditor.vue b/frontend/src/components/BlockEditor.vue index b6f2d64e3..23155956c 100644 --- a/frontend/src/components/BlockEditor.vue +++ b/frontend/src/components/BlockEditor.vue @@ -46,6 +46,7 @@ import type Block from "@/block"; import useBuilderStore from "@/stores/builderStore"; import useCanvasStore from "@/stores/canvasStore"; import blockController from "@/utils/blockController"; +import { elementFromPoint } from "@/utils/canvasShadowDom"; import { addPxToNumber } from "@/utils/helpers"; import { isReorderable, startBlockReorder } from "@/utils/useBlockReorder"; import { Ref, computed, inject, nextTick, onMounted, ref, watch, watchEffect } from "vue"; @@ -240,11 +241,11 @@ const handleClick = (ev: MouseEvent) => { const editorWrapper = editor.value; editorWrapper.classList.add("pointer-events-none"); - let element = document.elementFromPoint(ev.x, ev.y) as HTMLElement; + let element = elementFromPoint(ev.x, ev.y) as HTMLElement; if (element.classList.contains("editor")) { element.classList.remove("pointer-events-auto"); element.classList.add("pointer-events-none"); - element = document.elementFromPoint(ev.x, ev.y) as HTMLElement; + element = elementFromPoint(ev.x, ev.y) as HTMLElement; } if (element.classList.contains("__builder_component__")) { element.dispatchEvent(new MouseEvent("click", ev)); diff --git a/frontend/src/components/BuilderCanvas.vue b/frontend/src/components/BuilderCanvas.vue index bef3b334e..4cc63f43b 100644 --- a/frontend/src/components/BuilderCanvas.vue +++ b/frontend/src/components/BuilderCanvas.vue @@ -4,7 +4,10 @@ :data-builder-canvas="canvasId" @click="handleClick" @mousedown="handleMarqueeStart"> - +
{{ breakpoint.displayName }}
- + + + +
; const hoveredBreakpoint = ref("desktop") as Ref; const hoveredBlock = ref(null) as Ref; const setCanvasZoom = ref<(scale: number, pinchPoint: { x: number; y: number } | "center") => void>(); +let cleanupShadowCanvasEvents = () => {}; const { clearSelection, @@ -282,7 +298,7 @@ onMounted(() => { (readOnly) => (readOnly ? history.value?.disable() : history.value?.enable()), { immediate: true }, ); - useCanvasEvents( + cleanupShadowCanvasEvents = useCanvasEvents( canvasContainer as unknown as Ref, canvasProps, history as CanvasHistory, @@ -297,6 +313,9 @@ onMounted(() => { onUnmounted(() => { cleanupMarqueeListeners(); + cleanupShadowCanvasEvents(); + pageScriptRuntimes.forEach((runtime) => runtime.destroy()); + pageScriptRuntimes.clear(); }); const handleClick = (ev: MouseEvent) => { @@ -305,7 +324,7 @@ const handleClick = (ev: MouseEvent) => { return; } - const target = document.elementFromPoint(ev.clientX, ev.clientY); + const target = elementFromPoint(ev.clientX, ev.clientY); // hack to ensure if click is on canvas-container // TODO: Still clears selection if space handlers are dragged over canvas-container if (target?.classList.contains("canvas-container")) { @@ -452,11 +471,17 @@ function escapeAttributeValue(value: string) { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } -function emulateBlockClientScript(script: BlockClientScriptRuntime) { - const registrationKey = `${script.key}:${script.breakpoint}`; - const selector = `[data-builder-canvas="${canvasId}"] [data-block-uid="${escapeAttributeValue( +function blockStyleSelector(script: BlockClientScriptRuntime) { + const blockSelector = `[data-block-uid="${escapeAttributeValue( script.key, )}"][data-breakpoint="${escapeAttributeValue(script.breakpoint)}"]`; + // a shadow root already scopes its styles to one canvas + return shadowCanvasEnabled ? blockSelector : `[data-builder-canvas="${canvasId}"] ${blockSelector}`; +} + +function emulateBlockClientScript(script: BlockClientScriptRuntime) { + const registrationKey = `${script.key}:${script.breakpoint}`; + const selector = blockStyleSelector(script); blockStyles.set(registrationKey, script.css ? `${selector} { ${script.css} }` : ""); const mode = builderSettings.doc?.execute_block_scripts_in_editor ?? "Restricted"; @@ -466,10 +491,12 @@ function emulateBlockClientScript(script: BlockClientScriptRuntime) { componentData: script.componentData, props: script.props, }; + // the block's own shadow root is a tighter sandbox than the whole canvas + const sandboxRoot = getShadowRootOf(script.element) ?? canvasContainer.value; cleanup = mode === "Unrestricted" ? executeClientScriptUnrestricted(script.element, script.javascript, context) - : executeClientScriptRestricted(script.element, canvasContainer.value, script.javascript, context); + : executeClientScriptRestricted(script.element, sandboxRoot, script.javascript, context); } return () => { @@ -482,6 +509,37 @@ function emulateBlockClientScript(script: BlockClientScriptRuntime) { } const renderedBreakpoints = computed(() => canvasProps.breakpoints.filter((bp) => bp.renderedOnce)); + +// Page client scripts run once per breakpoint, against that breakpoint's shadow root. +const pageScriptRuntimes = new Map(); + +function registerCanvasRoot(breakpoint: BreakpointConfig, root: ShadowRoot) { + pageScriptRuntimes.set(breakpoint.device, new PageScriptRuntime(root, breakpoint.width)); + applyPageClientScripts(); +} + +function releaseCanvasRoot(breakpoint: BreakpointConfig) { + pageScriptRuntimes.get(breakpoint.device)?.destroy(); + pageScriptRuntimes.delete(breakpoint.device); +} + +function applyPageClientScripts() { + const mode = builderSettings.doc?.execute_block_scripts_in_editor ?? "Restricted"; + pageScriptRuntimes.forEach((runtime) => + runtime.apply(pageStore.activePageScripts, mode !== "Don't Execute", pageStore.pageData), + ); +} + +watch( + [ + () => pageStore.activePageScripts, + () => pageStore.pageData, + () => builderSettings.doc?.execute_block_scripts_in_editor, + () => pageStore.settingPage, + ], + applyPageClientScripts, + { deep: true }, +);