Skip to content

Commit 69b3bca

Browse files
authored
Merge pull request #743 from sugarlabs/704
704 feat(masonry) observe feedback when connecting/disconnecting Bricks
2 parents 064aeb9 + 5944dc2 commit 69b3bca

17 files changed

Lines changed: 1150 additions & 44 deletions

modules/masonry/src/components/Tower/TowerBrick.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ export const TowerBrickView = memo(function (props: TowerBrickViewProps) {
2727

2828
useBrickMove(id, ref);
2929

30-
const { x, y } = useBrickLayoutStore((state) => state.coords[id]);
30+
// We explicitly extract coords without returning a fallback object in the selector.
31+
// Returning a new `{ x: 0, y: 0 }` object inside the selector would cause useSyncExternalStore
32+
// to detect a new reference on every render, triggering an infinite re-render loop.
33+
const coords = useBrickLayoutStore((state) => state.coords[id]);
34+
const x = coords?.x ?? 0;
35+
const y = coords?.y ?? 0;
3136
const isMounted = useBrickLayoutStore((state) => state.mounted[id]);
3237
const isPositioned = useBrickLayoutStore((state) => state.positioned[id]);
3338

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { DisconnectShadowView } from './DisconnectShadowView';
4+
import { useConnectionPreviewStore } from '@/stores/connection-preview';
5+
import { useBrickLayoutStore } from '@/stores/brick';
6+
7+
describe('DisconnectShadowView', () => {
8+
beforeEach(() => {
9+
useConnectionPreviewStore.setState({
10+
activeTarget: null,
11+
isValid: false,
12+
snapPosition: null,
13+
disconnectShadow: null,
14+
});
15+
useBrickLayoutStore.setState({ coords: {} });
16+
});
17+
18+
it('renders nothing when there is no disconnect shadow', () => {
19+
const { container } = render(<DisconnectShadowView />);
20+
expect(container.firstChild).toBeNull();
21+
});
22+
23+
it('renders nothing when the host coordinates are not found', () => {
24+
useConnectionPreviewStore.setState({
25+
disconnectShadow: {
26+
hostTowerId: 'tower1',
27+
hostBrickId: 'missing-host',
28+
socket: 'next',
29+
},
30+
});
31+
const { container } = render(<DisconnectShadowView />);
32+
expect(container.firstChild).toBeNull();
33+
});
34+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useMemo } from 'react';
2+
import type { BrickViewProps, BrickViewPropsWithModel } from '@/@types/brick.types';
3+
import { BrickView } from '@/components/Brick/Brick';
4+
import { useConnectionPreviewStore } from '@/stores/connection-preview';
5+
import { findNodeAndTower } from '@/stores/workspace';
6+
import { useBrickLayoutStore } from '@/stores/brick';
7+
import { createBrickModel } from '@/utils/brick-model-factory';
8+
import { SCALE_LEVEL_CONFIG } from '@/utils/constants';
9+
10+
/**
11+
* Renders a "ghost" footprint (DisconnectShadow) exactly where a brick was just disconnected from.
12+
* This provides visual feedback to the user, showing them the empty slot where the brick
13+
* previously lived before they dropped it somewhere else or deleted it.
14+
* It automatically disappears once the user finishes dragging and drops the brick.
15+
*/
16+
export function DisconnectShadowView() {
17+
const disconnectShadow = useConnectionPreviewStore((state) => state.disconnectShadow);
18+
19+
// We need the exact coordinates of the host brick to render the shadow at the correct location
20+
const hostCoords = useBrickLayoutStore((state) =>
21+
disconnectShadow ? state.coords[disconnectShadow.hostBrickId] : null,
22+
);
23+
24+
// Generate a dummy grey "ghost" model that perfectly matches the shape of the disconnected brick
25+
const shadowModel = useMemo(() => {
26+
if (!disconnectShadow || !hostCoords) return null;
27+
28+
const found = findNodeAndTower(disconnectShadow.hostBrickId);
29+
if (!found) return null;
30+
const hostModel = found.node.model;
31+
32+
const colors = { background: '#d1d5db', foreground: 'transparent', border: '#9ca3af' };
33+
34+
if (typeof disconnectShadow.socket === 'number' || disconnectShadow.socket === 'output') {
35+
const props: BrickViewProps = {
36+
kind: 'value',
37+
widget: { type: 'label', text: '' },
38+
colorsDefault: colors,
39+
tooltipText: '',
40+
scaleLevel: hostModel.scaleLevel,
41+
};
42+
return createBrickModel(props);
43+
} else {
44+
const props: BrickViewProps = {
45+
kind: 'statement',
46+
widget: { type: 'label', text: '' },
47+
colorsDefault: colors,
48+
hasConnectionPrev: true,
49+
hasConnectionNext: true,
50+
tooltipText: '',
51+
scaleLevel: hostModel.scaleLevel,
52+
};
53+
return createBrickModel(props);
54+
}
55+
}, [disconnectShadow, hostCoords]);
56+
57+
// Calculate the precise absolute coordinates on the workspace where the shadow should render.
58+
// We extract the exact connector bounds (e.g. the notch for 'next') and scale it.
59+
const snapPosition = useMemo(() => {
60+
if (!disconnectShadow || !shadowModel || !hostCoords) return null;
61+
62+
const found = findNodeAndTower(disconnectShadow.hostBrickId);
63+
if (!found) return null;
64+
65+
const hostModel = found.node.model;
66+
const connectors = hostModel.getConnectorCoords();
67+
68+
let hostBounds = null;
69+
if (disconnectShadow.socket === 'next') {
70+
hostBounds = connectors.next;
71+
} else if (disconnectShadow.socket === 'nestedNext') {
72+
hostBounds = connectors.nestedNext;
73+
// If the host block's cavity collapsed (because we just detached its last child),
74+
// connectors.nestedNext will be missing. We can synthesize its exact location
75+
// by shifting the bottom tab to the right by TAIL_INDENT_W (8) + strokeWidth.
76+
if (!hostBounds && connectors.next) {
77+
const strokeWidth = hostModel.scaleLevel * 2;
78+
hostBounds = {
79+
x: connectors.next.x + 8 + strokeWidth,
80+
y: connectors.next.y,
81+
w: connectors.next.w,
82+
h: connectors.next.h,
83+
};
84+
}
85+
} else if (typeof disconnectShadow.socket === 'number') {
86+
hostBounds = connectors.inputs[disconnectShadow.socket];
87+
} else if (disconnectShadow.socket === 'output') hostBounds = connectors.output;
88+
89+
const hostScale = SCALE_LEVEL_CONFIG[hostModel.scaleLevel].brickScale;
90+
const hx = hostCoords.x + (hostBounds ? hostBounds.x * hostScale : 0);
91+
const hy = hostCoords.y + (hostBounds ? hostBounds.y * hostScale : 0);
92+
93+
const shadowScale = SCALE_LEVEL_CONFIG[shadowModel.scaleLevel].brickScale;
94+
const shadowConnectors = shadowModel.getConnectorCoords();
95+
const shadowBounds =
96+
typeof disconnectShadow.socket === 'number' || disconnectShadow.socket === 'output'
97+
? shadowConnectors.output
98+
: shadowConnectors.prev;
99+
100+
const dx = shadowBounds ? shadowBounds.x * shadowScale : 0;
101+
const dy = shadowBounds ? shadowBounds.y * shadowScale : 0;
102+
103+
const strokeWidthOffset = 2 * hostScale; // STROKE_WIDTH = 2
104+
let snapX = hx - dx;
105+
let snapY = hy - dy;
106+
107+
// The layout engine makes strokes abut rather than overlap
108+
if (typeof disconnectShadow.socket === 'number' || disconnectShadow.socket === 'output') {
109+
snapX += strokeWidthOffset;
110+
} else {
111+
snapY += strokeWidthOffset;
112+
}
113+
114+
return {
115+
x: snapX,
116+
y: snapY,
117+
};
118+
}, [disconnectShadow, hostCoords, shadowModel]);
119+
120+
if (!disconnectShadow || !snapPosition || !shadowModel) return null;
121+
122+
const viewProps = {
123+
kind: shadowModel.kind,
124+
model: shadowModel,
125+
} as unknown as BrickViewPropsWithModel;
126+
127+
return (
128+
<div
129+
data-testid="disconnect-shadow-view"
130+
className="pointer-events-none absolute top-0 left-0 z-20 opacity-80"
131+
style={{
132+
transform: `translate(${snapPosition.x}px, ${snapPosition.y}px)`,
133+
}}
134+
>
135+
<BrickView {...viewProps} />
136+
</div>
137+
);
138+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { SnapHintOverlay } from './SnapHintOverlay';
4+
import { useConnectionPreviewStore } from '@/stores/connection-preview';
5+
6+
describe('SnapHintOverlay', () => {
7+
beforeEach(() => {
8+
useConnectionPreviewStore.setState({
9+
activeTarget: null,
10+
isValid: false,
11+
snapPosition: null,
12+
disconnectShadow: null,
13+
});
14+
});
15+
16+
it('renders nothing when there is no active target', () => {
17+
const { container } = render(<SnapHintOverlay />);
18+
expect(container.firstChild).toBeNull();
19+
});
20+
21+
it('renders valid hint overlay with correct position and color', () => {
22+
useConnectionPreviewStore.setState({
23+
activeTarget: {
24+
draggedTowerId: 'tower1',
25+
targetTowerId: 'tower2',
26+
targetBrickId: 'brick1',
27+
type: 'statement',
28+
distance: 5,
29+
centroid: { x: 100, y: 150 },
30+
},
31+
isValid: true,
32+
});
33+
34+
const { getByTestId } = render(<SnapHintOverlay />);
35+
const overlay = getByTestId('snap-hint-overlay');
36+
37+
expect(overlay).toBeTruthy();
38+
expect(overlay.style.left).toBe('100px');
39+
expect(overlay.style.top).toBe('150px');
40+
expect(overlay.style.transform).toContain('scale(1.2)');
41+
// Valid color falls back to rgba(34, 197, 94, 0.4) because towers store is empty in this test
42+
expect(overlay.style.backgroundColor).toBe('rgba(34, 197, 94, 0.4)');
43+
});
44+
45+
it('renders invalid hint overlay with correct color', () => {
46+
useConnectionPreviewStore.setState({
47+
activeTarget: {
48+
draggedTowerId: 'tower1',
49+
targetTowerId: 'tower2',
50+
targetBrickId: 'brick1',
51+
type: 'argument',
52+
distance: 5,
53+
centroid: { x: 200, y: 250 },
54+
},
55+
isValid: false,
56+
});
57+
58+
const { getByTestId } = render(<SnapHintOverlay />);
59+
const overlay = getByTestId('snap-hint-overlay');
60+
61+
expect(overlay).toBeTruthy();
62+
expect(overlay.style.transform).toContain('scale(1)');
63+
expect(overlay.style.backgroundColor).toBe('rgba(239, 68, 68, 0.4)');
64+
});
65+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useConnectionPreviewStore } from '@/stores/connection-preview';
2+
import { useWorkspaceStore } from '@/stores/workspace';
3+
4+
/**
5+
* Renders a glowing dot (hint overlay) precisely at the socket or slot centroid when a user
6+
* drags a brick close to a valid or invalid connection candidate.
7+
*
8+
* - If the connection is valid, it pulses slightly larger and matches the color of the brick being dragged.
9+
* - If the connection is invalid, it shows a red glow to indicate a connection cannot be made here.
10+
*/
11+
export function SnapHintOverlay() {
12+
const activeTarget = useConnectionPreviewStore((state) => state.activeTarget);
13+
const isValid = useConnectionPreviewStore((state) => state.isValid);
14+
const towers = useWorkspaceStore((state) => state.towers);
15+
16+
if (!activeTarget) return null;
17+
18+
const { centroid, draggedTowerId } = activeTarget;
19+
20+
// We look up the dragged brick to match its theme colors for a seamless visual experience.
21+
const draggedTower = towers[draggedTowerId];
22+
const modelColors = draggedTower?.root?.model?.colorsDefault;
23+
24+
// Generate dynamic styles based on validity and the source brick's color palette
25+
const bgColor = isValid
26+
? modelColors
27+
? `${modelColors.background}80`
28+
: 'rgba(34, 197, 94, 0.4)'
29+
: 'rgba(239, 68, 68, 0.4)';
30+
const borderColor = isValid
31+
? modelColors
32+
? modelColors.border
33+
: 'rgba(20, 83, 45, 0.8)'
34+
: 'rgba(153, 27, 27, 0.8)';
35+
const glow = isValid
36+
? modelColors
37+
? `${modelColors.background}80`
38+
: 'rgba(34, 197, 94, 0.5)'
39+
: 'rgba(239, 68, 68, 0.5)';
40+
41+
return (
42+
<div
43+
data-testid="snap-hint-overlay"
44+
className="pointer-events-none absolute z-40 h-3 w-3 rounded-full transition-all duration-100 ease-out"
45+
style={{
46+
left: centroid.x,
47+
top: centroid.y,
48+
transform: `translate(-50%, -50%) ${isValid ? 'scale(1.2)' : 'scale(1)'}`,
49+
backgroundColor: bgColor,
50+
border: `2px solid ${borderColor}`,
51+
boxShadow: `0 0 10px 4px ${glow}`,
52+
}}
53+
/>
54+
);
55+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { SnapPreviewView } from './SnapPreviewView';
4+
import { useConnectionPreviewStore } from '@/stores/connection-preview';
5+
import { useWorkspaceStore } from '@/stores/workspace';
6+
7+
describe('SnapPreviewView', () => {
8+
beforeEach(() => {
9+
useConnectionPreviewStore.setState({
10+
activeTarget: null,
11+
isValid: false,
12+
snapPosition: null,
13+
disconnectShadow: null,
14+
});
15+
useWorkspaceStore.setState({ towers: {} });
16+
});
17+
18+
it('renders nothing when there is no snap position', () => {
19+
const { container } = render(<SnapPreviewView />);
20+
expect(container.firstChild).toBeNull();
21+
});
22+
23+
it('renders a dummy ghost block even if the dragged tower is not in the store', () => {
24+
useConnectionPreviewStore.setState({
25+
snapPosition: { x: 100, y: 100 },
26+
activeTarget: {
27+
draggedTowerId: 'missing-tower',
28+
targetTowerId: 'tower2',
29+
targetBrickId: 'brick1',
30+
type: 'statement',
31+
distance: 5,
32+
centroid: { x: 100, y: 150 },
33+
},
34+
isValid: true,
35+
});
36+
const { getByTestId } = render(<SnapPreviewView />);
37+
const container = getByTestId('snap-preview-view');
38+
expect(container).toBeTruthy();
39+
expect(container.style.transform).toBe('translate(100px, 100px)');
40+
});
41+
});

0 commit comments

Comments
 (0)