mirror of
https://github.com/webstudio-is/webstudio.git
synced 2025-03-14 09:57:02 +00:00
fix: Floating panel and dialog bugs (#4708)
## Description - [x] fix maximize/minimize size/positioning - open, move, maximize, minimize - should be in the last position after moving - [x] fixes #4681 - [x] fixes #4684 ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, 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: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
@ -92,7 +92,7 @@ export const ValueEditorDialog = ({
|
||||
title="CSS Value"
|
||||
placement="bottom"
|
||||
height={200}
|
||||
width={Number(rawTheme.sizes.sidebarWidth)}
|
||||
width={Number.parseFloat(rawTheme.sizes.sidebarWidth)}
|
||||
content={
|
||||
<CssFragmentEditorContent
|
||||
autoFocus
|
||||
|
@ -131,7 +131,7 @@ const mapPercenTageOrDimentionToUnit = (
|
||||
|
||||
return {
|
||||
type: "unit",
|
||||
value: parseFloat(node.value),
|
||||
value: Number.parseFloat(node.value),
|
||||
unit: node.type === "Percentage" ? "%" : (node.unit as Unit),
|
||||
};
|
||||
};
|
||||
|
@ -8,6 +8,9 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import * as Primitive from "@radix-ui/react-dialog";
|
||||
import { css, theme, type CSS } from "../stitches.config";
|
||||
@ -120,30 +123,87 @@ type Point = { x: number; y: number };
|
||||
type Size = { width: number; height: number };
|
||||
type Rect = Point & Size;
|
||||
|
||||
const centeredContent = {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
} as const;
|
||||
|
||||
type UseDraggableProps = {
|
||||
isMaximized?: boolean;
|
||||
isMaximized: boolean;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
} & Partial<Rect>;
|
||||
|
||||
const useDraggable = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
minHeight,
|
||||
minWidth,
|
||||
isMaximized,
|
||||
...props
|
||||
}: UseDraggableProps) => {
|
||||
const { isMaximized } = useContext(DialogContext);
|
||||
const initialDataRef = useRef<
|
||||
const [x, setX] = useState(props.x);
|
||||
const [y, setY] = useState(props.y);
|
||||
|
||||
const lastDragDataRef = useRef<
|
||||
| undefined
|
||||
| {
|
||||
point: Point;
|
||||
rect: Rect;
|
||||
}
|
||||
>(undefined);
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const calcStyle = useCallback(() => {
|
||||
const style: CSSProperties = isMaximized
|
||||
? centeredContent
|
||||
: {
|
||||
...centeredContent,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
if (minWidth !== undefined) {
|
||||
style.minWidth = minWidth;
|
||||
}
|
||||
if (minHeight !== undefined) {
|
||||
style.minHeight = minHeight;
|
||||
}
|
||||
|
||||
if (isMaximized === false) {
|
||||
if (x !== undefined) {
|
||||
style.left = x;
|
||||
style.transform = "none";
|
||||
}
|
||||
if (y !== undefined) {
|
||||
style.top = y;
|
||||
style.transform = "none";
|
||||
}
|
||||
}
|
||||
return style;
|
||||
}, [x, y, width, height, isMaximized, minWidth, minHeight]);
|
||||
|
||||
const [style, setStyle] = useState(calcStyle());
|
||||
|
||||
useEffect(() => {
|
||||
setStyle(calcStyle());
|
||||
}, [calcStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastDragDataRef.current) {
|
||||
// Until user draggs, we need component props to define the position, because floating panel needs to adjust it after rendering.
|
||||
// We don't want to use the props x/y value after user has dragged manually. At this point position is defined
|
||||
// by drag interaction and props can't override it, otherwise position will jump for unpredictable reasons, e.g. when parent decides to update.
|
||||
return;
|
||||
}
|
||||
setX(props.x);
|
||||
setY(props.y);
|
||||
}, [props.x, props.y]);
|
||||
|
||||
const handleDragStart: DragEventHandler = (event) => {
|
||||
const target = ref.current;
|
||||
if (target === null) {
|
||||
@ -157,7 +217,7 @@ const useDraggable = ({
|
||||
target.style.left = `${rect.x}px`;
|
||||
target.style.top = `${rect.y}px`;
|
||||
target.style.transform = "none";
|
||||
initialDataRef.current = {
|
||||
lastDragDataRef.current = {
|
||||
point: { x: event.pageX, y: event.pageY },
|
||||
rect,
|
||||
};
|
||||
@ -169,12 +229,12 @@ const useDraggable = ({
|
||||
if (
|
||||
event.pageX <= 0 ||
|
||||
event.pageY <= 0 ||
|
||||
initialDataRef.current === undefined ||
|
||||
lastDragDataRef.current === undefined ||
|
||||
target === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { rect, point } = initialDataRef.current;
|
||||
const { rect, point } = lastDragDataRef.current;
|
||||
const movementX = point.x - event.pageX;
|
||||
const movementY = point.y - event.pageY;
|
||||
let left = Math.max(rect.x - movementX, 0);
|
||||
@ -186,80 +246,49 @@ const useDraggable = ({
|
||||
target.style.top = `${top}px`;
|
||||
};
|
||||
|
||||
const style: CSSProperties = isMaximized
|
||||
? {
|
||||
...centeredContent,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
}
|
||||
: {
|
||||
...centeredContent,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const handleDragEnd: DragEventHandler = () => {
|
||||
const target = ref.current;
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
setX(rect.x);
|
||||
setY(rect.y);
|
||||
};
|
||||
|
||||
if (minWidth !== undefined) {
|
||||
style.minWidth = minWidth;
|
||||
}
|
||||
if (minHeight !== undefined) {
|
||||
style.minHeight = minHeight;
|
||||
}
|
||||
if (isMaximized === false) {
|
||||
if (x !== undefined) {
|
||||
style.left = x;
|
||||
delete style.transform;
|
||||
}
|
||||
if (y !== undefined) {
|
||||
style.top = y;
|
||||
delete style.transform;
|
||||
}
|
||||
}
|
||||
return {
|
||||
onDragStart: handleDragStart,
|
||||
onDrag: handleDrag,
|
||||
onDragEnd: handleDragEnd,
|
||||
style,
|
||||
ref,
|
||||
};
|
||||
};
|
||||
|
||||
// This is needed to prevent pointer events on the iframe from interfering with dragging and resizing.
|
||||
const useSetPointerEvents = () => {
|
||||
const useSetPointerEvents = (elementRef: RefObject<HTMLElement | null>) => {
|
||||
const { enableCanvasPointerEvents, disableCanvasPointerEvents } =
|
||||
useDisableCanvasPointerEvents();
|
||||
|
||||
const setPointerEvents = (value: string) => {
|
||||
return () => {
|
||||
value === "none"
|
||||
? disableCanvasPointerEvents()
|
||||
: enableCanvasPointerEvents();
|
||||
// RAF is needed otherwise dragstart event won't fire because of pointer-events: none
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
element.style.pointerEvents = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const [element, ref] = useResize({
|
||||
onResizeStart: setPointerEvents("none"),
|
||||
onResizeEnd: setPointerEvents("auto"),
|
||||
});
|
||||
|
||||
const { resize, draggable } = useContext(DialogContext);
|
||||
|
||||
if (resize === "none" && draggable !== true) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ref,
|
||||
onDragStartCapture: setPointerEvents("none"),
|
||||
onDragEndCapture: setPointerEvents("auto"),
|
||||
};
|
||||
return useCallback(
|
||||
(value: string) => {
|
||||
return () => {
|
||||
value === "none"
|
||||
? disableCanvasPointerEvents()
|
||||
: enableCanvasPointerEvents();
|
||||
// RAF is needed otherwise dragstart event won't fire because of pointer-events: none
|
||||
requestAnimationFrame(() => {
|
||||
if (elementRef.current) {
|
||||
elementRef.current.style.pointerEvents = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
[elementRef, enableCanvasPointerEvents, disableCanvasPointerEvents]
|
||||
);
|
||||
};
|
||||
|
||||
export const DialogContent = forwardRef(
|
||||
const ContentContainer = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@ -273,36 +302,53 @@ export const DialogContent = forwardRef(
|
||||
minHeight,
|
||||
...props
|
||||
}: ComponentProps<typeof Primitive.Content> &
|
||||
UseDraggableProps & {
|
||||
Partial<UseDraggableProps> & {
|
||||
css?: CSS;
|
||||
},
|
||||
forwardedRef: Ref<HTMLDivElement>
|
||||
) => {
|
||||
const { resize } = useContext(DialogContext);
|
||||
const { ref: draggableRef, ...draggableProps } = useDraggable({
|
||||
const { resize, isMaximized } = useContext(DialogContext);
|
||||
const { ref, ...draggableProps } = useDraggable({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
minWidth,
|
||||
minHeight,
|
||||
isMaximized,
|
||||
});
|
||||
const setPointerEvents = useSetPointerEvents(ref);
|
||||
|
||||
const [_, setElement] = useResize({
|
||||
onResizeStart: setPointerEvents?.("none"),
|
||||
onResizeEnd: setPointerEvents?.("auto"),
|
||||
});
|
||||
|
||||
const { ref: pointerEventsRef, ...pointerEventsProps } =
|
||||
useSetPointerEvents();
|
||||
return (
|
||||
<Primitive.Content
|
||||
className={contentStyle({ className, css, resize })}
|
||||
onDragStartCapture={setPointerEvents("none")}
|
||||
onDragEndCapture={setPointerEvents("auto")}
|
||||
{...draggableProps}
|
||||
{...props}
|
||||
ref={mergeRefs(forwardedRef, ref, setElement)}
|
||||
>
|
||||
{children}
|
||||
</Primitive.Content>
|
||||
);
|
||||
}
|
||||
);
|
||||
ContentContainer.displayName = "ContentContainer";
|
||||
|
||||
export const DialogContent = forwardRef(
|
||||
(
|
||||
props: ComponentProps<typeof ContentContainer>,
|
||||
forwardedRef: Ref<HTMLDivElement>
|
||||
) => {
|
||||
return (
|
||||
<Primitive.Portal>
|
||||
<Primitive.Overlay className={overlayStyle()} />
|
||||
<Primitive.Content
|
||||
className={contentStyle({ className, css, resize })}
|
||||
{...draggableProps}
|
||||
{...pointerEventsProps}
|
||||
{...props}
|
||||
ref={mergeRefs(forwardedRef, draggableRef, pointerEventsRef)}
|
||||
>
|
||||
{children}
|
||||
</Primitive.Content>
|
||||
<ContentContainer {...props} ref={forwardedRef} />
|
||||
</Primitive.Portal>
|
||||
);
|
||||
}
|
||||
@ -378,12 +424,6 @@ const overlayStyle = css({
|
||||
inset: 0,
|
||||
});
|
||||
|
||||
const centeredContent: CSSProperties = {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
};
|
||||
|
||||
const contentStyle = css(panelStyle, {
|
||||
position: "fixed",
|
||||
width: "min-content",
|
||||
|
@ -86,8 +86,8 @@ export const FloatingPanel = ({
|
||||
null
|
||||
);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [x, setX] = useState<number>();
|
||||
const [y, setY] = useState<number>();
|
||||
const [position, setPosition] = useState<{ x: number; y: number }>();
|
||||
const positionIsSetRef = useRef(false);
|
||||
|
||||
const calcPosition = useCallback(() => {
|
||||
if (
|
||||
@ -95,7 +95,9 @@ export const FloatingPanel = ({
|
||||
containerRef.current === null ||
|
||||
contentElement === null ||
|
||||
// When centering the dialog, we don't need to calculate the position
|
||||
placement === "center"
|
||||
placement === "center" ||
|
||||
// After we positioned it once, we leave it alone to avoid jumps when user is scrolling the trigger
|
||||
positionIsSetRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@ -125,11 +127,18 @@ export const FloatingPanel = ({
|
||||
placement === "bottom" && flip(),
|
||||
offset(offsetProp),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
setX(x);
|
||||
setY(y);
|
||||
}).then((position) => {
|
||||
setPosition(position);
|
||||
positionIsSetRef.current = true;
|
||||
});
|
||||
}, [contentElement, triggerRef, containerRef, placement, offsetProp]);
|
||||
}, [
|
||||
positionIsSetRef,
|
||||
contentElement,
|
||||
triggerRef,
|
||||
containerRef,
|
||||
placement,
|
||||
offsetProp,
|
||||
]);
|
||||
|
||||
useLayoutEffect(calcPosition, [calcPosition]);
|
||||
|
||||
@ -148,8 +157,7 @@ export const FloatingPanel = ({
|
||||
className={contentStyle()}
|
||||
width={width}
|
||||
height={height}
|
||||
x={x}
|
||||
y={y}
|
||||
{...position}
|
||||
onInteractOutside={(event) => {
|
||||
// When a dialog is centered, we don't want to close it when clicking outside
|
||||
// This allows having inline and left positioned dialogs open at the same time as a centered dialog,
|
||||
|
@ -94,9 +94,9 @@ export const useResize = ({
|
||||
onResizeEnd,
|
||||
timeout = 300,
|
||||
}: {
|
||||
onResizeStart?: () => void;
|
||||
onResize?: () => void;
|
||||
onResizeEnd?: () => void;
|
||||
onResizeStart?: (entries: Array<ResizeObserverEntry>) => void;
|
||||
onResize?: (entries: Array<ResizeObserverEntry>) => void;
|
||||
onResizeEnd?: (entries: Array<ResizeObserverEntry>) => void;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
const [element, ref] = useState<HTMLElement | null>(null);
|
||||
@ -115,7 +115,7 @@ export const useResize = ({
|
||||
}
|
||||
// Mark resizing as on a new observer instance, we will use this to skip first resize event.
|
||||
isResizingRef.current = undefined;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
// Resize observer called first time is not a start of resize
|
||||
if (isResizingRef.current === undefined) {
|
||||
isResizingRef.current = false;
|
||||
@ -123,12 +123,12 @@ export const useResize = ({
|
||||
}
|
||||
if (isResizingRef.current === false) {
|
||||
isResizingRef.current = true;
|
||||
onResizeStartRef.current?.();
|
||||
onResizeStartRef.current?.(entries);
|
||||
}
|
||||
onResizeRef.current?.();
|
||||
onResizeRef.current?.(entries);
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onResizeEndRef.current?.();
|
||||
onResizeEndRef.current?.(entries);
|
||||
isResizingRef.current = false;
|
||||
}, timeout);
|
||||
});
|
||||
|
Reference in New Issue
Block a user