Skip to content

Commit 5fa6c03

Browse files
authored
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
1 parent a09d12a commit 5fa6c03

File tree

5 files changed

+153
-105
lines changed

5 files changed

+153
-105
lines changed

apps/builder/app/builder/features/style-panel/shared/css-value-input/value-editor-dialog.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export const ValueEditorDialog = ({
9292
title="CSS Value"
9393
placement="bottom"
9494
height={200}
95-
width={Number(rawTheme.sizes.sidebarWidth)}
95+
width={Number.parseFloat(rawTheme.sizes.sidebarWidth)}
9696
content={
9797
<CssFragmentEditorContent
9898
autoFocus

packages/css-data/src/property-parsers/linear-gradient.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ const mapPercenTageOrDimentionToUnit = (
131131

132132
return {
133133
type: "unit",
134-
value: parseFloat(node.value),
134+
value: Number.parseFloat(node.value),
135135
unit: node.type === "Percentage" ? "%" : (node.unit as Unit),
136136
};
137137
};

packages/design-system/src/components/dialog.tsx

+127-87
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
createContext,
99
useContext,
1010
useState,
11+
useEffect,
12+
useCallback,
13+
type RefObject,
1114
} from "react";
1215
import * as Primitive from "@radix-ui/react-dialog";
1316
import { css, theme, type CSS } from "../stitches.config";
@@ -120,30 +123,87 @@ type Point = { x: number; y: number };
120123
type Size = { width: number; height: number };
121124
type Rect = Point & Size;
122125

126+
const centeredContent = {
127+
top: "50%",
128+
left: "50%",
129+
transform: "translate(-50%, -50%)",
130+
width: "100vw",
131+
height: "100vh",
132+
} as const;
133+
123134
type UseDraggableProps = {
124-
isMaximized?: boolean;
135+
isMaximized: boolean;
125136
minWidth?: number;
126137
minHeight?: number;
127138
} & Partial<Rect>;
128139

129140
const useDraggable = ({
130-
x,
131-
y,
132141
width,
133142
height,
134143
minHeight,
135144
minWidth,
145+
isMaximized,
146+
...props
136147
}: UseDraggableProps) => {
137-
const { isMaximized } = useContext(DialogContext);
138-
const initialDataRef = useRef<
148+
const [x, setX] = useState(props.x);
149+
const [y, setY] = useState(props.y);
150+
151+
const lastDragDataRef = useRef<
139152
| undefined
140153
| {
141154
point: Point;
142155
rect: Rect;
143156
}
144157
>(undefined);
158+
145159
const ref = useRef<HTMLDivElement | null>(null);
146160

161+
const calcStyle = useCallback(() => {
162+
const style: CSSProperties = isMaximized
163+
? centeredContent
164+
: {
165+
...centeredContent,
166+
width,
167+
height,
168+
};
169+
170+
if (minWidth !== undefined) {
171+
style.minWidth = minWidth;
172+
}
173+
if (minHeight !== undefined) {
174+
style.minHeight = minHeight;
175+
}
176+
177+
if (isMaximized === false) {
178+
if (x !== undefined) {
179+
style.left = x;
180+
style.transform = "none";
181+
}
182+
if (y !== undefined) {
183+
style.top = y;
184+
style.transform = "none";
185+
}
186+
}
187+
return style;
188+
}, [x, y, width, height, isMaximized, minWidth, minHeight]);
189+
190+
const [style, setStyle] = useState(calcStyle());
191+
192+
useEffect(() => {
193+
setStyle(calcStyle());
194+
}, [calcStyle]);
195+
196+
useEffect(() => {
197+
if (lastDragDataRef.current) {
198+
// Until user draggs, we need component props to define the position, because floating panel needs to adjust it after rendering.
199+
// We don't want to use the props x/y value after user has dragged manually. At this point position is defined
200+
// by drag interaction and props can't override it, otherwise position will jump for unpredictable reasons, e.g. when parent decides to update.
201+
return;
202+
}
203+
setX(props.x);
204+
setY(props.y);
205+
}, [props.x, props.y]);
206+
147207
const handleDragStart: DragEventHandler = (event) => {
148208
const target = ref.current;
149209
if (target === null) {
@@ -157,7 +217,7 @@ const useDraggable = ({
157217
target.style.left = `${rect.x}px`;
158218
target.style.top = `${rect.y}px`;
159219
target.style.transform = "none";
160-
initialDataRef.current = {
220+
lastDragDataRef.current = {
161221
point: { x: event.pageX, y: event.pageY },
162222
rect,
163223
};
@@ -169,12 +229,12 @@ const useDraggable = ({
169229
if (
170230
event.pageX <= 0 ||
171231
event.pageY <= 0 ||
172-
initialDataRef.current === undefined ||
232+
lastDragDataRef.current === undefined ||
173233
target === null
174234
) {
175235
return;
176236
}
177-
const { rect, point } = initialDataRef.current;
237+
const { rect, point } = lastDragDataRef.current;
178238
const movementX = point.x - event.pageX;
179239
const movementY = point.y - event.pageY;
180240
let left = Math.max(rect.x - movementX, 0);
@@ -186,80 +246,49 @@ const useDraggable = ({
186246
target.style.top = `${top}px`;
187247
};
188248

189-
const style: CSSProperties = isMaximized
190-
? {
191-
...centeredContent,
192-
width: "100vw",
193-
height: "100vh",
194-
}
195-
: {
196-
...centeredContent,
197-
width,
198-
height,
199-
};
200-
201-
if (minWidth !== undefined) {
202-
style.minWidth = minWidth;
203-
}
204-
if (minHeight !== undefined) {
205-
style.minHeight = minHeight;
206-
}
207-
if (isMaximized === false) {
208-
if (x !== undefined) {
209-
style.left = x;
210-
delete style.transform;
211-
}
212-
if (y !== undefined) {
213-
style.top = y;
214-
delete style.transform;
249+
const handleDragEnd: DragEventHandler = () => {
250+
const target = ref.current;
251+
if (target === null) {
252+
return;
215253
}
216-
}
254+
const rect = target.getBoundingClientRect();
255+
setX(rect.x);
256+
setY(rect.y);
257+
};
258+
217259
return {
218260
onDragStart: handleDragStart,
219261
onDrag: handleDrag,
262+
onDragEnd: handleDragEnd,
220263
style,
221264
ref,
222265
};
223266
};
224267

225268
// This is needed to prevent pointer events on the iframe from interfering with dragging and resizing.
226-
const useSetPointerEvents = () => {
269+
const useSetPointerEvents = (elementRef: RefObject<HTMLElement | null>) => {
227270
const { enableCanvasPointerEvents, disableCanvasPointerEvents } =
228271
useDisableCanvasPointerEvents();
229272

230-
const setPointerEvents = (value: string) => {
231-
return () => {
232-
value === "none"
233-
? disableCanvasPointerEvents()
234-
: enableCanvasPointerEvents();
235-
// RAF is needed otherwise dragstart event won't fire because of pointer-events: none
236-
requestAnimationFrame(() => {
237-
if (element) {
238-
element.style.pointerEvents = value;
239-
}
240-
});
241-
};
242-
};
243-
244-
const [element, ref] = useResize({
245-
onResizeStart: setPointerEvents("none"),
246-
onResizeEnd: setPointerEvents("auto"),
247-
});
248-
249-
const { resize, draggable } = useContext(DialogContext);
250-
251-
if (resize === "none" && draggable !== true) {
252-
return {};
253-
}
254-
255-
return {
256-
ref,
257-
onDragStartCapture: setPointerEvents("none"),
258-
onDragEndCapture: setPointerEvents("auto"),
259-
};
273+
return useCallback(
274+
(value: string) => {
275+
return () => {
276+
value === "none"
277+
? disableCanvasPointerEvents()
278+
: enableCanvasPointerEvents();
279+
// RAF is needed otherwise dragstart event won't fire because of pointer-events: none
280+
requestAnimationFrame(() => {
281+
if (elementRef.current) {
282+
elementRef.current.style.pointerEvents = value;
283+
}
284+
});
285+
};
286+
},
287+
[elementRef, enableCanvasPointerEvents, disableCanvasPointerEvents]
288+
);
260289
};
261290

262-
export const DialogContent = forwardRef(
291+
const ContentContainer = forwardRef(
263292
(
264293
{
265294
children,
@@ -273,36 +302,53 @@ export const DialogContent = forwardRef(
273302
minHeight,
274303
...props
275304
}: ComponentProps<typeof Primitive.Content> &
276-
UseDraggableProps & {
305+
Partial<UseDraggableProps> & {
277306
css?: CSS;
278307
},
279308
forwardedRef: Ref<HTMLDivElement>
280309
) => {
281-
const { resize } = useContext(DialogContext);
282-
const { ref: draggableRef, ...draggableProps } = useDraggable({
310+
const { resize, isMaximized } = useContext(DialogContext);
311+
const { ref, ...draggableProps } = useDraggable({
283312
width,
284313
height,
285314
x,
286315
y,
287316
minWidth,
288317
minHeight,
318+
isMaximized,
319+
});
320+
const setPointerEvents = useSetPointerEvents(ref);
321+
322+
const [_, setElement] = useResize({
323+
onResizeStart: setPointerEvents?.("none"),
324+
onResizeEnd: setPointerEvents?.("auto"),
289325
});
290326

291-
const { ref: pointerEventsRef, ...pointerEventsProps } =
292-
useSetPointerEvents();
327+
return (
328+
<Primitive.Content
329+
className={contentStyle({ className, css, resize })}
330+
onDragStartCapture={setPointerEvents("none")}
331+
onDragEndCapture={setPointerEvents("auto")}
332+
{...draggableProps}
333+
{...props}
334+
ref={mergeRefs(forwardedRef, ref, setElement)}
335+
>
336+
{children}
337+
</Primitive.Content>
338+
);
339+
}
340+
);
341+
ContentContainer.displayName = "ContentContainer";
293342

343+
export const DialogContent = forwardRef(
344+
(
345+
props: ComponentProps<typeof ContentContainer>,
346+
forwardedRef: Ref<HTMLDivElement>
347+
) => {
294348
return (
295349
<Primitive.Portal>
296350
<Primitive.Overlay className={overlayStyle()} />
297-
<Primitive.Content
298-
className={contentStyle({ className, css, resize })}
299-
{...draggableProps}
300-
{...pointerEventsProps}
301-
{...props}
302-
ref={mergeRefs(forwardedRef, draggableRef, pointerEventsRef)}
303-
>
304-
{children}
305-
</Primitive.Content>
351+
<ContentContainer {...props} ref={forwardedRef} />
306352
</Primitive.Portal>
307353
);
308354
}
@@ -378,12 +424,6 @@ const overlayStyle = css({
378424
inset: 0,
379425
});
380426

381-
const centeredContent: CSSProperties = {
382-
top: "50%",
383-
left: "50%",
384-
transform: "translate(-50%, -50%)",
385-
};
386-
387427
const contentStyle = css(panelStyle, {
388428
position: "fixed",
389429
width: "min-content",

packages/design-system/src/components/floating-panel.tsx

+17-9
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,18 @@ export const FloatingPanel = ({
8686
null
8787
);
8888
const triggerRef = useRef<HTMLButtonElement>(null);
89-
const [x, setX] = useState<number>();
90-
const [y, setY] = useState<number>();
89+
const [position, setPosition] = useState<{ x: number; y: number }>();
90+
const positionIsSetRef = useRef(false);
9191

9292
const calcPosition = useCallback(() => {
9393
if (
9494
triggerRef.current === null ||
9595
containerRef.current === null ||
9696
contentElement === null ||
9797
// When centering the dialog, we don't need to calculate the position
98-
placement === "center"
98+
placement === "center" ||
99+
// After we positioned it once, we leave it alone to avoid jumps when user is scrolling the trigger
100+
positionIsSetRef.current
99101
) {
100102
return;
101103
}
@@ -125,11 +127,18 @@ export const FloatingPanel = ({
125127
placement === "bottom" && flip(),
126128
offset(offsetProp),
127129
],
128-
}).then(({ x, y }) => {
129-
setX(x);
130-
setY(y);
130+
}).then((position) => {
131+
setPosition(position);
132+
positionIsSetRef.current = true;
131133
});
132-
}, [contentElement, triggerRef, containerRef, placement, offsetProp]);
134+
}, [
135+
positionIsSetRef,
136+
contentElement,
137+
triggerRef,
138+
containerRef,
139+
placement,
140+
offsetProp,
141+
]);
133142

134143
useLayoutEffect(calcPosition, [calcPosition]);
135144

@@ -148,8 +157,7 @@ export const FloatingPanel = ({
148157
className={contentStyle()}
149158
width={width}
150159
height={height}
151-
x={x}
152-
y={y}
160+
{...position}
153161
onInteractOutside={(event) => {
154162
// When a dialog is centered, we don't want to close it when clicking outside
155163
// This allows having inline and left positioned dialogs open at the same time as a centered dialog,

0 commit comments

Comments
 (0)