diff --git a/apps/designer/app/designer/features/style-panel/sections/spacing/input-popover.tsx b/apps/designer/app/designer/features/style-panel/sections/spacing/input-popover.tsx index 9a9f27cfd1..49b7d18869 100644 --- a/apps/designer/app/designer/features/style-panel/sections/spacing/input-popover.tsx +++ b/apps/designer/app/designer/features/style-panel/sections/spacing/input-popover.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { type ComponentProps, useState } from "react"; import { keyframes, Popover, @@ -7,13 +7,14 @@ import { PopoverPortal, styled, } from "@webstudio-is/design-system"; +import { StyleUpdate } from "@webstudio-is/project"; +import type { StyleProperty, StyleValue } from "@webstudio-is/css-data"; import { CssValueInput, type IntermediateStyleValue, } from "../../shared/css-value-input"; -import type { StyleProperty, StyleValue } from "@webstudio-is/css-data"; -import type { StyleChangeHandler } from "./types"; import type { StyleSource } from "../../shared/style-info"; +import { StyleUpdateOptions } from "../../shared/use-style-data"; const slideUpAndFade = keyframes({ "0%": { opacity: 0, transform: "scale(0.8)" }, @@ -30,7 +31,7 @@ const Input = ({ styleSource: StyleSource; property: StyleProperty; value: StyleValue; - onChange: StyleChangeHandler; + onChange: (update: StyleUpdate, options: StyleUpdateOptions) => void; onClosePopover: () => void; }) => { const [intermediateValue, setIntermediateValue] = useState< @@ -51,7 +52,6 @@ const Input = ({ return; } - setIntermediateValue(styleValue); if (styleValue.type !== "intermediate") { onChange( { operation: "set", property, value: styleValue }, @@ -74,7 +74,6 @@ const Input = ({ setIntermediateValue(undefined); onChange({ operation: "set", property, value }, { isEphemeral: false }); - // @todo: handle Esc if (reason === "blur" || reason === "enter") { onClosePopover(); } @@ -114,7 +113,7 @@ export const InputPopover = ({ styleSource: StyleSource; property: StyleProperty; value: StyleValue; - onChange: StyleChangeHandler; + onChange: ComponentProps["onChange"]; isOpen: boolean; onClose: () => void; }) => { diff --git a/apps/designer/app/designer/features/style-panel/sections/spacing/layout.tsx b/apps/designer/app/designer/features/style-panel/sections/spacing/layout.tsx index 15ae423e08..bd935d6f73 100644 --- a/apps/designer/app/designer/features/style-panel/sections/spacing/layout.tsx +++ b/apps/designer/app/designer/features/style-panel/sections/spacing/layout.tsx @@ -223,14 +223,14 @@ const getPath = (property: SpacingStyleProperty) => { type LayoutProps = { onClick: () => void; onHover: (hoverTarget: HoverTagret | undefined) => void; - activeProperty?: SpacingStyleProperty; + activeProperties?: ReadonlyArray; renderCell: (args: { property: SpacingStyleProperty }) => React.ReactNode; }; export const SpacingLayout = ({ onClick, onHover, - activeProperty, + activeProperties, renderCell, }: LayoutProps) => { const outerClipId = useId(); @@ -244,7 +244,7 @@ export const SpacingLayout = ({ onHover({ element: event.currentTarget, property }) } onMouseLeave={() => onHover(undefined)} - isActive={activeProperty === property} + isActive={activeProperties?.includes(property)} /> ); diff --git a/apps/designer/app/designer/features/style-panel/sections/spacing/scrub.tsx b/apps/designer/app/designer/features/style-panel/sections/spacing/scrub.tsx index 3392f0d5b1..7ea0f8aac4 100644 --- a/apps/designer/app/designer/features/style-panel/sections/spacing/scrub.tsx +++ b/apps/designer/app/designer/features/style-panel/sections/spacing/scrub.tsx @@ -1,30 +1,48 @@ import type { StyleValue, UnitValue } from "@webstudio-is/css-data"; import { numericScrubControl } from "@webstudio-is/design-system"; import { useState, useEffect, useRef } from "react"; -import type { - StyleChangeHandler, - SpacingStyleProperty, - HoverTagret, -} from "./types"; +import { useModifierKeys } from "../../shared/modifier-keys"; +import type { StyleUpdateOptions } from "../../shared/use-style-data"; +import type { SpacingStyleProperty, HoverTagret } from "./types"; -type ScrubStatus = - | { isActive: false } - | { isActive: true; property: SpacingStyleProperty; value: UnitValue }; +type Values = Partial>; + +type ScrubStatus = { + isActive: boolean; + + // Properties that should be affected on the next pointer move. + // We keep track of these properties even when scrub is not active. + properties: ReadonlyArray; + + // When scrub is active, this contains ephemeral values for all properties + // that have been affected during current scrub. + // + // Note that Object.keys(values) != properties above, + // because user can press and release modifier keys multiple times during scrub. + values: Values; +}; export const useScrub = (props: { value?: StyleValue; target: HoverTagret | undefined; - onChange: StyleChangeHandler; + onChange: (values: Values, options: StyleUpdateOptions) => void; }): ScrubStatus => { // we want to hold on to the target while scrub is active even if hover changes const [activeTarget, setActiveTarget] = useState(); const finalTarget = activeTarget ?? props.target; - const [value, setValue] = useState(); + const modifiers = useModifierKeys(); - // we need this in useEffect, but don't want it as a dependency - const latestProps = useRef(props); - latestProps.current = props; + const properties = + finalTarget === undefined + ? [] + : getModifiersGroup(finalTarget.property, modifiers); + + const [values, setValues] = useState({}); + + // we need these in useEffect, but don't want as dependencies + const nonDependencies = useRef({ props, values, properties }); + nonDependencies.current = { props, values, properties }; const unitRef = useRef(); @@ -35,56 +53,53 @@ export const useScrub = (props: { const property = finalTarget.property; + const handleChange = (event: { value: number }, isEphemeral: boolean) => { + // for TypeScript + if (unitRef.current === undefined) { + return; + } + + const { values, properties, props } = nonDependencies.current; + + const value = { + type: "unit", + value: event.value, + unit: unitRef.current, + } as const; + + const nextValues = { ...values }; + for (const property of properties) { + nextValues[property] = value; + } + + props.onChange(nextValues, { isEphemeral }); + + setValues(isEphemeral ? nextValues : {}); + }; + const scrub = numericScrubControl(finalTarget.element, { direction: property.endsWith("Left") || property.endsWith("Right") ? "horizontal" : "vertical", getValue() { - const { value } = latestProps.current; + const { value } = nonDependencies.current.props; if (value?.type === "unit") { unitRef.current = value.unit; return value.value; } }, onValueInput(event) { - // for TypeScript - if (unitRef.current === undefined) { - return; - } - - const value = { - type: "unit", - value: event.value, - unit: unitRef.current, - } as const; - - setValue(value); - latestProps.current.onChange( - { operation: "set", property, value }, - { isEphemeral: true } - ); + handleChange(event, true); }, onValueChange(event) { - // for TypeScript - if (unitRef.current === undefined) { - return; - } - - const value = { - type: "unit", - value: event.value, - unit: unitRef.current, - } as const; - - latestProps.current.onChange( - { operation: "set", property, value }, - { isEphemeral: false } - ); + handleChange(event, false); }, onStatusChange(status) { setActiveTarget( - status === "scrubbing" ? latestProps.current.target : undefined + status === "scrubbing" + ? nonDependencies.current.props.target + : undefined ); }, }); @@ -92,7 +107,36 @@ export const useScrub = (props: { return scrub.disconnectedCallback; }, [finalTarget]); - return activeTarget === undefined || value === undefined - ? { isActive: false } - : { isActive: true, property: activeTarget.property, value }; + return { + isActive: activeTarget !== undefined, + properties, + values, + }; +}; + +const opposingGroups = [ + ["paddingTop", "paddingBottom"], + ["paddingRight", "paddingLeft"], + ["marginTop", "marginBottom"], + ["marginRight", "marginLeft"], +] as const; + +const circleGroups = [ + ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"], + ["marginTop", "marginRight", "marginBottom", "marginLeft"], +] as const; + +const getModifiersGroup = ( + property: SpacingStyleProperty, + modifiers: { shiftKey: boolean; altKey: boolean } +) => { + let groups: ReadonlyArray> = []; + + if (modifiers.shiftKey) { + groups = circleGroups; + } else if (modifiers.altKey) { + groups = opposingGroups; + } + + return groups.find((group) => group.includes(property)) ?? [property]; }; diff --git a/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx b/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx index ca7fb5c292..734dd78d9c 100644 --- a/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx +++ b/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx @@ -1,13 +1,10 @@ -import { useState } from "react"; +import { type ComponentProps, useState } from "react"; import type { RenderCategoryProps } from "../../style-sections"; import { SpacingLayout } from "./layout"; import { ValueText } from "./value-text"; import { useScrub } from "./scrub"; -import type { - StyleChangeHandler, - SpacingStyleProperty, - HoverTagret, -} from "./types"; +import { spacingPropertiesNames } from "./types"; +import type { SpacingStyleProperty, HoverTagret } from "./types"; import { InputPopover } from "./input-popover"; import { SpacingTooltip } from "./tooltip"; import { getStyleSource } from "../../shared/style-info"; @@ -24,7 +21,7 @@ const Cell = ({ }: { isPopoverOpen: boolean; onPopoverClose: () => void; - onChange: StyleChangeHandler; + onChange: ComponentProps["onChange"]; onHover: (target: HoverTagret | undefined) => void; property: SpacingStyleProperty; isActive: boolean; @@ -32,9 +29,8 @@ const Cell = ({ currentStyle: RenderCategoryProps["currentStyle"]; }) => { const styleInfo = currentStyle[property]; - const finalValue = scrubStatus.isActive - ? scrubStatus.value - : styleInfo?.value; + const finalValue = + (scrubStatus.isActive && scrubStatus.values[property]) || styleInfo?.value; const styleSource = getStyleSource(styleInfo); // for TypeScript @@ -88,38 +84,39 @@ const Cell = ({ export const SpacingSection = ({ setProperty, deleteProperty, + createBatchUpdate, currentStyle, }: RenderCategoryProps) => { const [hoverTarget, setHoverTarget] = useState(); - const handleChange: StyleChangeHandler = (update, options) => { - if (update.operation === "set") { - setProperty(update.property)(update.value, options); - } else { - deleteProperty(update.property, options); - } - }; - const scrubStatus = useScrub({ value: hoverTarget === undefined ? undefined : currentStyle[hoverTarget.property]?.value, target: hoverTarget, - onChange: handleChange, + onChange: (values, options) => { + const batch = createBatchUpdate(); + for (const property of spacingPropertiesNames) { + const value = values[property]; + if (value !== undefined) { + batch.setProperty(property)(value); + } + } + batch.publish(options); + }, }); const [openProperty, setOpenProperty] = useState(); - const activeProperty = scrubStatus.isActive - ? scrubStatus.property - : openProperty ?? hoverTarget?.property; + const activeProperties = + openProperty === undefined ? scrubStatus.properties : [openProperty]; return ( setOpenProperty(hoverTarget?.property)} onHover={setHoverTarget} - activeProperty={activeProperty} + activeProperties={activeProperties} renderCell={({ property }) => ( { + if (update.operation === "set") { + setProperty(update.property)(update.value, options); + } else { + deleteProperty(update.property, options); + } + }} onHover={setHoverTarget} property={property} - scrubStatus={ - scrubStatus.isActive && scrubStatus.property === property - ? scrubStatus - : { isActive: false } - } - isActive={activeProperty === property} + scrubStatus={scrubStatus} + isActive={activeProperties.includes(property)} currentStyle={currentStyle} /> )} diff --git a/apps/designer/app/designer/features/style-panel/sections/spacing/types.ts b/apps/designer/app/designer/features/style-panel/sections/spacing/types.ts index 7988ad6918..a33cad36e6 100644 --- a/apps/designer/app/designer/features/style-panel/sections/spacing/types.ts +++ b/apps/designer/app/designer/features/style-panel/sections/spacing/types.ts @@ -1,6 +1,4 @@ -import { StyleUpdate } from "@webstudio-is/project"; import { categories } from "@webstudio-is/react-sdk"; -import { StyleUpdateOptions } from "../../shared/use-style-data"; export const spacingPropertiesNames = categories.spacing.properties; @@ -10,8 +8,3 @@ export type HoverTagret = { property: SpacingStyleProperty; element: SVGElement | HTMLElement; }; - -export type StyleChangeHandler = ( - update: StyleUpdate, - options: StyleUpdateOptions -) => void; diff --git a/apps/designer/app/designer/features/style-panel/shared/modifier-keys.ts b/apps/designer/app/designer/features/style-panel/shared/modifier-keys.ts new file mode 100644 index 0000000000..daa7c5e72e --- /dev/null +++ b/apps/designer/app/designer/features/style-panel/shared/modifier-keys.ts @@ -0,0 +1,32 @@ +import { useState, useEffect } from "react"; + +// Used for combined mouse and keyboard interactions, like scrubbing while holding ALT. +// If it's just a keyboard interaction, you should already have a keyboard event at hand. +export const useModifierKeys = () => { + const [state, setState] = useState({ + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + }); + + useEffect(() => { + const handler = (event: KeyboardEvent) => + setState({ + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }); + + window.addEventListener("keydown", handler); + window.addEventListener("keyup", handler); + + return () => { + window.removeEventListener("keydown", handler); + window.removeEventListener("keyup", handler); + }; + }, []); + + return state; +}; diff --git a/apps/designer/app/designer/features/style-panel/shared/use-style-data.ts b/apps/designer/app/designer/features/style-panel/shared/use-style-data.ts index d718063864..710cbadaaf 100644 --- a/apps/designer/app/designer/features/style-panel/shared/use-style-data.ts +++ b/apps/designer/app/designer/features/style-panel/shared/use-style-data.ts @@ -38,7 +38,7 @@ export type CreateBatchUpdate = () => { property: StyleProperty ) => (style: string | StyleValue) => void; deleteProperty: (property: StyleProperty) => void; - publish: () => void; + publish: (options?: StyleUpdateOptions) => void; }; export const useStyleData = ({ @@ -174,27 +174,31 @@ export const useStyleData = ({ updates.push({ operation: "delete", property }); }; - const publish = () => { + const publish = (options = { isEphemeral: false }) => { if (!updates.length) { return; } - publishUpdates("update", updates); - setBreakpointStyle((prevStyle) => { - const nextStyle = updates.reduce( - (reduceStyle, update) => { - if (update.operation === "delete") { - delete reduceStyle[update.property]; - } - if (update.operation === "set") { - reduceStyle[update.property] = update.value; - } - return reduceStyle; - }, - { ...prevStyle } - ); - updates = []; - return nextStyle; - }); + const type = options.isEphemeral ? "preview" : "update"; + publishUpdates(type, updates); + + if (options.isEphemeral === false) { + setBreakpointStyle((prevStyle) => { + const nextStyle = updates.reduce( + (reduceStyle, update) => { + if (update.operation === "delete") { + delete reduceStyle[update.property]; + } + if (update.operation === "set") { + reduceStyle[update.property] = update.value; + } + return reduceStyle; + }, + { ...prevStyle } + ); + updates = []; + return nextStyle; + }); + } }; return {