Skip to content

Commit 5de42b2

Browse files
authored
fix: Ensure outline stays within canvas bounds (#4570)
## 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
1 parent a82ee06 commit 5de42b2

File tree

13 files changed

+251
-27
lines changed

13 files changed

+251
-27
lines changed

apps/builder/app/builder/features/workspace/canvas-iframe.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,15 @@ const CanvasRectUpdater = ({
4545
}
4646

4747
const rect = iframeRef.current.getBoundingClientRect();
48-
$canvasRect.set(rect);
48+
49+
$canvasRect.set(
50+
new DOMRect(
51+
Math.round(rect.x),
52+
Math.round(rect.y),
53+
Math.round(rect.width),
54+
Math.round(rect.height)
55+
)
56+
);
4957
};
5058

5159
setUpdateCallback(() => task);

apps/builder/app/builder/features/workspace/canvas-tools/canvas-tools.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { Label } from "./outline/label";
1717
import { Outline } from "./outline/outline";
1818
import { useSubscribeDragAndDropState } from "./use-subscribe-drag-drop-state";
1919
import { applyScale } from "./outline";
20-
import { $scale } from "~/builder/shared/nano-states";
20+
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
2121
import { BlockChildHoveredInstanceOutline } from "./outline/block-instance-outline";
2222

2323
const containerStyle = css({
@@ -41,11 +41,16 @@ export const CanvasTools = () => {
4141
const dragAndDropState = useStore($dragAndDropState);
4242
const instances = useStore($instances);
4343
const scale = useStore($scale);
44+
const clampingRect = useStore($clampingRect);
4445

4546
if (!canvasToolsVisible) {
4647
return;
4748
}
4849

50+
if (clampingRect === undefined) {
51+
return;
52+
}
53+
4954
if (dragAndDropState.isDragging) {
5055
if (dragAndDropState.placementIndicator === undefined) {
5156
return;
@@ -59,7 +64,7 @@ export const CanvasTools = () => {
5964

6065
return dropTargetInstance ? (
6166
<div className={containerStyle({ overflow: "hidden" })}>
62-
<Outline rect={rect}>
67+
<Outline rect={rect} clampingRect={clampingRect}>
6368
<Label instance={dropTargetInstance} instanceRect={rect} />
6469
</Outline>
6570
{placementIndicator !== undefined && (

apps/builder/app/builder/features/workspace/canvas-tools/outline/apply-scale.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ export const applyScale = (rect: Rect, scale: number = 1) => {
44
// Calculate in the "scale" that is applied to the canvas
55
const scaleFactor = scale / 100;
66
return {
7-
top: rect.top * scaleFactor,
8-
left: rect.left * scaleFactor,
9-
width: rect.width * scaleFactor,
10-
height: rect.height * scaleFactor,
7+
top: Math.round(rect.top * scaleFactor),
8+
left: Math.round(rect.left * scaleFactor),
9+
width: Math.round(rect.width * scaleFactor),
10+
height: Math.round(rect.height * scaleFactor),
1111
};
1212
};

apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
} from "@webstudio-is/design-system";
3131
import { Outline } from "./outline";
3232
import { applyScale } from "./apply-scale";
33-
import { $scale } from "~/builder/shared/nano-states";
33+
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
3434
import { PlusIcon, TrashIcon } from "@webstudio-is/icons";
3535
import { BoxIcon } from "@webstudio-is/icons/svg";
3636
import { useRef, useState } from "react";
@@ -342,6 +342,7 @@ export const BlockChildHoveredInstanceOutline = () => {
342342
const isContentMode = useStore($isContentMode);
343343
const modifierKeys = useStore($modifierKeys);
344344
const instances = useStore($instances);
345+
const clampingRect = useStore($clampingRect);
345346

346347
const timeoutRef = useRef<undefined | ReturnType<typeof setTimeout>>(
347348
undefined
@@ -366,6 +367,10 @@ export const BlockChildHoveredInstanceOutline = () => {
366367
return;
367368
}
368369

370+
if (clampingRect === undefined) {
371+
return;
372+
}
373+
369374
const blockInstanceSelector = findBlockSelector(outline.selector, instances);
370375

371376
if (blockInstanceSelector === undefined) {
@@ -412,7 +417,7 @@ export const BlockChildHoveredInstanceOutline = () => {
412417
);
413418

414419
return (
415-
<Outline rect={rect}>
420+
<Outline rect={rect} clampingRect={clampingRect}>
416421
<div
417422
style={{
418423
width: 0,

apps/builder/app/builder/features/workspace/canvas-tools/outline/collaborative-instance-outline.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,31 @@ import { useStore } from "@nanostores/react";
22
import { $collaborativeInstanceRect } from "~/shared/nano-states";
33
import { Outline } from "./outline";
44
import { applyScale } from "./apply-scale";
5-
import { $scale } from "~/builder/shared/nano-states";
5+
import { $scale, $clampingRect } from "~/builder/shared/nano-states";
66
import { $ephemeralStyles } from "~/canvas/stores";
77

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

14-
if (instanceRect === undefined || ephemeralStyles.length !== 0) {
15+
if (
16+
instanceRect === undefined ||
17+
ephemeralStyles.length !== 0 ||
18+
clampingRect === undefined
19+
) {
1520
return;
1621
}
1722

1823
const rect = applyScale(instanceRect, scale);
1924

20-
return <Outline variant="collaboration" rect={rect}></Outline>;
25+
return (
26+
<Outline
27+
variant="collaboration"
28+
rect={rect}
29+
clampingRect={clampingRect}
30+
></Outline>
31+
);
2132
};

apps/builder/app/builder/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { Outline } from "./outline";
1111
import { Label } from "./label";
1212
import { applyScale } from "./apply-scale";
13-
import { $scale } from "~/builder/shared/nano-states";
13+
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
1414
import { findClosestSlot } from "~/shared/instance-utils";
1515
import { shallowEqual } from "shallow-equal";
1616
import type { InstanceSelector } from "~/shared/tree-utils";
@@ -31,8 +31,13 @@ export const HoveredInstanceOutline = () => {
3131
const scale = useStore($scale);
3232
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
3333
const isContentMode = useStore($isContentMode);
34+
const clampingRect = useStore($clampingRect);
3435

35-
if (outline === undefined || hoveredInstanceSelector === undefined) {
36+
if (
37+
outline === undefined ||
38+
hoveredInstanceSelector === undefined ||
39+
clampingRect === undefined
40+
) {
3641
return;
3742
}
3843

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

6065
return (
61-
<Outline rect={rect} variant={variant}>
66+
<Outline rect={rect} clampingRect={clampingRect} variant={variant}>
6267
<Label
6368
variant={variant}
6469
instance={outline.instance}

apps/builder/app/builder/features/workspace/canvas-tools/outline/outline.stories.tsx

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export const Basic = () => (
1313
<Box css={{ width: "min-content", textAlign: "center" }}>
1414
Selected outline
1515
</Box>
16-
<Outline rect={new DOMRect(0, 0, 150, 150)} />
16+
<Outline
17+
rect={new DOMRect(0, 0, 150, 150)}
18+
clampingRect={new DOMRect(0, 0, 150, 150)}
19+
/>
1720
</Grid>
1821

1922
<Flex
@@ -24,7 +27,11 @@ export const Basic = () => (
2427
<Box css={{ width: "min-content", textAlign: "center" }}>
2528
Collaboration outline
2629
</Box>
27-
<Outline rect={new DOMRect(0, 0, 150, 150)} variant="collaboration" />
30+
<Outline
31+
rect={new DOMRect(0, 0, 150, 150)}
32+
clampingRect={new DOMRect(0, 0, 150, 150)}
33+
variant="collaboration"
34+
/>
2835
</Flex>
2936

3037
<Flex
@@ -35,8 +42,15 @@ export const Basic = () => (
3542
<Box css={{ width: "min-content", textAlign: "center" }}>
3643
Collaboration outline over Selected
3744
</Box>
38-
<Outline rect={new DOMRect(0, 0, 150, 150)} />
39-
<Outline rect={new DOMRect(0, 0, 150, 150)} variant="collaboration" />
45+
<Outline
46+
rect={new DOMRect(0, 0, 150, 150)}
47+
clampingRect={new DOMRect(0, 0, 150, 150)}
48+
/>
49+
<Outline
50+
rect={new DOMRect(0, 0, 150, 150)}
51+
clampingRect={new DOMRect(0, 0, 150, 150)}
52+
variant="collaboration"
53+
/>
4054
</Flex>
4155

4256
<Flex
@@ -47,8 +61,57 @@ export const Basic = () => (
4761
<Box css={{ width: "min-content", textAlign: "center" }}>
4862
Selected outline over Collaboration
4963
</Box>
50-
<Outline rect={new DOMRect(0, 0, 150, 150)} variant="collaboration" />
51-
<Outline rect={new DOMRect(0, 0, 150, 150)} />
64+
<Outline
65+
rect={new DOMRect(0, 0, 150, 150)}
66+
clampingRect={new DOMRect(0, 0, 150, 150)}
67+
variant="collaboration"
68+
/>
69+
<Outline
70+
rect={new DOMRect(0, 0, 150, 150)}
71+
clampingRect={new DOMRect(0, 0, 150, 150)}
72+
/>
73+
</Flex>
74+
75+
<Flex
76+
align="center"
77+
justify="center"
78+
css={{ position: "relative", height: 150, width: 150 }}
79+
>
80+
<Box css={{ width: "min-content", textAlign: "center" }}>
81+
Clamped left
82+
</Box>
83+
<Outline
84+
rect={new DOMRect(-10, 0, 150, 150)}
85+
clampingRect={new DOMRect(0, 0, 150, 150)}
86+
/>
87+
</Flex>
88+
89+
<Flex
90+
align="center"
91+
justify="center"
92+
css={{ position: "relative", height: 150, width: 150 }}
93+
>
94+
<Box css={{ width: "min-content", textAlign: "center" }}>
95+
Clamped right
96+
</Box>
97+
<Outline
98+
rect={new DOMRect(0, 0, 160, 150)}
99+
clampingRect={new DOMRect(0, 0, 150, 150)}
100+
/>
101+
</Flex>
102+
103+
<Flex
104+
align="center"
105+
justify="center"
106+
css={{ position: "relative", height: 150, width: 150 }}
107+
>
108+
<Box css={{ width: "min-content", textAlign: "center" }}>
109+
Clamped left-right
110+
</Box>
111+
<Outline
112+
rect={new DOMRect(-10, 0, 170, 150)}
113+
clampingRect={new DOMRect(0, 0, 150, 150)}
114+
/>
52115
</Flex>
53116
</Grid>
54117
);

apps/builder/app/builder/features/workspace/canvas-tools/outline/outline.tsx

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,27 @@ const baseOutlineStyle = css({
4040
borderColor: theme.colors.foregroundReusable,
4141
},
4242
},
43+
44+
isLeftClamped: {
45+
true: {
46+
borderLeftWidth: 0,
47+
},
48+
},
49+
isRightClamped: {
50+
true: {
51+
borderRightWidth: 0,
52+
},
53+
},
54+
isBottomClamped: {
55+
true: {
56+
borderBottomWidth: 0,
57+
},
58+
},
59+
isTopClamped: {
60+
true: {
61+
borderTopWidth: 0,
62+
},
63+
},
4364
},
4465
defaultVariants: { variant: "default" },
4566
});
@@ -68,18 +89,45 @@ const useDynamicStyle = (rect?: Rect) => {
6889

6990
type OutlineProps = {
7091
children?: ReactNode;
71-
rect?: Rect;
92+
rect: Rect;
93+
clampingRect: Rect;
7294
variant?: "default" | "collaboration" | "slot";
7395
};
7496

75-
export const Outline = ({ children, rect, variant }: OutlineProps) => {
76-
const dynamicStyle = useDynamicStyle(rect);
97+
export const Outline = ({
98+
children,
99+
rect,
100+
clampingRect,
101+
variant,
102+
}: OutlineProps) => {
103+
const outlineRect = {
104+
top: Math.max(rect.top, clampingRect.top),
105+
height:
106+
Math.min(rect.top + rect.height, clampingRect.top + clampingRect.height) -
107+
Math.max(rect.top, clampingRect.top),
108+
109+
left: Math.max(rect.left, clampingRect.left),
110+
width:
111+
Math.min(rect.left + rect.width, clampingRect.left + clampingRect.width) -
112+
Math.max(rect.left, clampingRect.left),
113+
};
114+
115+
const isLeftClamped = rect.left < outlineRect.left;
116+
const isTopClamped = rect.top < outlineRect.top;
117+
118+
const isRightClamped =
119+
Math.round(rect.left + rect.width) > Math.round(clampingRect.width);
120+
121+
const isBottomClamped =
122+
Math.round(rect.top + rect.height) > Math.round(clampingRect.height);
123+
124+
const dynamicStyle = useDynamicStyle(outlineRect);
77125

78126
return (
79127
<>
80128
{propertyStyle}
81129
<div
82-
className={`${baseStyle()} ${baseOutlineStyle({ variant })}`}
130+
className={`${baseStyle()} ${baseOutlineStyle({ variant, isLeftClamped, isRightClamped, isBottomClamped, isTopClamped })}`}
83131
style={dynamicStyle}
84132
>
85133
{children}

apps/builder/app/builder/features/workspace/canvas-tools/outline/selected-instance-outline.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { $textEditingInstanceSelector } from "~/shared/nano-states";
88
import { type InstanceSelector } from "~/shared/tree-utils";
99
import { Outline } from "./outline";
1010
import { applyScale } from "./apply-scale";
11-
import { $scale } from "~/builder/shared/nano-states";
11+
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
1212
import { findClosestSlot } from "~/shared/instance-utils";
1313
import { $ephemeralStyles } from "~/canvas/stores";
1414

@@ -26,11 +26,16 @@ export const SelectedInstanceOutline = () => {
2626
const outline = useStore($selectedInstanceOutlineAndInstance);
2727
const scale = useStore($scale);
2828
const ephemeralStyles = useStore($ephemeralStyles);
29+
const clampingRect = useStore($clampingRect);
2930

3031
if (selectedInstanceSelector === undefined) {
3132
return;
3233
}
3334

35+
if (clampingRect === undefined) {
36+
return;
37+
}
38+
3439
const isEditingCurrentInstance =
3540
textEditingInstanceSelector !== undefined &&
3641
isDescendantOrSelf(
@@ -51,5 +56,5 @@ export const SelectedInstanceOutline = () => {
5156
: "default";
5257
const rect = applyScale(outline.rect, scale);
5358

54-
return <Outline rect={rect} variant={variant} />;
59+
return <Outline rect={rect} clampingRect={clampingRect} variant={variant} />;
5560
};

0 commit comments

Comments
 (0)