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/builder.d.ts b/frontend/src/builder.d.ts index a8023140e..b621e10d5 100644 --- a/frontend/src/builder.d.ts +++ b/frontend/src/builder.d.ts @@ -132,6 +132,7 @@ declare interface CanvasProps { settingCanvas: boolean; overlayElement: HTMLElement | null; breakpoints: Breakpoint[]; + scriptsRunning: boolean; } declare type EditingMode = "page" | "fragment"; 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/BuilderBlock.vue b/frontend/src/components/BuilderBlock.vue index aa9f0872a..8d3251fbe 100644 --- a/frontend/src/components/BuilderBlock.vue +++ b/frontend/src/components/BuilderBlock.vue @@ -50,6 +50,8 @@ import usePageStore from "@/stores/pageStore"; import { BlockValueResolver } from "@/utils/blockValueResolver"; import componentController from "@/utils/componentController.js"; import { setFont } from "@/utils/fontManager"; +import { blockSelector, toStateStyleRules } from "@/utils/blockStateStyles"; +import type { BlockStateStyleRegistrar } from "@/utils/blockStateStyles"; import { extractComponentId } from "@/utils/helpers"; import type { BlockClientScriptEmulator } from "@/utils/scriptSandbox"; import { useDraggableBlock } from "@/utils/useDraggableBlock"; @@ -245,6 +247,7 @@ const emulateBlockClientScript = inject( "emulateBlockClientScript", () => () => {}, ); +const registerBlockStateStyles = inject("registerBlockStateStyles", () => () => {}); const target = computed(() => { if (!component.value) return null; @@ -288,22 +291,28 @@ const styles = computed(() => { styleMap.fontFamily = (styleMap.fontFamily as string).replace(/ /g, "\\ "); } + // state styles are not valid inline properties; stateStyleRules emits them + // as real rules into the canvas stylesheet instead Object.keys(styleMap).forEach((key) => { - if (key.startsWith("hover:")) { - // state style preview on hover - // if (!isHovered.value) { - // delete styleMap[key]; - // } else { - // styleMap[key.replace("hover:", "")] = styleMap[key]; - // delete styleMap[key]; - // } - delete styleMap[key]; - } + if (key.includes(":")) delete styleMap[key]; }); return styleMap; }); +const stateStyleRules = computed(() => + toStateStyleRules(props.block.getStyles(props.breakpoint), blockSelector(uidToUse, props.breakpoint)), +); + +watch( + stateStyleRules, + (rules, _, onCleanup) => { + if (!rules) return; + onCleanup(registerBlockStateStyles(uidToUse, props.breakpoint, rules)); + }, + { immediate: true }, +); + const loadEditor = computed(() => { return ( target.value && @@ -415,9 +424,14 @@ watch( allResolvedProps, () => builderSettings.doc?.execute_block_scripts_in_editor, () => pageStore.settingPage, + () => canvasProps?.scriptsRunning, componentDataReady, ], - ([element, clientScript, componentData, resolvedProps, , settingPage, dataReady], _, onCleanup) => { + ( + [element, clientScript, componentData, resolvedProps, , settingPage, scriptsRunning, dataReady], + _, + onCleanup, + ) => { if (!element || !clientScript) return; const waitsForComponentData = Boolean(props.block.extendedFromComponent); const cleanup = emulateBlockClientScript({ @@ -426,7 +440,7 @@ watch( breakpoint: props.breakpoint, css: clientScript.css ?? "", javascript: - settingPage || (waitsForComponentData && !editingComponentId.value && !dataReady) + !scriptsRunning || settingPage || (waitsForComponentData && !editingComponentId.value && !dataReady) ? "" : clientScript.javascript ?? "", componentData: componentData ?? {}, diff --git a/frontend/src/components/BuilderCanvas.vue b/frontend/src/components/BuilderCanvas.vue index bef3b334e..8c0028479 100644 --- a/frontend/src/components/BuilderCanvas.vue +++ b/frontend/src/components/BuilderCanvas.vue @@ -4,7 +4,6 @@ :data-builder-canvas="canvasId" @click="handleClick" @mousedown="handleMarqueeStart"> -
{{ breakpoint.displayName }}
- + + + +
; const canvas = ref(null); const showBlocks = ref(false); const overlay = ref(null); -const blockStyles = reactive(new Map()); +type BreakpointStyles = { breakpoint: string; css: string }; +const blockStyles = reactive(new Map()); +const blockStateStyles = reactive(new Map()); const props = withDefaults( defineProps<{ @@ -181,12 +194,27 @@ const props = withDefaults( const block = ref(props.blockData) as Ref; const history = ref(null) as Ref | CanvasHistory; -const blockClientStyles = computed(() => Array.from(blockStyles.values()).join("\n")); +// State styles resolve only while previewing. Editing means hovering blocks to +// select them, and a block that restyles itself under the pointer is unusable. +const previewingStates = computed(() => canvasProps.scriptsRunning); + +// grouped by breakpoint so each shadow root only gets the CSS for blocks it actually contains +const blockStylesByBreakpoint = computed(() => { + const grouped = new Map(); + const append = ({ breakpoint, css }: BreakpointStyles) => { + if (!css) return; + grouped.set(breakpoint, grouped.has(breakpoint) ? `${grouped.get(breakpoint)}\n${css}` : css); + }; + blockStyles.forEach(append); + if (previewingStates.value) blockStateStyles.forEach(append); + return grouped; +}); const activeBreakpoint = ref("desktop") as Ref; 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, @@ -206,6 +234,12 @@ const canvasProps = reactive({ settingCanvas: true, scaling: false, panning: false, + // Scripts are opt-in per session. They share the editor's own realm, so an + // open editor should not run arbitrary page or block JavaScript unless asked. + scriptsRunning: false, + // bumped on Stop to force the block tree to remount, undoing any raw DOM + // mutation a script made (e.g. document.body.innerHTML = ...) + pageRestoreNonce: 0, breakpoints: [ { icon: "lucide-monitor", @@ -282,7 +316,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 +331,9 @@ onMounted(() => { onUnmounted(() => { cleanupMarqueeListeners(); + cleanupShadowCanvasEvents(); + pageScriptRuntimes.forEach((runtime) => runtime.destroy()); + pageScriptRuntimes.clear(); }); const handleClick = (ev: MouseEvent) => { @@ -305,7 +342,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")) { @@ -382,6 +419,7 @@ watch( provide("canvasProps", canvasProps); provide("emulateBlockClientScript", emulateBlockClientScript); +provide("registerBlockStateStyles", registerBlockStateStyles); defineExpose({ setScaleAndTranslate, @@ -448,28 +486,41 @@ function selectBreakpoint(ev: MouseEvent, breakpoint: BreakpointConfig) { } } -function escapeAttributeValue(value: string) { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +// Registered whatever the mode, so toggling preview flips one computed here +// rather than re-running every block's watcher. +function registerBlockStateStyles(uid: string, breakpoint: string, css: string) { + const registrationKey = `${uid}:${breakpoint}`; + blockStateStyles.set(registrationKey, { breakpoint, css }); + return () => blockStateStyles.delete(registrationKey); } function emulateBlockClientScript(script: BlockClientScriptRuntime) { const registrationKey = `${script.key}:${script.breakpoint}`; - const selector = `[data-builder-canvas="${canvasId}"] [data-block-uid="${escapeAttributeValue( - script.key, - )}"][data-breakpoint="${escapeAttributeValue(script.breakpoint)}"]`; - blockStyles.set(registrationKey, script.css ? `${selector} { ${script.css} }` : ""); - const mode = builderSettings.doc?.execute_block_scripts_in_editor ?? "Restricted"; + // CSS runs alongside the JavaScript, under the same Run/Stop and mode gate, + // not on its own + const scriptsActive = canvasProps.scriptsRunning && mode !== "Don't Execute"; + + // each breakpoint's shadow root already scopes its own styles to one canvas + const selector = blockSelector(script.key, script.breakpoint); + const css = scriptsActive ? script.css : ""; + blockStyles.set(registrationKey, { + breakpoint: script.breakpoint, + css: css ? `${selector} { ${css} }` : "", + }); + let cleanup = () => {}; - if (mode !== "Don't Execute" && script.javascript.trim()) { + if (scriptsActive && script.javascript.trim()) { const context = { 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 +533,52 @@ 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)); + // the shadow root exists before Vue teleports the block tree into it, + // so a script run now finds no root block + nextTick(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"; + const runScripts = canvasProps.scriptsRunning && mode !== "Don't Execute"; + pageScriptRuntimes.forEach((runtime) => + runtime.apply(pageStore.activePageScripts, runScripts, pageStore.pageData), + ); +} + +watch( + [ + () => pageStore.activePageScripts, + () => pageStore.pageData, + () => builderSettings.doc?.execute_block_scripts_in_editor, + () => pageStore.settingPage, + () => canvasProps.scriptsRunning, + ], + applyPageClientScripts, + { deep: true }, +); + +// Stopping cleans up listeners and timers only. A script can also mutate the +// DOM directly, such as document.body.innerHTML = ... Vue has no way to detect +// or undo that on its own. So this remounts the block tree from the Block +// model to bring the page back. +watch( + () => canvasProps.scriptsRunning, + (running, wasRunning) => { + if (!running && wasRunning) canvasProps.pageRestoreNonce++; + }, +);