Support option/shift in spacing's scrub (#717)

## Description

Adds option / shift support to scrub in spacing. Closes #668

## Steps for reproduction

- Try to scrub in spacing while holding SHIFT or OPTION (ALT in Windows)
- Try to press and release SHIFT / OPTION multiple times during scrub
- Try to press SHIFT / OPTION while hovering over a property (other
properties will highlight even without scrub)
This commit is contained in:
Roman Pominov authored and GitHub committed 2022-12-30 13:30:49 +03:00
1 parent 4817778649
commit 0d4a3ef0ed
7 files changed
+186 -115

No files matched your search

@@ -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<typeof Input>["onChange"];
isOpen: boolean;
onClose: () => void;
}) => {
@@ -223,14 +223,14 @@ const getPath = (property: SpacingStyleProperty) => {
type LayoutProps = {
onClick: () => void;
onHover: (hoverTarget: HoverTagret | undefined) => void;
activeProperty?: SpacingStyleProperty;
activeProperties?: ReadonlyArray<SpacingStyleProperty>;
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)}
/>
);
@@ -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<Record<SpacingStyleProperty, StyleValue>>;
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<SpacingStyleProperty>;
// 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<HoverTagret>();
const finalTarget = activeTarget ?? props.target;
const [value, setValue] = useState<UnitValue>();
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<Values>({});
// 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<UnitValue["unit"]>();
@@ -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<ReadonlyArray<SpacingStyleProperty>> = [];
if (modifiers.shiftKey) {
groups = circleGroups;
} else if (modifiers.altKey) {
groups = opposingGroups;
}
return groups.find((group) => group.includes(property)) ?? [property];
};
@@ -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<typeof InputPopover>["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<HoverTagret>();
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<SpacingStyleProperty>();
const activeProperty = scrubStatus.isActive
? scrubStatus.property
: openProperty ?? hoverTarget?.property;
const activeProperties =
openProperty === undefined ? scrubStatus.properties : [openProperty];
return (
<SpacingLayout
onClick={() => setOpenProperty(hoverTarget?.property)}
onHover={setHoverTarget}
activeProperty={activeProperty}
activeProperties={activeProperties}
renderCell={({ property }) => (
<Cell
isPopoverOpen={openProperty === property}
@@ -128,15 +125,17 @@ export const SpacingSection = ({
setOpenProperty(undefined);
}
}}
onChange={handleChange}
onChange={(update, options) => {
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}
/>
)}
@@ -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;
@@ -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;
};
@@ -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 {