Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
1 change: 1 addition & 0 deletions frontend/src/builder.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ declare interface CanvasProps {
settingCanvas: boolean;
overlayElement: HTMLElement | null;
breakpoints: Breakpoint[];
scriptsRunning: boolean;
}

declare type EditingMode = "page" | "fragment";
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/BlockEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
38 changes: 26 additions & 12 deletions frontend/src/components/BuilderBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -245,6 +247,7 @@ const emulateBlockClientScript = inject<BlockClientScriptEmulator>(
"emulateBlockClientScript",
() => () => {},
);
const registerBlockStateStyles = inject<BlockStateStyleRegistrar>("registerBlockStateStyles", () => () => {});

const target = computed(() => {
if (!component.value) return null;
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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({
Expand All @@ -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 ?? {},
Expand Down
145 changes: 121 additions & 24 deletions frontend/src/components/BuilderCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
:data-builder-canvas="canvasId"
@click="handleClick"
@mousedown="handleMarqueeStart">
<component :is="'style'" v-if="blockClientStyles" v-text="blockClientStyles" />
<Transition name="fade">
<div
class="absolute bottom-0 left-0 right-0 top-0 grid w-full place-items-center bg-surface-gray-1 p-10 text-ink-gray-5"
Expand Down Expand Up @@ -75,15 +74,23 @@
@click="activeBreakpoint = breakpoint.device">
{{ breakpoint.displayName }}
</div>
<BuilderBlock
class="h-full min-h-[inherit]"
:block="block"
:style="variables"
:key="block.blockId"
:readonly="builderStore.readOnlyMode"
v-if="showBlocks"
:breakpoint="breakpoint.device"
:data="pageStore.pageData" />
<BuilderCanvasShadowRoot
@ready="(root) => registerCanvasRoot(breakpoint, root)"
@teardown="releaseCanvasRoot(breakpoint)">
<component
:is="'style'"
v-if="blockStylesByBreakpoint.get(breakpoint.device)"
v-text="blockStylesByBreakpoint.get(breakpoint.device)" />
<BuilderBlock
class="h-full min-h-[inherit]"
:block="block"
:style="variables"
:key="`${block.blockId}-${canvasProps.pageRestoreNonce}`"
:readonly="builderStore.readOnlyMode"
v-if="showBlocks"
:breakpoint="breakpoint.device"
:data="pageStore.pageData" />
</BuilderCanvasShadowRoot>
</div>
</div>
<div
Expand Down Expand Up @@ -127,7 +134,10 @@ import useBuilderStore from "@/stores/builderStore";
import useCanvasStore from "@/stores/canvasStore";
import usePageStore from "@/stores/pageStore";
import { BreakpointConfig, CanvasHistory } from "@/types/Builder/BuilderCanvas";
import { blockSelector } from "@/utils/blockStateStyles";
import { elementFromPoint, getShadowRootOf } from "@/utils/canvasShadowDom";
import { getBlockObject, isCtrlOrCmd } from "@/utils/helpers";
import { PageScriptRuntime } from "@/utils/pageScriptEmulation";
import {
type BlockClientScriptRuntime,
executeClientScriptRestricted,
Expand All @@ -141,10 +151,11 @@ import { useCanvasEvents } from "@/utils/useCanvasEvents";
import { useCanvasMarqueeSelection } from "@/utils/useCanvasMarqueeSelection";
import { useCanvasUtils } from "@/utils/useCanvasUtils";
import { Tooltip } from "frappe-ui";
import { Ref, computed, onMounted, onUnmounted, provide, reactive, ref, useId, watch } from "vue";
import { Ref, computed, nextTick, onMounted, onUnmounted, provide, reactive, ref, useId, watch } from "vue";
import setPanAndZoom from "../utils/panAndZoom";
import BlockSnapGuides from "./BlockSnapGuides.vue";
import BuilderBlock from "./BuilderBlock.vue";
import BuilderCanvasShadowRoot from "./BuilderCanvasShadowRoot.vue";
import DropIndicator from "./DropIndicator.vue";
import FitScreenIcon from "./Icons/FitScreen.vue";

Expand All @@ -167,7 +178,9 @@ const canvasContainer = ref(null) as Ref<HTMLElement | null>;
const canvas = ref(null);
const showBlocks = ref(false);
const overlay = ref(null);
const blockStyles = reactive(new Map<string, string>());
type BreakpointStyles = { breakpoint: string; css: string };
const blockStyles = reactive(new Map<string, BreakpointStyles>());
const blockStateStyles = reactive(new Map<string, BreakpointStyles>());

const props = withDefaults(
defineProps<{
Expand All @@ -181,12 +194,27 @@ const props = withDefaults(

const block = ref(props.blockData) as Ref<Block>;
const history = ref(null) as Ref<null> | 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<string, string>();
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<string | null>;
const hoveredBreakpoint = ref("desktop") as Ref<string | null>;
const hoveredBlock = ref(null) as Ref<string | null>;
const setCanvasZoom = ref<(scale: number, pinchPoint: { x: number; y: number } | "center") => void>();
let cleanupShadowCanvasEvents = () => {};

const {
clearSelection,
Expand All @@ -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",
Expand Down Expand Up @@ -282,7 +316,7 @@ onMounted(() => {
(readOnly) => (readOnly ? history.value?.disable() : history.value?.enable()),
{ immediate: true },
);
useCanvasEvents(
cleanupShadowCanvasEvents = useCanvasEvents(
canvasContainer as unknown as Ref<HTMLElement>,
canvasProps,
history as CanvasHistory,
Expand All @@ -297,6 +331,9 @@ onMounted(() => {

onUnmounted(() => {
cleanupMarqueeListeners();
cleanupShadowCanvasEvents();
pageScriptRuntimes.forEach((runtime) => runtime.destroy());
pageScriptRuntimes.clear();
});

const handleClick = (ev: MouseEvent) => {
Expand All @@ -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")) {
Expand Down Expand Up @@ -382,6 +419,7 @@ watch(

provide("canvasProps", canvasProps);
provide("emulateBlockClientScript", emulateBlockClientScript);
provide("registerBlockStateStyles", registerBlockStateStyles);

defineExpose({
setScaleAndTranslate,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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<string, PageScriptRuntime>();

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++;
},
);
</script>
<style>
.fade-enter-active,
Expand Down
Loading
Loading