fix: enable iframes inside dialogs when using draggable/resizable (#4655)

When dialog is used with iframe, we need to make sure we are not getting
stuck when hovering over iframe content during drag-resize

Also fixes https://github.com/webstudio-is/webstudio/issues/4639 -
dragging is now only with title area

## Steps for reproduction

1. open help popover and then Support Hub
2. try dragging and resizing towards middle jumping over iframe with the
mouse

## 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:
Oleg Isonen authored and GitHub committed 2024-12-26 15:58:20 +01:00
1 parent 5ee696cbbf
commit 21b18095c3
9 files changed
+184 -68

No files matched your search

@@ -13,7 +13,6 @@ import { ExternalLinkIcon } from "@webstudio-is/icons";
export const RemoteDialog = () => {
const options = useStore($remoteDialog);
if (options === undefined) {
return;
}
@@ -25,11 +24,16 @@ export const RemoteDialog = () => {
$remoteDialog.set(undefined);
}}
modal={false}
draggable
resize="auto"
>
<DialogContent draggable resize="auto" width={640} height={640}>
<DialogContent width={640} height={640}>
<iframe
src={options.url}
style={{ border: 0, height: "100%" }}
style={{
border: 0,
height: "100%",
}}
></iframe>
{/* Title is at the end intentionally,
* to make the close button last in the tab order
@@ -41,9 +41,12 @@ export const ProjectSettingsView = ({
const isDesignMode = useStore($isDesignMode);
return (
<Dialog open={sections.has(currentSection!)} onOpenChange={onOpenChange}>
<Dialog
draggable
open={sections.has(currentSection!)}
onOpenChange={onOpenChange}
>
<DialogContent
draggable
css={{
width: `calc(${leftPanelWidth} + ${rightPanelWidth})`,
maxWidth: "none",
@@ -390,7 +390,7 @@ export const RepeatedStyle = (props: {
>
<CssValueListItem
id={id}
draggable={true}
draggable
active={dragItemId === id}
index={index}
label={<Label truncate>{itemLabel}</Label>}
@@ -142,22 +142,12 @@ const useScrub = ({ side }: { side: "right" | "left" }) => {
return ref;
};
const useResize = () => {
export const ResizeHandles = () => {
const isResizing = useStore($isResizingCanvas);
const leftRef = useScrub({ side: "left" });
const rightRef = useScrub({ side: "right" });
const state = isResizing ? "resizing" : "idle";
return {
state,
leftRef,
rightRef,
};
};
export const ResizeHandles = () => {
const { state, leftRef, rightRef } = useResize();
return (
<>
<div
@@ -56,7 +56,7 @@ const ListItem = (props: {
}
thumbnail={<Thumbnail />}
hidden={props.hidden}
draggable={true}
draggable
state={props.state}
focused={props.focused}
active={props.active}
@@ -13,12 +13,13 @@ import * as Primitive from "@radix-ui/react-dialog";
import { css, theme, type CSS } from "../stitches.config";
import { PanelTitle } from "./panel-title";
import { Flex } from "./flex";
import { useDisableCanvasPointerEvents } from "../utilities";
import { useDisableCanvasPointerEvents, useResize } from "../utilities";
import type { CSSProperties } from "@stitches/react";
import { mergeRefs } from "@react-aria/utils";
import { Button } from "./button";
import { XIcon, MaximizeIcon, MinimizeIcon } from "@webstudio-is/icons";
import { Separator } from "./separator";
import { Text } from "./text";
export const DialogTrigger = Primitive.Trigger;
@@ -51,15 +52,28 @@ const panelStyle = css({
const DialogContext = createContext<{
isMaximized: boolean;
setIsMaximized: (isMaximized: boolean) => void;
resize?: "auto" | "none";
draggable?: boolean;
}>({
isMaximized: false,
setIsMaximized: () => {},
draggable: true,
resize: "none",
});
export const Dialog = (props: ComponentProps<typeof Primitive.Dialog>) => {
export const Dialog = ({
resize,
draggable,
...props
}: ComponentProps<typeof Primitive.Dialog> & {
resize?: "auto" | "none";
draggable?: boolean;
}) => {
const [isMaximized, setIsMaximized] = useState(false);
return (
<DialogContext.Provider value={{ isMaximized, setIsMaximized }}>
<DialogContext.Provider
value={{ isMaximized, setIsMaximized, resize, draggable }}
>
<Primitive.Dialog {...props} />
</DialogContext.Provider>
);
@@ -128,16 +142,13 @@ const useDraggable = ({
rect: Rect;
}
>(undefined);
const { enableCanvasPointerEvents, disableCanvasPointerEvents } =
useDisableCanvasPointerEvents();
const draggableRef = useRef<HTMLDivElement | null>(null);
const ref = useRef<HTMLDivElement | null>(null);
const handleDragStart: DragEventHandler = (event) => {
const target = draggableRef.current;
const target = ref.current;
if (target === null) {
return;
}
disableCanvasPointerEvents();
if (placeholderImage) {
event.dataTransfer.setDragImage(placeholderImage, 0, 0);
}
@@ -153,7 +164,7 @@ const useDraggable = ({
};
const handleDrag: DragEventHandler = (event) => {
const target = draggableRef.current;
const target = ref.current;
if (
event.pageX <= 0 ||
@@ -206,9 +217,45 @@ const useDraggable = ({
return {
onDragStart: handleDragStart,
onDrag: handleDrag,
onDragEnd: enableCanvasPointerEvents,
style,
draggableRef,
ref,
};
};
// This is needed to prevent pointer events on the iframe from interfering with dragging and resizing.
const useSetPointerEvents = () => {
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"),
};
};
@@ -218,7 +265,6 @@ export const DialogContent = forwardRef(
children,
className,
css,
resize = "none",
width,
height,
x,
@@ -229,11 +275,11 @@ export const DialogContent = forwardRef(
}: ComponentProps<typeof Primitive.Content> &
UseDraggableProps & {
css?: CSS;
resize?: "auto" | "none";
},
forwardedRef: Ref<HTMLDivElement>
) => {
const { draggableRef, ...draggableProps } = useDraggable({
const { resize } = useContext(DialogContext);
const { ref: draggableRef, ...draggableProps } = useDraggable({
width,
height,
x,
@@ -242,14 +288,18 @@ export const DialogContent = forwardRef(
minHeight,
});
const { ref: pointerEventsRef, ...pointerEventsProps } =
useSetPointerEvents();
return (
<Primitive.Portal>
<Primitive.Overlay className={overlayStyle()} />
<Primitive.Content
className={contentStyle({ className, css, resize })}
{...draggableProps}
{...pointerEventsProps}
{...props}
ref={mergeRefs(forwardedRef, draggableRef)}
ref={mergeRefs(forwardedRef, draggableRef, pointerEventsRef)}
>
{children}
</Primitive.Content>
@@ -265,6 +315,13 @@ const titleSlotStyle = css({
order: -1,
});
const titleStyle = css({
display: "flex",
flexGrow: 1,
height: "100%",
alignItems: "center",
});
export const DialogTitle = ({
children,
suffix,
@@ -272,27 +329,30 @@ export const DialogTitle = ({
}: ComponentProps<typeof PanelTitle> & {
suffix?: ReactNode;
closeLabel?: string;
}) => (
<div className={titleSlotStyle()}>
<PanelTitle {...rest} suffix={suffix ?? <DialogClose />}>
<Primitive.Title className={titleStyle()}>{children}</Primitive.Title>
</PanelTitle>
<Separator />
</div>
);
}) => {
const { draggable } = useContext(DialogContext);
return (
<div className={titleSlotStyle()}>
<PanelTitle {...rest} suffix={suffix ?? <DialogClose />}>
<Primitive.Title asChild>
<Text
draggable={draggable}
className={titleStyle()}
variant="titles"
truncate
>
{children}
</Text>
</Primitive.Title>
</PanelTitle>
<Separator />
</div>
);
};
export const DialogTitleActions = ({ children }: { children: ReactNode }) => {
return (
<Flex
gap="1"
onMouseDown={(event) => {
// Prevent dragging dialog
event.preventDefault();
}}
>
{children}
</Flex>
);
return <Flex gap="1">{children}</Flex>;
};
export const DialogActions = ({ children }: { children: ReactNode }) => {
@@ -345,8 +405,3 @@ const contentStyle = css(panelStyle, {
},
},
});
const titleStyle = css({
// Resetting H2 styles (Primitive.Title is H2)
all: "unset",
});
@@ -52,7 +52,7 @@ type FloatingPanelProps = {
content: ReactNode;
children: ReactNode;
maximizable?: boolean;
resize?: ComponentProps<typeof DialogContent>["resize"];
resize?: ComponentProps<typeof Dialog>["resize"];
width?: number;
height?: number;
// - bottom - below the trigger button
@@ -134,13 +134,17 @@ export const FloatingPanel = ({
useLayoutEffect(calcPosition, [calcPosition]);
return (
<Dialog open={open} modal={false} onOpenChange={onOpenChange}>
<Dialog
draggable
resize={resize}
open={open}
modal={false}
onOpenChange={onOpenChange}
>
<DialogTrigger asChild ref={triggerRef}>
{children}
</DialogTrigger>
<DialogContent
draggable
resize={resize}
className={contentStyle()}
width={width}
height={height}
@@ -10,16 +10,14 @@ import {
type Ref,
} from "react";
import { theme, styled, type CSS } from "../stitches.config";
import { truncate } from "../utilities";
import { textVariants } from "./text";
import { Box } from "./box";
import { Text } from "./text";
type TitleProps = ComponentProps<"div"> & {
suffix?: ReactNode;
css?: CSS;
};
const Container = styled("div", textVariants.titles, {
const Container = styled("div", {
display: "flex",
alignItems: "center",
flexShrink: 0,
@@ -27,7 +25,6 @@ const Container = styled("div", textVariants.titles, {
height: theme.spacing[15],
paddingInline: theme.panel.paddingInline,
paddingRight: theme.spacing[5],
color: theme.colors.foregroundMain,
});
const SuffixSlot = styled("div", {
@@ -48,7 +45,13 @@ export const PanelTitle = forwardRef(
ref: Ref<HTMLDivElement>
) => (
<Container className={className} css={css} {...rest} ref={ref}>
<Box css={truncate()}>{children}</Box>
{typeof children === "string" ? (
<Text variant="titles" truncate>
{children}
</Text>
) : (
children
)}
{suffix && <SuffixSlot>{suffix}</SuffixSlot>}
</Container>
)
+58 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import warnOnce from "warn-once";
import type { CSS } from "./stitches.config";
@@ -85,6 +85,63 @@ export const useDisableCanvasPointerEvents = () => {
return enableDisable;
};
/**
* Uses ResizeObserver to notify about resize events, with start, end and timeout.
*/
export const useResize = ({
onResizeStart,
onResize,
onResizeEnd,
timeout = 300,
}: {
onResizeStart?: () => void;
onResize?: () => void;
onResizeEnd?: () => void;
timeout?: number;
}) => {
const [element, ref] = useState<HTMLElement | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const onResizeStartRef = useRef(onResizeStart);
onResizeStartRef.current = onResizeStart;
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const onResizeEndRef = useRef(onResizeEnd);
onResizeEndRef.current = onResizeEnd;
const isResizingRef = useRef<boolean | undefined>();
useEffect(() => {
if (element === null) {
return;
}
// Mark resizing as on a new observer instance, we will use this to skip first resize event.
isResizingRef.current = undefined;
const observer = new ResizeObserver(() => {
// Resize observer called first time is not a start of resize
if (isResizingRef.current === undefined) {
isResizingRef.current = false;
return;
}
if (isResizingRef.current === false) {
isResizingRef.current = true;
onResizeStartRef.current?.();
}
onResizeRef.current?.();
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onResizeEndRef.current?.();
isResizingRef.current = false;
}, timeout);
});
observer.observe(element);
return () => {
clearTimeout(timeoutRef.current);
observer.disconnect();
};
}, [element, onResizeStartRef, onResizeRef, onResizeEndRef, timeout]);
return [element, ref] as const;
};
export const truncate = (): CSS => ({
whiteSpace: "nowrap",
textOverflow: "ellipsis",