Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/tired-mammals-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'quickmock': patch
---

Fix component-gallery drag-and-drop in the VS Code extension on macOS,
where HTML5 drag events targeting the inner iframe were dispatched to
the webview shell instead of into the iframe (microsoft/vscode#193558).
Linux and Windows are unaffected.
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ShapeDisplayName, ShapeType } from '#core/model';
import {
loadThumbnailAsDataUrl,
notifyDragEndToWebviewShell,
notifyDragStartToWebviewShell,
shouldUseMacWebviewDragBridge,
} from '#core/vscode/mac-webview-drag-bridge.utils';
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { useEffect, useRef, useState } from 'react';
Expand All @@ -14,8 +20,22 @@ interface Props {
export const ItemComponent: React.FC<Props> = props => {
const { item } = props;
const dragRef = useRef<HTMLDivElement>(null);
const thumbnailDataUrlRef = useRef<string | null>(null);
const [isDragging, setIsDragging] = useState(false);

useEffect(() => {
if (!shouldUseMacWebviewDragBridge()) return;
let cancelled = false;
loadThumbnailAsDataUrl(item.thumbnailSrc)
.then(dataUrl => {
if (!cancelled) thumbnailDataUrlRef.current = dataUrl;
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [item.thumbnailSrc]);

useEffect(() => {
const el = dragRef.current;

Expand All @@ -24,9 +44,37 @@ export const ItemComponent: React.FC<Props> = props => {
return draggable({
element: el,
getInitialData: () => ({ type: item.type }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
onDragStart: () => {
setIsDragging(true);
const dataUrl = thumbnailDataUrlRef.current ?? item.thumbnailSrc;
notifyDragStartToWebviewShell(item.type as ShapeType, dataUrl);
Comment on lines +49 to +50
},
onDrop: () => {
setIsDragging(false);
notifyDragEndToWebviewShell();
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
// Native drag image from the nested iframe is unreliable on macOS; the
// shell paints its own preview (see drag-bridge.ts), so suppress the
// native one with a 1×1 transparent element.
if (shouldUseMacWebviewDragBridge()) {
setCustomNativeDragPreview({
getOffset: () => ({ x: 0, y: 0 }),
render({ container }) {
const transparent = document.createElement('div');
transparent.style.width = '1px';
transparent.style.height = '1px';
transparent.style.opacity = '0';
container.appendChild(transparent);
return () => {
transparent.remove();
};
},
nativeSetDragImage,
});
return;
}

setCustomNativeDragPreview({
//Important: this numbers are the half of the width and height of var(--gallery-item-size)
// TODO, we may extract the size variable value from the HTML variable it self
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/common/helpers/platform.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export function isMacOS() {
return navigator.userAgent.toLowerCase().includes('mac');
interface NavigatorWithUserAgentData extends Navigator {
userAgentData?: { platform: string };
}

export function isWindowsOrLinux() {
return !isMacOS();
export function isMacOS(): boolean {
const userAgentData = (navigator as NavigatorWithUserAgentData).userAgentData;
if (userAgentData?.platform) {
return userAgentData.platform === 'macOS';
}
// Fallback for runtimes without UA-CH (Firefox, Safari, older Chromium).
return /Mac/i.test(navigator.userAgent);
}
2 changes: 1 addition & 1 deletion apps/web/src/common/utils/vscode-bridge.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const resolveParentOrigin = (): string => {
}
};

const parentOrigin = resolveParentOrigin();
export const parentOrigin = resolveParentOrigin();

export const sendToExtension = (msg: AppMessage): void => {
if (!isVSCodeEnv()) return;
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { isMacOS } from '#common/helpers/platform.helpers.ts';
import { isVSCodeEnv } from '#common/utils/env.utils';
import { parentOrigin } from '#common/utils/vscode-bridge.utils';
import { ShapeType } from '#core/model';
import {
type DragBridgeAppMessage,
DRAG_BRIDGE_MESSAGE_TYPE,
} from '@lemoncode/quickmock-bridge-protocol';

// macOS workaround for microsoft/vscode#193558: the native HTML5 drag preview
// from the nested iframe is unreliable, so the shell paints its own preview
// from a thumbnail data URL the iframe sends on drag-start.
export const shouldUseMacWebviewDragBridge = (): boolean => {
return isVSCodeEnv() && isMacOS();
};

const postMessageToWebviewShell = (message: DragBridgeAppMessage): void => {
window.parent.postMessage(message, parentOrigin);
};

export const notifyDragStartToWebviewShell = (
shapeType: ShapeType,
thumbnailDataUrl: string
): void => {
if (!shouldUseMacWebviewDragBridge()) {
return;
}
postMessageToWebviewShell({
type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_START,
payload: { shapeType, thumbnailDataUrl },
});
};

export const notifyDragMoveToWebviewShell = (
clientX: number,
clientY: number
): void => {
if (!shouldUseMacWebviewDragBridge()) {
return;
}
postMessageToWebviewShell({
type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_MOVE,
payload: { clientX, clientY },
});
};

export const notifyDragEndToWebviewShell = (): void => {
if (!shouldUseMacWebviewDragBridge()) {
return;
}
postMessageToWebviewShell({ type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END });
};

const thumbnailDataUrlCache = new Map<string, Promise<string>>();

export const loadThumbnailAsDataUrl = (src: string): Promise<string> => {
const cached = thumbnailDataUrlCache.get(src);
if (cached) return cached;
const promise = fetch(src)
.then(response => response.blob())
.then(
blob =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
})
);
thumbnailDataUrlCache.set(src, promise);
return promise;
};
117 changes: 117 additions & 0 deletions apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { ShapeType } from '#core/model';
import { useCanvasContext } from '#core/providers';
import {
convertFromDivElementCoordsToKonvaCoords,
getScrollFromDiv,
portScreenPositionToDivCoordinates,
} from '#pods/canvas/canvas.util';
import { calculateShapeOffsetToXDropCoordinate } from '#pods/canvas/use-monitor.business';
import {
type DragBridgeHostMessage,
DRAG_BRIDGE_MESSAGE_TYPE,
} from '@lemoncode/quickmock-bridge-protocol';
import { useEffect } from 'react';
import {
notifyDragMoveToWebviewShell,
shouldUseMacWebviewDragBridge,
} from './mac-webview-drag-bridge.utils';

// macOS workaround for microsoft/vscode#193558: drag events on the inner
// iframe route to the shell, so the shell-side bridge captures the drop and
// forwards coordinates here; this reproduces the insertion useMonitorShape
// performs natively on other platforms.

type GalleryDropMessage = Extract<
DragBridgeHostMessage,
{ type: typeof DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP }
>;

const isGalleryDropMessage = (data: unknown): data is GalleryDropMessage => {
if (!data || typeof data !== 'object') {
return false;
}
const message = data as {
type?: unknown;
payload?: {
shapeType?: unknown;
clientX?: unknown;
clientY?: unknown;
};
};
return (
message.type === DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP &&
typeof message.payload?.shapeType === 'string' &&
typeof message.payload?.clientX === 'number' &&
typeof message.payload?.clientY === 'number'
);
};

export const useMacWebviewDragBridge = (
dropRef: React.MutableRefObject<null>,
addNewShape: (type: ShapeType, x: number, y: number) => void
) => {
const { stageRef } = useCanvasContext();

useEffect(() => {
if (!shouldUseMacWebviewDragBridge()) {
return;
}

const handleGalleryDrop = (event: MessageEvent): void => {
if (!isGalleryDropMessage(event.data)) {
return;
}
const { shapeType, clientX, clientY } = event.data.payload;

const dropDivElement = dropRef.current as HTMLDivElement | null;
const stageInstance = stageRef.current;
if (!dropDivElement || !stageInstance) {
return;
}

const screenPosition = { x: clientX, y: clientY };
const relativeDivPosition = portScreenPositionToDivCoordinates(
dropDivElement,
screenPosition
);
Comment on lines +72 to +76
const { scrollLeft, scrollTop } = getScrollFromDiv(
dropRef as unknown as React.MutableRefObject<HTMLDivElement>
);
const konvaCoordinate = convertFromDivElementCoordsToKonvaCoords(
stageInstance,
{
screenPosition,
relativeDivPosition,
scroll: { x: scrollLeft, y: scrollTop },
}
);

const shapeOffsetX = calculateShapeOffsetToXDropCoordinate(
konvaCoordinate.x,
shapeType as ShapeType
);
const positionX = konvaCoordinate.x - shapeOffsetX;
const positionY = konvaCoordinate.y;

addNewShape(shapeType as ShapeType, positionX, positionY);
};

window.addEventListener('message', handleGalleryDrop);
return () => {
window.removeEventListener('message', handleGalleryDrop);
};
}, []);

useEffect(() => {
if (!shouldUseMacWebviewDragBridge()) {
return;
}
const handleDragOver = (event: DragEvent): void => {
notifyDragMoveToWebviewShell(event.clientX, event.clientY);
};
document.addEventListener('dragover', handleDragOver, true);
return () => {
document.removeEventListener('dragover', handleDragOver, true);
};
}, []);
};
2 changes: 2 additions & 0 deletions apps/web/src/pods/canvas/canvas.pod.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTransform } from './use-transform.hook';
import { renderShapeComponent } from './shape-renderer';
import { useDropShape } from './use-drop-shape.hook';
import { useMonitorShape } from './use-monitor-shape.hook';
import { useMacWebviewDragBridge } from '#core/vscode/use-mac-webview-drag-bridge.hook';
import classes from './canvas.pod.module.css';
import { EditableComponent } from '#common/components/inline-edit';
import { useSnapIn } from './use-snapin.hook';
Expand Down Expand Up @@ -58,6 +59,7 @@ export const CanvasPod = () => {

const { isDraggedOver, dropRef } = useDropShape();
useMonitorShape(dropRef, addNewShapeAndSetSelected);
useMacWebviewDragBridge(dropRef, addNewShapeAndSetSelected);
useEffect(() => {
if (dropRef.current) setDropRef(dropRef);
}, [dropRef, setDropRef]);
Expand Down
7 changes: 7 additions & 0 deletions packages/bridge-protocol/src/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ export const APP_MESSAGE_TYPE = {
WEBVIEW_READY: 'WEBVIEW_READY',
NEW_FILE: 'qm:new-file',
} as const;

export const DRAG_BRIDGE_MESSAGE_TYPE = {
DRAG_START: 'qm:drag-start',
DRAG_MOVE: 'qm:drag-move',
DRAG_END: 'qm:drag-end',
GALLERY_DROP: 'qm:gallery-drop',
} as const;
38 changes: 37 additions & 1 deletion packages/bridge-protocol/src/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { APP_MESSAGE_TYPE, HOST_MESSAGE_TYPE } from './constant';
import type {
APP_MESSAGE_TYPE,
DRAG_BRIDGE_MESSAGE_TYPE,
HOST_MESSAGE_TYPE,
} from './constant';

export interface ContentBbox {
x: number;
Expand Down Expand Up @@ -39,3 +43,35 @@ export type AppMessage =

export type PayloadOf<U extends { type: string }, T extends U['type']> =
Extract<U, { type: T }> extends { payload: infer P } ? P : undefined;

export interface DragStartPayload {
shapeType: string;
thumbnailDataUrl: string;
}

export interface DragMovePayload {
clientX: number;
clientY: number;
}

export interface GalleryDropPayload {
shapeType: string;
clientX: number;
clientY: number;
}

export type DragBridgeAppMessage =
| {
type: typeof DRAG_BRIDGE_MESSAGE_TYPE.DRAG_START;
payload: DragStartPayload;
}
| {
type: typeof DRAG_BRIDGE_MESSAGE_TYPE.DRAG_MOVE;
payload: DragMovePayload;
}
| { type: typeof DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END };

export type DragBridgeHostMessage = {
type: typeof DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP;
payload: GalleryDropPayload;
};
2 changes: 1 addition & 1 deletion packages/vscode-extension/src/editor/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const getHtml = (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; frame-src ${appOrigin}; connect-src ${appOrigin} ${wsOrigin}; script-src ${webview.cspSource}; style-src 'unsafe-inline';" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; frame-src ${appOrigin}; connect-src ${appOrigin} ${wsOrigin}; script-src ${webview.cspSource}; style-src 'unsafe-inline'; img-src data:;" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
Expand Down
Loading
Loading