Refactor canvas resize to use scrub (#1469)

## Description

Now user can keep resizing outside of the window boundary

Also:
- fixes https://github.com/webstudio-is/webstudio-builder/issues/1465

## Steps for reproduction

1. start resizing, keep going outside of window


## Code Review

- [ ] hi @istarkov , I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio-builder/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env.example`
and the `builder/env-check.js` if mandatory
This commit is contained in:
Oleg Isonen authored and GitHub committed 2023-04-17 11:57:53 +02:00
1 parent a148507e48
commit 705d7c00fb
10 files changed
+139 -116

No files matched your search

@@ -35,7 +35,7 @@ export const TriggerButton = (props: TriggerButtonProps) => {
const scale = useStore(scaleStore);
const breakpoint = useStore(selectedBreakpointStore);
const [canvasWidth] = useCanvasWidth();
if (breakpoint === undefined) {
if (breakpoint === undefined || canvasWidth === undefined) {
return null;
}
return (
@@ -46,7 +46,7 @@ export const TriggerButton = (props: TriggerButtonProps) => {
})}
>
<Value unit="PX" minWidth={55}>
{canvasWidth}
{Math.round(canvasWidth)}
</Value>
{scale !== 100 && (
<Value unit="%" minWidth={30}>
@@ -77,12 +77,12 @@ export const useScrub = (props: {
setValues(isEphemeral ? nextValues : {});
};
const scrub = numericScrubControl(finalTarget.element, {
return numericScrubControl(finalTarget.element, {
direction:
property.endsWith("Left") || property.endsWith("Right")
? "horizontal"
: "vertical",
getValue() {
getInitialValue() {
const { value } = nonDependencies.current.props;
if (value?.type === "unit") {
unitRef.current = value.unit;
@@ -103,8 +103,6 @@ export const useScrub = (props: {
);
},
});
return scrub.disconnectedCallback;
}, [finalTarget]);
return {
@@ -85,7 +85,7 @@ const useScrub = ({
return;
}
const scrub = numericScrubControl(scrubRefCurrent, {
return numericScrubControl(scrubRefCurrent, {
// @todo: after this https://github.com/webstudio-is/webstudio-builder/issues/564
// we can switch back on using just initial value
//
@@ -95,7 +95,7 @@ const useScrub = ({
// Until we have decision do we use key properties for this or not,
// on of the solution to get value inside scrub is to use ref and lazy getter.
// Getter to avoid recreating scrub on every value change
getValue: () => {
getInitialValue: () => {
if (valueRef.current.type === "unit") {
return valueRef.current.value;
}
@@ -130,8 +130,6 @@ const useScrub = ({
},
shouldHandleEvent: shouldHandleEvent,
});
return scrub.disconnectedCallback;
}, [type, unit, shouldHandleEvent]);
return [scrubRef, inputRef];
@@ -60,16 +60,13 @@ export const CanvasTools = ({ publish }: CanvasToolsProps) => {
) : null;
}
if (isPreviewMode) {
return null;
}
return (
<Box css={toolsStyle}>
<MediaBadge />
<SelectedInstanceOutline />
<HoveredInstanceOutline />
<TextToolbar publish={publish} />
<ResizeHandles />
{isPreviewMode === false && <SelectedInstanceOutline />}
{isPreviewMode === false && <HoveredInstanceOutline />}
{isPreviewMode === false && <TextToolbar publish={publish} />}
</Box>
);
};
@@ -1,9 +1,8 @@
import { useStore } from "@nanostores/react";
import { findApplicableMedia } from "@webstudio-is/css-engine";
import { css, theme, useDrag } from "@webstudio-is/design-system";
import { useState } from "react";
import { css, numericScrubControl, theme } from "@webstudio-is/design-system";
import { useEffect, useRef, useState } from "react";
import {
canvasRectStore,
canvasWidthStore,
isCanvasPointerEventsEnabledStore,
} from "~/builder/shared/nano-states";
@@ -78,59 +77,64 @@ const handleIcon = (
</svg>
);
const baseDragProps = {
startDistanceThreashold: 0,
elementToData: () => true,
onStart() {
isCanvasPointerEventsEnabledStore.set(false);
isResizingCanvasStore.set(true);
},
onEnd() {
isCanvasPointerEventsEnabledStore.set(true);
isResizingCanvasStore.set(false);
},
const updateBreakpoint = (width: number) => {
const applicableBreakpoint = findApplicableMedia(
Array.from(breakpointsStore.get().values()),
width
);
if (applicableBreakpoint) {
selectedBreakpointIdStore.set(applicableBreakpoint.id);
}
};
const useScrub = ({ side }: { side: "right" | "left" }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current === null) {
return;
}
return numericScrubControl(ref.current, {
getInitialValue() {
return canvasWidthStore.get();
},
getValue(state, movement) {
const value =
side === "left"
? // * 2 is a compensation for the fact that canvas is centered, so when we scrub, width has to change twice faster,
// otherwise cursor will be faster than the edge movement
state.value - movement * 2
: state.value + movement * 2;
return Math.max(value, minCanvasWidth);
},
onStatusChange(status) {
if (status === "scrubbing") {
isCanvasPointerEventsEnabledStore.set(false);
isResizingCanvasStore.set(true);
return;
}
isCanvasPointerEventsEnabledStore.set(true);
isResizingCanvasStore.set(false);
},
onValueInput(event) {
canvasWidthStore.set(event.value);
updateBreakpoint(event.value);
},
});
}, [side]);
return ref;
};
const useResize = () => {
const isDragging = useStore(isCanvasPointerEventsEnabledStore) === false;
const [isHovering, setIsHovering] = useState(false);
const updateBreakpoint = (width: number) => {
const applicableBreakpoint = findApplicableMedia(
Array.from(breakpointsStore.get().values()),
width
);
if (applicableBreakpoint) {
selectedBreakpointIdStore.set(applicableBreakpoint.id);
}
};
const { rootRef: leftRef } = useDrag({
...baseDragProps,
onMove({ x }) {
const rect = canvasRectStore.get();
if (rect) {
const width = Math.round(Math.max(rect.right - x, minCanvasWidth));
canvasWidthStore.set(width);
updateBreakpoint(width);
}
},
});
const { rootRef: rightRef } = useDrag({
...baseDragProps,
onMove({ x }) {
const rect = canvasRectStore.get();
if (rect) {
const width = Math.max(Math.round(x - rect.left), minCanvasWidth);
canvasWidthStore.set(width);
updateBreakpoint(width);
}
},
});
const leftRef = useScrub({ side: "left" });
const rightRef = useScrub({ side: "right" });
const handleMouseEnter = () => setIsHovering(true);
const handleMouseOut = () => setIsHovering(false);
const handleMouseLeave = () => setIsHovering(false);
const state = isDragging ? "dragging" : isHovering ? "hovering" : "idle";
@@ -139,12 +143,12 @@ const useResize = () => {
leftRef,
rightRef,
handleMouseEnter,
handleMouseOut,
handleMouseLeave,
};
};
export const ResizeHandles = () => {
const { state, leftRef, rightRef, handleMouseEnter, handleMouseOut } =
const { state, leftRef, rightRef, handleMouseEnter, handleMouseLeave } =
useResize();
return (
@@ -152,7 +156,7 @@ export const ResizeHandles = () => {
<div
ref={leftRef}
onMouseEnter={handleMouseEnter}
onMouseOut={handleMouseOut}
onMouseOut={handleMouseLeave}
data-state={state}
data-align="left"
className={handleStyle()}
@@ -160,7 +164,7 @@ export const ResizeHandles = () => {
<div
ref={rightRef}
onMouseEnter={handleMouseEnter}
onMouseOut={handleMouseOut}
onMouseLeave={handleMouseLeave}
data-state={state}
data-align="right"
className={handleStyle()}
@@ -16,7 +16,7 @@ const useContentController = ({
if (ref.current == null) {
return;
}
const { disconnectedCallback } = createContentController(ref.current, {
const { cleanup } = createContentController(ref.current, {
contents: [
{
name: "unit",
@@ -33,7 +33,7 @@ const useContentController = ({
onMouseMove: callback,
onCaretMove: callback,
});
return () => disconnectedCallback();
return cleanup;
}, [ref, read, write, callback]);
};
@@ -2,11 +2,11 @@
* @description
* - detects whether the pointer/keyboard is on any given token as specified by the {contents: []} contract.
* - pointermove/keyup in turn dispatch onMouseMove/onCaretMove with the current state if and when within a configured content token.
* - returns two methods {update, disconnectedCallback} the former of which dispatches a content update(while preserving the caret position if any)
* - returns two methods {update, cleanup} the former of which dispatches a content update(while preserving the caret position if any)
* - read/write methods are called when reading from the passed element or when updating/writing to the same element.
* @example
* const target = document.querySelector('input');
* const {update, disconnectedCallback} = createContentController(target, {
* const {update, cleanup} = createContentController(target, {
* contents: [
* { name: 'unit', match: (value) => value === 'px' },
* { name: 'number', match: (value) => Number.isNaN(parseFloat(value)) === false },
@@ -89,7 +89,7 @@ export const createContentController = (
}
): {
update: (value: string) => void;
disconnectedCallback: () => void;
cleanup: () => void;
} => {
const eventNames = ["keyup", "pointermove"] as const;
const handleEvent = (event: KeyboardEvent | PointerEvent): void => {
@@ -136,7 +136,7 @@ export const createContentController = (
write(targetNode, value);
targetNode.setSelectionRange(selectionStart, selectionEnd);
},
disconnectedCallback: () => {
cleanup: () => {
eventNames.forEach((eventName) =>
targetNode.removeEventListener(eventName, handleEvent, false)
);
@@ -19,8 +19,8 @@ const useNumericScrubControl = ({
return;
}
ref.current.value = String(value);
const { disconnectedCallback } = numericScrubControl(ref.current, {
getValue: () => value,
return numericScrubControl(ref.current, {
getInitialValue: () => value,
direction: direction,
onValueChange: (event) => {
event.preventDefault();
@@ -28,7 +28,6 @@ const useNumericScrubControl = ({
(event.target as HTMLInputElement).select();
},
});
return () => disconnectedCallback();
}, [direction, value, ref]);
};
@@ -25,9 +25,15 @@ export type NumericScrubCallback = (event: {
}) => void;
export type NumericScrubOptions = {
inverse?: boolean;
minValue?: NumericScrubValue;
maxValue?: NumericScrubValue;
getValue: () => number | undefined;
getInitialValue: () => number | undefined;
getValue?: (
state: NumericScrubState,
movement: number,
options: NumericScrubOptions
) => number;
direction?: NumericScrubDirection;
onValueInput?: NumericScrubCallback;
onValueChange?: NumericScrubCallback;
@@ -42,19 +48,37 @@ type NumericScrubState = {
timerId?: ReturnType<typeof window.setTimeout>;
};
export const numericScrubControl = (
targetNode: HTMLElement | SVGElement,
const getValueDefault = (
state: NumericScrubState,
movement: number,
{
minValue = Number.MIN_SAFE_INTEGER,
maxValue = Number.MAX_SAFE_INTEGER,
getValue,
}: NumericScrubOptions
) => {
const value = state.value + movement;
if (value < minValue) {
return minValue;
}
if (state.value > maxValue) {
return maxValue;
}
return value;
};
export const numericScrubControl = (
targetNode: HTMLElement | SVGElement,
options: NumericScrubOptions
) => {
const {
getInitialValue,
getValue = getValueDefault,
direction = "horizontal",
onValueInput,
onValueChange,
onStatusChange,
shouldHandleEvent,
}: NumericScrubOptions
) => {
} = options;
const eventNames = ["pointerup", "pointerdown"] as const;
const state: NumericScrubState = {
// We will read value lazyly in a moment it will be used to avoid having outdated value
@@ -66,6 +90,25 @@ export const numericScrubControl = (
let exitPointerLock: (() => void) | undefined = undefined;
let originalUserSelect = "";
const cleanup = () => {
targetNode.removeEventListener("pointermove", handleEvent);
onStatusChange?.("idle");
clearTimeout(state.timerId);
exitPointerLock?.();
exitPointerLock = undefined;
if (originalUserSelect) {
targetNode.ownerDocument.documentElement.style.userSelect =
originalUserSelect;
} else {
targetNode.ownerDocument.documentElement.style.removeProperty(
"user-select"
);
}
};
// Cannot define `event:` as PointerEvent,
// because (HTMLElement | SVGElement).addEventListener("pointermove", ...)
// takes (Event => void) as a callback
@@ -81,13 +124,7 @@ export const numericScrubControl = (
switch (type) {
case "pointerup": {
const shouldComponentUpdate = Boolean(state.cursor);
targetNode.removeEventListener("pointermove", handleEvent);
onStatusChange?.("idle");
clearTimeout(state.timerId);
exitPointerLock?.();
exitPointerLock = undefined;
cleanup();
if (shouldComponentUpdate) {
onValueChange?.({
target: targetNode,
@@ -108,11 +145,11 @@ export const numericScrubControl = (
if (event.pressure === 0 || event.button !== 0) {
break;
}
const value = getValue();
const value = getInitialValue();
// We don't support scrub on non unit values
// Its highly unlikely that the value here will be undefined, as useScrub tries to not create scrub on non unit values
// but having that we use lazy getValue() and vanilla js events it's possible.
// but having that we use lazy getInitialValue() and vanilla js events it's possible.
if (value === undefined) {
return;
}
@@ -127,17 +164,17 @@ export const numericScrubControl = (
onStatusChange?.("scrubbing");
targetNode.addEventListener("pointermove", handleEvent);
originalUserSelect =
targetNode.ownerDocument.documentElement.style.userSelect;
targetNode.ownerDocument.documentElement.style.userSelect = "none";
break;
}
case "pointermove": {
state.value += movement;
if (state.value < minValue) {
state.value = minValue;
} else if (state.value > maxValue) {
state.value = maxValue;
const nextValue = getValue(state, movement, options);
if (nextValue === state.value) {
break;
}
state.value = nextValue;
onValueInput?.({
target: targetNode,
value: state.value,
@@ -172,19 +209,11 @@ export const numericScrubControl = (
targetNode.addEventListener(eventName, handleEvent)
);
return {
disconnectedCallback: () => {
eventNames.forEach((eventName) =>
targetNode.removeEventListener(eventName, handleEvent)
);
clearTimeout(state.timerId);
targetNode.removeEventListener("pointermove", handleEvent);
onStatusChange?.("idle");
exitPointerLock?.();
exitPointerLock = undefined;
},
return () => {
eventNames.forEach((eventName) =>
targetNode.removeEventListener(eventName, handleEvent)
);
cleanup();
};
};
@@ -40,8 +40,8 @@ export const useScrub = ({
return;
}
const scrub = numericScrubControl(scrubRefCurrent, {
getValue: () => {
return numericScrubControl(scrubRefCurrent, {
getInitialValue: () => {
return valueRef.current;
},
onValueInput(event) {
@@ -66,8 +66,6 @@ export const useScrub = ({
},
shouldHandleEvent: shouldHandleEventRef.current,
});
return scrub.disconnectedCallback;
}, []);
return { scrubRef, inputRef };