From 4f80dc668a26875ec7649ead038fb7023f7a4187 Mon Sep 17 00:00:00 2001 From: stravo1 Date: Mon, 20 Jul 2026 16:43:10 +0530 Subject: [PATCH] fix: preview state styles during canvas drags When a state variant is active, canvas drag handles now read and write the active style key instead of falling back to the base style. This keeps rotation, resize, spacing, movement and border radius previews aligned with the selected state. --- frontend/src/block.ts | 14 +++++++++ frontend/src/builder.d.ts | 2 +- frontend/src/components/BlockEditor.vue | 21 ++++++++----- .../src/components/BorderRadiusHandler.vue | 19 ++++++++---- frontend/src/components/BoxResizer.vue | 30 +++++++++++-------- frontend/src/components/BuilderBlock.vue | 9 +++--- .../Controls/BasePropertyControl.vue | 18 +++++++++++ frontend/src/components/RotationHandler.vue | 4 +-- frontend/src/composables/useSpacingHandler.ts | 17 ++++++----- frontend/src/utils/rotation.ts | 2 +- 10 files changed, 93 insertions(+), 43 deletions(-) diff --git a/frontend/src/block.ts b/frontend/src/block.ts index 60e8ea78a..a0ac97aa8 100644 --- a/frontend/src/block.ts +++ b/frontend/src/block.ts @@ -393,6 +393,20 @@ class Block implements BlockOptions { } styleObj[style] = value; } + setActiveStyle(style: styleProperty, value: StyleValue) { + this.setStyle(this.getActiveStyleProperty(style), value); + } + getActiveStyleValue(style: styleProperty) { + const activeStateStyle = this.getActiveStyleProperty(style); + const activeValue = activeStateStyle === style ? undefined : this.getStyle(activeStateStyle); + return activeValue === null || activeValue === "" || activeValue === undefined + ? this.getStyle(style) + : activeValue; + } + getActiveStyleProperty(style: styleProperty) { + const activeState = this.activeState?.split(":")[0]; + return (activeState ? `${activeState}:${style}` : style) as styleProperty; + } setAttribute(attribute: string, value: string | undefined) { this.attributes[attribute] = value; } diff --git a/frontend/src/builder.d.ts b/frontend/src/builder.d.ts index a8023140e..15ce32487 100644 --- a/frontend/src/builder.d.ts +++ b/frontend/src/builder.d.ts @@ -1,6 +1,6 @@ declare type StyleValue = string | number | boolean | null | undefined; -declare type styleProperty = keyof CSSProperties | `__${string}`; +declare type styleProperty = keyof CSSProperties | `${string}:${keyof CSSProperties}` | `__${string}`; declare interface BlockStyleMap { [key: styleProperty]: StyleValue; diff --git a/frontend/src/components/BlockEditor.vue b/frontend/src/components/BlockEditor.vue index 4e339d041..be1c4c300 100644 --- a/frontend/src/components/BlockEditor.vue +++ b/frontend/src/components/BlockEditor.vue @@ -46,7 +46,7 @@ import type Block from "@/block"; import useBuilderStore from "@/stores/builderStore"; import useCanvasStore from "@/stores/canvasStore"; import blockController from "@/utils/blockController"; -import { addPxToNumber } from "@/utils/helpers"; +import { addPxToNumber, getNumberFromPx } from "@/utils/helpers"; import { isReorderable, startBlockReorder } from "@/utils/useBlockReorder"; import { Ref, computed, inject, nextTick, onMounted, ref, watch, watchEffect } from "vue"; import setGuides from "../utils/guidesTracker"; @@ -100,6 +100,7 @@ const transforming = computed(() => resizing.value || rotating.value); const guides = setGuides(props.target, canvasProps); const moving = ref(false); const preventCLick = ref(false); +const hasStyleValue = (value: StyleValue) => value !== null && value !== undefined && value !== ""; const showPaddingHandler = computed(() => { return ( @@ -296,8 +297,14 @@ const handleMove = (ev: MouseEvent) => { const target = ev.target as HTMLElement; const startX = ev.clientX; const startY = ev.clientY; - const startLeft = (props.target as HTMLElement).offsetLeft || 0; - const startTop = (props.target as HTMLElement).offsetTop || 0; + const activeLeft = props.block.getActiveStyleValue("left"); + const activeTop = props.block.getActiveStyleValue("top"); + const startLeft = hasStyleValue(activeLeft) + ? getNumberFromPx(activeLeft) + : (props.target as HTMLElement).offsetLeft || 0; + const startTop = hasStyleValue(activeTop) + ? getNumberFromPx(activeTop) + : (props.target as HTMLElement).offsetTop || 0; moving.value = true; guides.showX(); @@ -314,15 +321,15 @@ const handleMove = (ev: MouseEvent) => { const movementY = (mouseMoveEvent.clientY - startY) / scale; let finalLeft = startLeft + movementX; let finalTop = startTop + movementY; - props.block.setStyle("left", addPxToNumber(finalLeft)); - props.block.setStyle("top", addPxToNumber(finalTop)); + props.block.setActiveStyle("left", addPxToNumber(finalLeft)); + props.block.setActiveStyle("top", addPxToNumber(finalTop)); await nextTick(); const { leftOffset, rightOffset } = guides.getPositionOffset(); if (leftOffset !== 0) { - props.block.setStyle("left", addPxToNumber(finalLeft + leftOffset)); + props.block.setActiveStyle("left", addPxToNumber(finalLeft + leftOffset)); } if (rightOffset !== 0) { - props.block.setStyle("left", addPxToNumber(finalLeft + rightOffset)); + props.block.setActiveStyle("left", addPxToNumber(finalLeft + rightOffset)); } mouseMoveEvent.preventDefault(); diff --git a/frontend/src/components/BorderRadiusHandler.vue b/frontend/src/components/BorderRadiusHandler.vue index adab3b15c..87eee1dd1 100644 --- a/frontend/src/components/BorderRadiusHandler.vue +++ b/frontend/src/components/BorderRadiusHandler.vue @@ -28,7 +28,15 @@ const props = defineProps<{ target: HTMLElement | SVGElement; }>(); -const borderRadius = ref(parseInt(props.target.style.borderRadius, 10) || 0); +const hasStyleValue = (value: StyleValue) => value !== null && value !== undefined && value !== ""; + +const getCurrentRadius = () => { + const radius = props.targetBlock.getActiveStyleValue("borderRadius"); + if (hasStyleValue(radius)) return getNumberFromPx(radius); + return parseInt(props.target.style.borderRadius, 10) || 0; +}; + +const borderRadius = ref(getCurrentRadius()); const updating = ref(false); const canvasProps = inject("canvasProps") as CanvasProps; const handler = ref() as Ref; @@ -73,6 +81,7 @@ const handleRounded = (ev: MouseEvent) => { const startY = ev.clientY; let lastX = startX; let lastY = startY; + borderRadius.value = getCurrentRadius(); updating.value = true; const handleDimensions = handler.value.getBoundingClientRect(); @@ -88,13 +97,11 @@ const handleRounded = (ev: MouseEvent) => { MIN_POSITION.left = -(handleDimensions.width / 2); } - const radius = Math.round( - Math.max(0, Math.min(getNumberFromPx(props.target.style.borderRadius) + movement, maxRadius.value)), - ); + const radius = Math.round(Math.max(0, Math.min(borderRadius.value + movement, maxRadius.value))); borderRadius.value = radius; setHandlerPosition(radius); - props.targetBlock.setStyle("borderRadius", `${radius}px`); + props.targetBlock.setActiveStyle("borderRadius", `${radius}px`); lastX = mouseMoveEvent.clientX; lastY = mouseMoveEvent.clientY; @@ -102,7 +109,7 @@ const handleRounded = (ev: MouseEvent) => { const mouseup = (mouseUpEvent: MouseEvent) => { mouseUpEvent.preventDefault(); - if (getNumberFromPx(props.targetBlock.getStyle("borderRadius")) < 10) { + if (getNumberFromPx(props.targetBlock.getActiveStyleValue("borderRadius")) < 10) { handlerTop.value = MIN_POSITION.top; handlerLeft.value = MIN_POSITION.left; } diff --git a/frontend/src/components/BoxResizer.vue b/frontend/src/components/BoxResizer.vue index 6ad76dcde..e251f6489 100644 --- a/frontend/src/components/BoxResizer.vue +++ b/frontend/src/components/BoxResizer.vue @@ -54,6 +54,7 @@ const cursorPosition = ref({ x: 0, y: 0 }); let guides = null as unknown as ReturnType; const canvasProps = inject("canvasProps") as CanvasProps; +const hasStyleValue = (value: StyleValue) => value !== null && value !== undefined && value !== ""; onMounted(() => { guides = guidesTracker(props.target as HTMLElement, canvasProps); @@ -123,18 +124,21 @@ watch(resizing, () => { }); const targetWidth = computed(() => { - props.targetBlock.getStyle("width"); // to trigger reactivity + props.targetBlock.getActiveStyleValue("width"); // to trigger reactivity return Math.round(getNumberFromPx(getComputedStyle(props.target).getPropertyValue("width"))); }); const targetHeight = computed(() => { - props.targetBlock.getStyle("height"); // to trigger reactivity + props.targetBlock.getActiveStyleValue("height"); // to trigger reactivity return Math.round(getNumberFromPx(getComputedStyle(props.target).getPropertyValue("height"))); }); const fontSize = computed(() => { - props.targetBlock.getStyle("fontSize"); // to trigger reactivity - return Math.round(getNumberFromPx(getComputedStyle(props.target).getPropertyValue("font-size"))); + const activeFontSize = props.targetBlock.getActiveStyleValue("fontSize"); + const fontSize = hasStyleValue(activeFontSize) + ? getNumberFromPx(activeFontSize) + : getNumberFromPx(getComputedStyle(props.target).getPropertyValue("font-size")); + return Math.round(fontSize); }); // For the left/top side, the opposite edge is kept visually fixed by shifting top/left the same @@ -148,8 +152,8 @@ const handleResize = (ev: MouseEvent, { horizontal, vertical }: ResizeDirection) const startTop = target.offsetTop; const startLeft = target.offsetLeft; const ownRotation = parseFloat(getComputedStyle(target).rotate) || 0; - const blockStartWidth = props.targetBlock.getStyle("width") as string; - const blockStartHeight = props.targetBlock.getStyle("height") as string; + const blockStartWidth = props.targetBlock.getActiveStyleValue("width") as string; + const blockStartHeight = props.targetBlock.getActiveStyleValue("height") as string; const startFontSize = fontSize.value || 0; const canReposition = props.targetBlock.isMovable(); @@ -198,8 +202,8 @@ const handleResize = (ev: MouseEvent, { horizontal, vertical }: ResizeDirection) { horizontal, vertical }, ownRotation, ); - props.targetBlock.setStyle("left", `${Math.round(startLeft + positionDelta.x)}px`); - props.targetBlock.setStyle("top", `${Math.round(startTop + positionDelta.y)}px`); + props.targetBlock.setActiveStyle("left", `${Math.round(startLeft + positionDelta.x)}px`); + props.targetBlock.setActiveStyle("top", `${Math.round(startTop + positionDelta.y)}px`); } }, onEnd: () => { @@ -229,9 +233,9 @@ const setWidth = (movementX: number, startWidth: number, blockStartWidth: string const movementPercent = (movementX / parentWidth) * 100; const startWidthPercent = (startWidth / parentWidth) * 100; const finalWidthPercent = Math.abs(Math.round(startWidthPercent + movementPercent)); - props.targetBlock.setStyle("width", `${finalWidthPercent}%`); + props.targetBlock.setActiveStyle("width", `${finalWidthPercent}%`); } else { - props.targetBlock.setStyle("width", `${finalWidth}px`); + props.targetBlock.setActiveStyle("width", `${finalWidth}px`); } }; @@ -242,14 +246,14 @@ const setHeight = (movementY: number, startHeight: number, blockStartHeight: str const movementPercent = (movementY / parentHeight) * 100; const startHeightPercent = (startHeight / parentHeight) * 100; const finalHeightPercent = Math.abs(Math.round(startHeightPercent + movementPercent)); - props.targetBlock.setStyle("height", `${finalHeightPercent}%`); + props.targetBlock.setActiveStyle("height", `${finalHeightPercent}%`); } else { - props.targetBlock.setStyle("height", `${finalHeight}px`); + props.targetBlock.setActiveStyle("height", `${finalHeight}px`); } }; const setFontSize = (movement: number, startFontSize: number) => { const fontSize = clamp(Math.round(startFontSize + 0.5 * movement), 10, 300); - props.targetBlock.setStyle("fontSize", `${fontSize}px`); + props.targetBlock.setActiveStyle("fontSize", `${fontSize}px`); }; diff --git a/frontend/src/components/BuilderBlock.vue b/frontend/src/components/BuilderBlock.vue index 3d335bc28..12088c72e 100644 --- a/frontend/src/components/BuilderBlock.vue +++ b/frontend/src/components/BuilderBlock.vue @@ -349,16 +349,14 @@ const styles = computed(() => { ...dynamicStyles, } as BlockStyleMap; - if (props.block.activeState) { - const [state, property] = props.block.activeState.split(":"); + if (!props.preview && isSelected.value && props.block.activeState) { + const [state] = props.block.activeState.split(":"); if (canvasStore.activeCanvas?.activeBreakpoint === props.breakpoint) { const stateStyles = props.block.getStateStyles(state, props.breakpoint); if (stateStyles) { Object.keys(stateStyles).forEach((key) => { - if (key === property) { - styleMap[key] = stateStyles[key]; - } + styleMap[key] = stateStyles[key]; }); } } @@ -620,6 +618,7 @@ if (!props.preview) { } } else { isSelected.value = false; + props.block.activeState = null; } }, { diff --git a/frontend/src/components/Controls/BasePropertyControl.vue b/frontend/src/components/Controls/BasePropertyControl.vue index 1543870d4..de7a6d1c5 100644 --- a/frontend/src/components/Controls/BasePropertyControl.vue +++ b/frontend/src/components/Controls/BasePropertyControl.vue @@ -46,6 +46,8 @@ :placeholder="placeholderValue" :dynamicValueKey="dynamicValue?.key" componentClass="w-full" + @focusin="clearActiveVariant" + @mousedown="clearActiveVariant" @update:modelValue="updateValue" @keydown="handleKeyDown" @openDynamicModal="showDynamicValueModal = true" @@ -71,6 +73,8 @@ :placeholder="placeholderValue" :enableSlider="enableSlider" :isLast="index === visibleVariants.length - 1" + @focusin="setActiveVariant(variant.property)" + @mousedown="setActiveVariant(variant.property)" @update:modelValue="(v: any) => updateVariantValue(variant.name, v)" @keydown="(e: KeyboardEvent) => handleKeyDown(e, variant.name)" @labelMousedown="(e: MouseEvent) => handleSliderMouseDown(e, variant.name)" @@ -238,6 +242,19 @@ const clearVariant = (variantName: string) => props.setVariantValue?.(variantNam const showDynamicValueModal = ref(false); +const setActiveVariant = (property: string) => { + if (props.controlType !== "style") return; + blockController.getSelectedBlocks().forEach((block) => { + block.activeState = property; + }); +}; + +const clearActiveVariant = () => { + blockController.getSelectedBlocks().forEach((block) => { + block.activeState = null; + }); +}; + const dropdownOptions = computed(() => { const options = []; if (props.variants?.length) { @@ -249,6 +266,7 @@ const dropdownOptions = computed(() => { onClick: () => { if (props.setVariantValue) { props.setVariantValue(variant.name, rawModelValue.value as string); + setActiveVariant(variant.property); } }, })), diff --git a/frontend/src/components/RotationHandler.vue b/frontend/src/components/RotationHandler.vue index e3ec5cee8..5f371475a 100644 --- a/frontend/src/components/RotationHandler.vue +++ b/frontend/src/components/RotationHandler.vue @@ -57,7 +57,7 @@ const handleRotate = (ev: MouseEvent, baseAngle: number) => { const centerX = bounds.left + bounds.width / 2; const centerY = bounds.top + bounds.height / 2; let previousPointerAngle = Math.atan2(ev.clientY - centerY, ev.clientX - centerX) * (180 / Math.PI); - let rotation = parseFloat(String(props.targetBlock.getStyle("rotate") || 0)) || 0; + let rotation = parseFloat(String(props.targetBlock.getActiveStyleValue("rotate") || 0)) || 0; // ancestors don't rotate mid-drag, so their contribution can be captured once up front const ancestorRotation = getElementRotation((props.target as Element).parentElement); const pauseId = canvasStore.activeCanvas?.history?.pause(); @@ -85,7 +85,7 @@ const handleRotate = (ev: MouseEvent, baseAngle: number) => { ? Math.round(rotation / 15) * 15 : Math.round(rotation); currentRotation.value = finalRotation; - props.targetBlock.setStyle("rotate", `${finalRotation}deg`); + props.targetBlock.setActiveStyle("rotate", `${finalRotation}deg`); // the cursor SVG only needs rebuilding when the rounded/snapped angle actually // changes - most mousemove ticks land on the same value, especially while snapping if (finalRotation !== lastCursorAngle) { diff --git a/frontend/src/composables/useSpacingHandler.ts b/frontend/src/composables/useSpacingHandler.ts index 6a12a0d6b..200a06989 100644 --- a/frontend/src/composables/useSpacingHandler.ts +++ b/frontend/src/composables/useSpacingHandler.ts @@ -34,6 +34,7 @@ const sides = { const verticalSides = [Position.Top, Position.Bottom]; const horizontalSides = [Position.Left, Position.Right]; const allSides = [...verticalSides, ...horizontalSides]; +const hasStyleValue = (value: StyleValue) => value !== null && value !== undefined && value !== ""; // Shared state and drag behaviour for the Margin and Padding handlers. The per-side // positioning and value display differ and stay in each component. @@ -42,13 +43,12 @@ export function useSpacingHandler(getTargetBlock: () => Block, getBreakpoint: () const updating = ref(false); const blockStyles = computed(() => { + const block = getTargetBlock(); const breakpoint = getBreakpoint(); - let styles = { ...getTargetBlock().baseStyles }; - if (breakpoint === "mobile" || breakpoint === "tablet") { - styles = { ...styles, ...getTargetBlock().mobileStyles }; - } - if (breakpoint === "tablet") { - styles = { ...styles, ...getTargetBlock().tabletStyles }; + const styles = { ...block.getStyles(breakpoint) }; + if (block.activeState) { + const [state] = block.activeState.split(":"); + Object.assign(styles, block.getStateStyles(state, breakpoint)); } return styles; }); @@ -71,7 +71,7 @@ export function useSpacingHandler(getTargetBlock: () => Block, getBreakpoint: () `${property}${sides[side].styleSuffix}` as styleProperty; const setSpacing = (property: SpacingProperty, side: Position, value: number) => - getTargetBlock().setStyle(styleKey(property, side), `${value}px`); + getTargetBlock().setActiveStyle(styleKey(property, side), `${value}px`); // Shift spreads the value to all four sides, alt to both sides of the dragged axis. const sidesToUpdate = (event: MouseEvent, side: Position) => { @@ -88,7 +88,8 @@ export function useSpacingHandler(getTargetBlock: () => Block, getBreakpoint: () const { axis, outward } = sides[side]; // the handles sit on the block's edge, so dragging outward grows a margin but shrinks a padding const sign = property === "margin" ? outward : -outward; - const startValue = getNumberFromPx(blockStyles.value[styleKey(property, side)] as string) || fallback; + const currentValue = blockStyles.value[styleKey(property, side)]; + const startValue = hasStyleValue(currentValue) ? getNumberFromPx(currentValue) : fallback; const startPoint = { x: event.clientX, y: event.clientY }; event.preventDefault(); diff --git a/frontend/src/utils/rotation.ts b/frontend/src/utils/rotation.ts index 062150af5..9ff6df7c1 100644 --- a/frontend/src/utils/rotation.ts +++ b/frontend/src/utils/rotation.ts @@ -34,7 +34,7 @@ function toLocalDelta(dx: number, dy: number, rotationDeg: number) { // Uses the reactive block style so computed cursors update with style-panel edits. function getTotalRotation(target: Element, targetBlock: Block): number { - const ownRotation = parseFloat(String(targetBlock.getStyle("rotate") || 0)) || 0; + const ownRotation = parseFloat(String(targetBlock.getActiveStyleValue("rotate") || 0)) || 0; return ownRotation + getElementRotation(target.parentElement); }