mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
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:
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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user