mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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:
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 {
|
||||
|
||||
Reference in new issue
Block a user