Skip to content

Commit 045a587

Browse files
authored
Merge pull request #848 from Lemoncode/fix/#847-macos-drag-and-drop
Fix/#847 MacOS drag and drop shapes
2 parents 2973813 + e193472 commit 045a587

14 files changed

Lines changed: 537 additions & 9 deletions

File tree

.changeset/tired-mammals-cough.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'quickmock': patch
3+
---
4+
5+
Fix component-gallery drag-and-drop in the VS Code extension on macOS,
6+
where HTML5 drag events targeting the inner iframe were dispatched to
7+
the webview shell instead of into the iframe (microsoft/vscode#193558).
8+
Linux and Windows are unaffected.

apps/web/src/common/components/gallery/components/item-component.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import { ShapeDisplayName, ShapeType } from '#core/model';
2+
import {
3+
loadThumbnailAsDataUrl,
4+
notifyDragEndToWebviewShell,
5+
notifyDragStartToWebviewShell,
6+
shouldUseMacWebviewDragBridge,
7+
} from '#core/vscode/mac-webview-drag-bridge.utils';
28
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
39
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
410
import { useEffect, useRef, useState } from 'react';
@@ -14,8 +20,22 @@ interface Props {
1420
export const ItemComponent: React.FC<Props> = props => {
1521
const { item } = props;
1622
const dragRef = useRef<HTMLDivElement>(null);
23+
const thumbnailDataUrlRef = useRef<string | null>(null);
1724
const [isDragging, setIsDragging] = useState(false);
1825

26+
useEffect(() => {
27+
if (!shouldUseMacWebviewDragBridge()) return;
28+
let cancelled = false;
29+
loadThumbnailAsDataUrl(item.thumbnailSrc)
30+
.then(dataUrl => {
31+
if (!cancelled) thumbnailDataUrlRef.current = dataUrl;
32+
})
33+
.catch(() => {});
34+
return () => {
35+
cancelled = true;
36+
};
37+
}, [item.thumbnailSrc]);
38+
1939
useEffect(() => {
2040
const el = dragRef.current;
2141

@@ -24,9 +44,39 @@ export const ItemComponent: React.FC<Props> = props => {
2444
return draggable({
2545
element: el,
2646
getInitialData: () => ({ type: item.type }),
27-
onDragStart: () => setIsDragging(true),
28-
onDrop: () => setIsDragging(false),
47+
onDragStart: () => {
48+
setIsDragging(true);
49+
const dataUrl = thumbnailDataUrlRef.current;
50+
if (dataUrl) {
51+
notifyDragStartToWebviewShell(item.type as ShapeType, dataUrl);
52+
}
53+
},
54+
onDrop: () => {
55+
setIsDragging(false);
56+
notifyDragEndToWebviewShell();
57+
},
2958
onGenerateDragPreview: ({ nativeSetDragImage }) => {
59+
// Native drag image from the nested iframe is unreliable on macOS; the
60+
// shell paints its own preview (see drag-bridge.ts), so suppress the
61+
// native one with a 1×1 transparent element.
62+
if (shouldUseMacWebviewDragBridge() && thumbnailDataUrlRef.current) {
63+
setCustomNativeDragPreview({
64+
getOffset: () => ({ x: 0, y: 0 }),
65+
render({ container }) {
66+
const transparent = document.createElement('div');
67+
transparent.style.width = '1px';
68+
transparent.style.height = '1px';
69+
transparent.style.opacity = '0';
70+
container.appendChild(transparent);
71+
return () => {
72+
transparent.remove();
73+
};
74+
},
75+
nativeSetDragImage,
76+
});
77+
return;
78+
}
79+
3080
setCustomNativeDragPreview({
3181
//Important: this numbers are the half of the width and height of var(--gallery-item-size)
3282
// TODO, we may extract the size variable value from the HTML variable it self
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
export function isMacOS() {
2-
return navigator.userAgent.toLowerCase().includes('mac');
1+
interface NavigatorWithUserAgentData extends Navigator {
2+
userAgentData?: { platform: string };
33
}
44

5-
export function isWindowsOrLinux() {
6-
return !isMacOS();
5+
export function isMacOS(): boolean {
6+
const userAgentData = (navigator as NavigatorWithUserAgentData).userAgentData;
7+
if (userAgentData?.platform) {
8+
return userAgentData.platform === 'macOS';
9+
}
10+
// Fallback for runtimes without UA-CH (Firefox, Safari, older Chromium).
11+
return /Mac/i.test(navigator.userAgent);
712
}

apps/web/src/common/utils/vscode-bridge.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const resolveParentOrigin = (): string => {
2222
}
2323
};
2424

25-
const parentOrigin = resolveParentOrigin();
25+
export const parentOrigin = resolveParentOrigin();
2626

2727
export const sendToExtension = (msg: AppMessage): void => {
2828
if (!isVSCodeEnv()) return;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { isMacOS } from '#common/helpers/platform.helpers.ts';
2+
import { isVSCodeEnv } from '#common/utils/env.utils';
3+
import { parentOrigin } from '#common/utils/vscode-bridge.utils';
4+
import { ShapeType } from '#core/model';
5+
import {
6+
type DragBridgeAppMessage,
7+
DRAG_BRIDGE_MESSAGE_TYPE,
8+
} from '@lemoncode/quickmock-bridge-protocol';
9+
10+
// macOS workaround for microsoft/vscode#193558: the native HTML5 drag preview
11+
// from the nested iframe is unreliable, so the shell paints its own preview
12+
// from a thumbnail data URL the iframe sends on drag-start.
13+
export const shouldUseMacWebviewDragBridge = (): boolean => {
14+
return isVSCodeEnv() && isMacOS();
15+
};
16+
17+
const postMessageToWebviewShell = (message: DragBridgeAppMessage): void => {
18+
window.parent.postMessage(message, parentOrigin);
19+
};
20+
21+
export const notifyDragStartToWebviewShell = (
22+
shapeType: ShapeType,
23+
thumbnailDataUrl: string
24+
): void => {
25+
if (!shouldUseMacWebviewDragBridge()) {
26+
return;
27+
}
28+
postMessageToWebviewShell({
29+
type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_START,
30+
payload: { shapeType, thumbnailDataUrl },
31+
});
32+
};
33+
34+
export const notifyDragMoveToWebviewShell = (
35+
clientX: number,
36+
clientY: number
37+
): void => {
38+
if (!shouldUseMacWebviewDragBridge()) {
39+
return;
40+
}
41+
postMessageToWebviewShell({
42+
type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_MOVE,
43+
payload: { clientX, clientY },
44+
});
45+
};
46+
47+
export const notifyDragEndToWebviewShell = (): void => {
48+
if (!shouldUseMacWebviewDragBridge()) {
49+
return;
50+
}
51+
postMessageToWebviewShell({ type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END });
52+
};
53+
54+
const thumbnailDataUrlCache = new Map<string, Promise<string>>();
55+
56+
export const loadThumbnailAsDataUrl = (src: string): Promise<string> => {
57+
const cached = thumbnailDataUrlCache.get(src);
58+
if (cached) return cached;
59+
const promise = fetch(src)
60+
.then(response => response.blob())
61+
.then(
62+
blob =>
63+
new Promise<string>((resolve, reject) => {
64+
const reader = new FileReader();
65+
reader.onload = () => resolve(reader.result as string);
66+
reader.onerror = () => reject(reader.error);
67+
reader.readAsDataURL(blob);
68+
})
69+
);
70+
thumbnailDataUrlCache.set(src, promise);
71+
return promise;
72+
};
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { ShapeType } from '#core/model';
2+
import { useCanvasContext } from '#core/providers';
3+
import {
4+
convertFromDivElementCoordsToKonvaCoords,
5+
getScrollFromDiv,
6+
isScreenPositionInsideDivElement,
7+
portScreenPositionToDivCoordinates,
8+
} from '#pods/canvas/canvas.util';
9+
import { calculateShapeOffsetToXDropCoordinate } from '#pods/canvas/use-monitor.business';
10+
import {
11+
type DragBridgeHostMessage,
12+
DRAG_BRIDGE_MESSAGE_TYPE,
13+
} from '@lemoncode/quickmock-bridge-protocol';
14+
import { useEffect } from 'react';
15+
import {
16+
notifyDragMoveToWebviewShell,
17+
shouldUseMacWebviewDragBridge,
18+
} from './mac-webview-drag-bridge.utils';
19+
20+
// macOS workaround for microsoft/vscode#193558: drag events on the inner
21+
// iframe route to the shell, so the shell-side bridge captures the drop and
22+
// forwards coordinates here; this reproduces the insertion useMonitorShape
23+
// performs natively on other platforms.
24+
25+
type GalleryDropMessage = Extract<
26+
DragBridgeHostMessage,
27+
{ type: typeof DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP }
28+
>;
29+
30+
const isGalleryDropMessage = (data: unknown): data is GalleryDropMessage => {
31+
if (!data || typeof data !== 'object') {
32+
return false;
33+
}
34+
const message = data as {
35+
type?: unknown;
36+
payload?: {
37+
shapeType?: unknown;
38+
clientX?: unknown;
39+
clientY?: unknown;
40+
};
41+
};
42+
return (
43+
message.type === DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP &&
44+
typeof message.payload?.shapeType === 'string' &&
45+
typeof message.payload?.clientX === 'number' &&
46+
typeof message.payload?.clientY === 'number'
47+
);
48+
};
49+
50+
export const useMacWebviewDragBridge = (
51+
dropRef: React.MutableRefObject<null>,
52+
addNewShape: (type: ShapeType, x: number, y: number) => void
53+
) => {
54+
const { stageRef } = useCanvasContext();
55+
56+
useEffect(() => {
57+
if (!shouldUseMacWebviewDragBridge()) {
58+
return;
59+
}
60+
61+
const handleGalleryDrop = (event: MessageEvent): void => {
62+
if (!isGalleryDropMessage(event.data)) {
63+
return;
64+
}
65+
const { shapeType, clientX, clientY } = event.data.payload;
66+
67+
const dropDivElement = dropRef.current as HTMLDivElement | null;
68+
const stageInstance = stageRef.current;
69+
if (!dropDivElement || !stageInstance) {
70+
return;
71+
}
72+
73+
const screenPosition = { x: clientX, y: clientY };
74+
if (!isScreenPositionInsideDivElement(dropDivElement, screenPosition)) {
75+
return;
76+
}
77+
78+
const relativeDivPosition = portScreenPositionToDivCoordinates(
79+
dropDivElement,
80+
screenPosition
81+
);
82+
const { scrollLeft, scrollTop } = getScrollFromDiv(
83+
dropRef as unknown as React.MutableRefObject<HTMLDivElement>
84+
);
85+
const konvaCoordinate = convertFromDivElementCoordsToKonvaCoords(
86+
stageInstance,
87+
{
88+
screenPosition,
89+
relativeDivPosition,
90+
scroll: { x: scrollLeft, y: scrollTop },
91+
}
92+
);
93+
94+
const shapeOffsetX = calculateShapeOffsetToXDropCoordinate(
95+
konvaCoordinate.x,
96+
shapeType as ShapeType
97+
);
98+
const positionX = konvaCoordinate.x - shapeOffsetX;
99+
const positionY = konvaCoordinate.y;
100+
101+
addNewShape(shapeType as ShapeType, positionX, positionY);
102+
};
103+
104+
window.addEventListener('message', handleGalleryDrop);
105+
return () => {
106+
window.removeEventListener('message', handleGalleryDrop);
107+
};
108+
}, []);
109+
110+
useEffect(() => {
111+
if (!shouldUseMacWebviewDragBridge()) {
112+
return;
113+
}
114+
const handleDragOver = (event: DragEvent): void => {
115+
notifyDragMoveToWebviewShell(event.clientX, event.clientY);
116+
};
117+
document.addEventListener('dragover', handleDragOver, true);
118+
return () => {
119+
document.removeEventListener('dragover', handleDragOver, true);
120+
};
121+
}, []);
122+
};

apps/web/src/pods/canvas/canvas.pod.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useTransform } from './use-transform.hook';
66
import { renderShapeComponent } from './shape-renderer';
77
import { useDropShape } from './use-drop-shape.hook';
88
import { useMonitorShape } from './use-monitor-shape.hook';
9+
import { useMacWebviewDragBridge } from '#core/vscode/use-mac-webview-drag-bridge.hook';
910
import classes from './canvas.pod.module.css';
1011
import { EditableComponent } from '#common/components/inline-edit';
1112
import { useSnapIn } from './use-snapin.hook';
@@ -58,6 +59,7 @@ export const CanvasPod = () => {
5859

5960
const { isDraggedOver, dropRef } = useDropShape();
6061
useMonitorShape(dropRef, addNewShapeAndSetSelected);
62+
useMacWebviewDragBridge(dropRef, addNewShapeAndSetSelected);
6163
useEffect(() => {
6264
if (dropRef.current) setDropRef(dropRef);
6365
}, [dropRef, setDropRef]);

apps/web/src/pods/canvas/canvas.util.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ export const portScreenPositionToDivCoordinates = (
2424
return { x, y };
2525
};
2626

27+
export const isScreenPositionInsideDivElement = (
28+
divElement: HTMLDivElement,
29+
screenPosition: Coord
30+
) => {
31+
const { left, right, top, bottom } = divElement.getBoundingClientRect();
32+
33+
return (
34+
screenPosition.x >= left &&
35+
screenPosition.x <= right &&
36+
screenPosition.y >= top &&
37+
screenPosition.y <= bottom
38+
);
39+
};
40+
2741
interface PositionInfo {
2842
screenPosition: Coord;
2943
relativeDivPosition: Coord;

packages/bridge-protocol/src/constant.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ export const APP_MESSAGE_TYPE = {
1212
WEBVIEW_READY: 'WEBVIEW_READY',
1313
NEW_FILE: 'qm:new-file',
1414
} as const;
15+
16+
export const DRAG_BRIDGE_MESSAGE_TYPE = {
17+
DRAG_START: 'qm:drag-start',
18+
DRAG_MOVE: 'qm:drag-move',
19+
DRAG_END: 'qm:drag-end',
20+
GALLERY_DROP: 'qm:gallery-drop',
21+
} as const;

0 commit comments

Comments
 (0)