mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-07 12:33:36 +09:00
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:
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 {
|
||||
|
||||
+2
-4
@@ -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()}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
);
|
||||
|
||||
+2
-3
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user