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); }