Skip to content

Commit

Permalink
fix: Ensure outline stays within canvas bounds (#4570)
Browse files Browse the repository at this point in the history
## Description

Playground


https://p-183a0702-0620-49ac-84b6-066dbce1d300-dot-fix-outline.development.webstudio.is/


<img width="1229" alt="image"
src="https://github.com/user-attachments/assets/5c6a8c73-70e6-48af-8cbd-6c12144d55ee"
/>

todo:
- [x] - fix if horizontal scroll 
<img width="629" alt="image"
src="https://github.com/user-attachments/assets/d904d7ac-f74a-4116-993f-00b8b14c39dd"
/>

- [x] - fix if vertical

## 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
  • Loading branch information
istarkov authored Dec 12, 2024
1 parent a82ee06 commit 5de42b2
Show file tree
Hide file tree
Showing 13 changed files with 251 additions and 27 deletions.
10 changes: 9 additions & 1 deletion apps/builder/app/builder/features/workspace/canvas-iframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,15 @@ const CanvasRectUpdater = ({
}

const rect = iframeRef.current.getBoundingClientRect();
$canvasRect.set(rect);

$canvasRect.set(
new DOMRect(
Math.round(rect.x),
Math.round(rect.y),
Math.round(rect.width),
Math.round(rect.height)
)
);
};

setUpdateCallback(() => task);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { Label } from "./outline/label";
import { Outline } from "./outline/outline";
import { useSubscribeDragAndDropState } from "./use-subscribe-drag-drop-state";
import { applyScale } from "./outline";
import { $scale } from "~/builder/shared/nano-states";
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
import { BlockChildHoveredInstanceOutline } from "./outline/block-instance-outline";

const containerStyle = css({
Expand All @@ -41,11 +41,16 @@ export const CanvasTools = () => {
const dragAndDropState = useStore($dragAndDropState);
const instances = useStore($instances);
const scale = useStore($scale);
const clampingRect = useStore($clampingRect);

if (!canvasToolsVisible) {
return;
}

if (clampingRect === undefined) {
return;
}

if (dragAndDropState.isDragging) {
if (dragAndDropState.placementIndicator === undefined) {
return;
Expand All @@ -59,7 +64,7 @@ export const CanvasTools = () => {

return dropTargetInstance ? (
<div className={containerStyle({ overflow: "hidden" })}>
<Outline rect={rect}>
<Outline rect={rect} clampingRect={clampingRect}>
<Label instance={dropTargetInstance} instanceRect={rect} />
</Outline>
{placementIndicator !== undefined && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ export const applyScale = (rect: Rect, scale: number = 1) => {
// Calculate in the "scale" that is applied to the canvas
const scaleFactor = scale / 100;
return {
top: rect.top * scaleFactor,
left: rect.left * scaleFactor,
width: rect.width * scaleFactor,
height: rect.height * scaleFactor,
top: Math.round(rect.top * scaleFactor),
left: Math.round(rect.left * scaleFactor),
width: Math.round(rect.width * scaleFactor),
height: Math.round(rect.height * scaleFactor),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
} from "@webstudio-is/design-system";
import { Outline } from "./outline";
import { applyScale } from "./apply-scale";
import { $scale } from "~/builder/shared/nano-states";
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
import { PlusIcon, TrashIcon } from "@webstudio-is/icons";
import { BoxIcon } from "@webstudio-is/icons/svg";
import { useRef, useState } from "react";
Expand Down Expand Up @@ -342,6 +342,7 @@ export const BlockChildHoveredInstanceOutline = () => {
const isContentMode = useStore($isContentMode);
const modifierKeys = useStore($modifierKeys);
const instances = useStore($instances);
const clampingRect = useStore($clampingRect);

const timeoutRef = useRef<undefined | ReturnType<typeof setTimeout>>(
undefined
Expand All @@ -366,6 +367,10 @@ export const BlockChildHoveredInstanceOutline = () => {
return;
}

if (clampingRect === undefined) {
return;
}

const blockInstanceSelector = findBlockSelector(outline.selector, instances);

if (blockInstanceSelector === undefined) {
Expand Down Expand Up @@ -412,7 +417,7 @@ export const BlockChildHoveredInstanceOutline = () => {
);

return (
<Outline rect={rect}>
<Outline rect={rect} clampingRect={clampingRect}>
<div
style={{
width: 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,31 @@ import { useStore } from "@nanostores/react";
import { $collaborativeInstanceRect } from "~/shared/nano-states";
import { Outline } from "./outline";
import { applyScale } from "./apply-scale";
import { $scale } from "~/builder/shared/nano-states";
import { $scale, $clampingRect } from "~/builder/shared/nano-states";
import { $ephemeralStyles } from "~/canvas/stores";

// Outline of an instance that is being edited by AI or a human collaborator.
export const CollaborativeInstanceOutline = () => {
const scale = useStore($scale);
const instanceRect = useStore($collaborativeInstanceRect);
const ephemeralStyles = useStore($ephemeralStyles);
const clampingRect = useStore($clampingRect);

if (instanceRect === undefined || ephemeralStyles.length !== 0) {
if (
instanceRect === undefined ||
ephemeralStyles.length !== 0 ||
clampingRect === undefined
) {
return;
}

const rect = applyScale(instanceRect, scale);

return <Outline variant="collaboration" rect={rect}></Outline>;
return (
<Outline
variant="collaboration"
rect={rect}
clampingRect={clampingRect}
></Outline>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { Outline } from "./outline";
import { Label } from "./label";
import { applyScale } from "./apply-scale";
import { $scale } from "~/builder/shared/nano-states";
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
import { findClosestSlot } from "~/shared/instance-utils";
import { shallowEqual } from "shallow-equal";
import type { InstanceSelector } from "~/shared/tree-utils";
Expand All @@ -31,8 +31,13 @@ export const HoveredInstanceOutline = () => {
const scale = useStore($scale);
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
const isContentMode = useStore($isContentMode);
const clampingRect = useStore($clampingRect);

if (outline === undefined || hoveredInstanceSelector === undefined) {
if (
outline === undefined ||
hoveredInstanceSelector === undefined ||
clampingRect === undefined
) {
return;
}

Expand All @@ -58,7 +63,7 @@ export const HoveredInstanceOutline = () => {
const rect = applyScale(outline.rect, scale);

return (
<Outline rect={rect} variant={variant}>
<Outline rect={rect} clampingRect={clampingRect} variant={variant}>
<Label
variant={variant}
instance={outline.instance}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const Basic = () => (
<Box css={{ width: "min-content", textAlign: "center" }}>
Selected outline
</Box>
<Outline rect={new DOMRect(0, 0, 150, 150)} />
<Outline
rect={new DOMRect(0, 0, 150, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
/>
</Grid>

<Flex
Expand All @@ -24,7 +27,11 @@ export const Basic = () => (
<Box css={{ width: "min-content", textAlign: "center" }}>
Collaboration outline
</Box>
<Outline rect={new DOMRect(0, 0, 150, 150)} variant="collaboration" />
<Outline
rect={new DOMRect(0, 0, 150, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
variant="collaboration"
/>
</Flex>

<Flex
Expand All @@ -35,8 +42,15 @@ export const Basic = () => (
<Box css={{ width: "min-content", textAlign: "center" }}>
Collaboration outline over Selected
</Box>
<Outline rect={new DOMRect(0, 0, 150, 150)} />
<Outline rect={new DOMRect(0, 0, 150, 150)} variant="collaboration" />
<Outline
rect={new DOMRect(0, 0, 150, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
/>
<Outline
rect={new DOMRect(0, 0, 150, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
variant="collaboration"
/>
</Flex>

<Flex
Expand All @@ -47,8 +61,57 @@ export const Basic = () => (
<Box css={{ width: "min-content", textAlign: "center" }}>
Selected outline over Collaboration
</Box>
<Outline rect={new DOMRect(0, 0, 150, 150)} variant="collaboration" />
<Outline rect={new DOMRect(0, 0, 150, 150)} />
<Outline
rect={new DOMRect(0, 0, 150, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
variant="collaboration"
/>
<Outline
rect={new DOMRect(0, 0, 150, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
/>
</Flex>

<Flex
align="center"
justify="center"
css={{ position: "relative", height: 150, width: 150 }}
>
<Box css={{ width: "min-content", textAlign: "center" }}>
Clamped left
</Box>
<Outline
rect={new DOMRect(-10, 0, 150, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
/>
</Flex>

<Flex
align="center"
justify="center"
css={{ position: "relative", height: 150, width: 150 }}
>
<Box css={{ width: "min-content", textAlign: "center" }}>
Clamped right
</Box>
<Outline
rect={new DOMRect(0, 0, 160, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
/>
</Flex>

<Flex
align="center"
justify="center"
css={{ position: "relative", height: 150, width: 150 }}
>
<Box css={{ width: "min-content", textAlign: "center" }}>
Clamped left-right
</Box>
<Outline
rect={new DOMRect(-10, 0, 170, 150)}
clampingRect={new DOMRect(0, 0, 150, 150)}
/>
</Flex>
</Grid>
);
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ const baseOutlineStyle = css({
borderColor: theme.colors.foregroundReusable,
},
},

isLeftClamped: {
true: {
borderLeftWidth: 0,
},
},
isRightClamped: {
true: {
borderRightWidth: 0,
},
},
isBottomClamped: {
true: {
borderBottomWidth: 0,
},
},
isTopClamped: {
true: {
borderTopWidth: 0,
},
},
},
defaultVariants: { variant: "default" },
});
Expand Down Expand Up @@ -68,18 +89,45 @@ const useDynamicStyle = (rect?: Rect) => {

type OutlineProps = {
children?: ReactNode;
rect?: Rect;
rect: Rect;
clampingRect: Rect;
variant?: "default" | "collaboration" | "slot";
};

export const Outline = ({ children, rect, variant }: OutlineProps) => {
const dynamicStyle = useDynamicStyle(rect);
export const Outline = ({
children,
rect,
clampingRect,
variant,
}: OutlineProps) => {
const outlineRect = {
top: Math.max(rect.top, clampingRect.top),
height:
Math.min(rect.top + rect.height, clampingRect.top + clampingRect.height) -
Math.max(rect.top, clampingRect.top),

left: Math.max(rect.left, clampingRect.left),
width:
Math.min(rect.left + rect.width, clampingRect.left + clampingRect.width) -
Math.max(rect.left, clampingRect.left),
};

const isLeftClamped = rect.left < outlineRect.left;
const isTopClamped = rect.top < outlineRect.top;

const isRightClamped =
Math.round(rect.left + rect.width) > Math.round(clampingRect.width);

const isBottomClamped =
Math.round(rect.top + rect.height) > Math.round(clampingRect.height);

const dynamicStyle = useDynamicStyle(outlineRect);

return (
<>
{propertyStyle}
<div
className={`${baseStyle()} ${baseOutlineStyle({ variant })}`}
className={`${baseStyle()} ${baseOutlineStyle({ variant, isLeftClamped, isRightClamped, isBottomClamped, isTopClamped })}`}
style={dynamicStyle}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { $textEditingInstanceSelector } from "~/shared/nano-states";
import { type InstanceSelector } from "~/shared/tree-utils";
import { Outline } from "./outline";
import { applyScale } from "./apply-scale";
import { $scale } from "~/builder/shared/nano-states";
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
import { findClosestSlot } from "~/shared/instance-utils";
import { $ephemeralStyles } from "~/canvas/stores";

Expand All @@ -26,11 +26,16 @@ export const SelectedInstanceOutline = () => {
const outline = useStore($selectedInstanceOutlineAndInstance);
const scale = useStore($scale);
const ephemeralStyles = useStore($ephemeralStyles);
const clampingRect = useStore($clampingRect);

if (selectedInstanceSelector === undefined) {
return;
}

if (clampingRect === undefined) {
return;
}

const isEditingCurrentInstance =
textEditingInstanceSelector !== undefined &&
isDescendantOrSelf(
Expand All @@ -51,5 +56,5 @@ export const SelectedInstanceOutline = () => {
: "default";
const rect = applyScale(outline.rect, scale);

return <Outline rect={rect} variant={variant} />;
return <Outline rect={rect} clampingRect={clampingRect} variant={variant} />;
};
Loading

0 comments on commit 5de42b2

Please sign in to comment.