From 0bfd6f00413d0bb3e02d77434a9c610be2ee641e Mon Sep 17 00:00:00 2001 From: Ivanruii Date: Tue, 12 May 2026 13:29:37 +0200 Subject: [PATCH 1/5] feat: implement macOS drag-and-drop bridge for VS Code webview --- .../gallery/components/item-component.tsx | 14 ++- .../src/common/utils/vscode-bridge.utils.ts | 2 +- .../vscode/mac-webview-drag-bridge.utils.ts | 33 ++++++ .../use-mac-webview-drag-bridge.hook.ts | 104 +++++++++++++++++ apps/web/src/pods/canvas/canvas.pod.tsx | 2 + packages/bridge-protocol/src/constant.ts | 6 + packages/bridge-protocol/src/model.ts | 28 ++++- .../vscode-extension/src/webview/bridge.ts | 4 + .../src/webview/drag-bridge.ts | 109 ++++++++++++++++++ packages/vscode-extension/src/webview/main.ts | 2 + 10 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts create mode 100644 apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts create mode 100644 packages/vscode-extension/src/webview/drag-bridge.ts diff --git a/apps/web/src/common/components/gallery/components/item-component.tsx b/apps/web/src/common/components/gallery/components/item-component.tsx index 818437c8..0c7f71f9 100644 --- a/apps/web/src/common/components/gallery/components/item-component.tsx +++ b/apps/web/src/common/components/gallery/components/item-component.tsx @@ -1,4 +1,8 @@ import { ShapeDisplayName, ShapeType } from '#core/model'; +import { + notifyDragEndToWebviewShell, + notifyDragStartToWebviewShell, +} 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'; @@ -24,8 +28,14 @@ export const ItemComponent: React.FC = props => { return draggable({ element: el, getInitialData: () => ({ type: item.type }), - onDragStart: () => setIsDragging(true), - onDrop: () => setIsDragging(false), + onDragStart: () => { + setIsDragging(true); + notifyDragStartToWebviewShell(item.type as ShapeType); + }, + onDrop: () => { + setIsDragging(false); + notifyDragEndToWebviewShell(); + }, onGenerateDragPreview: ({ nativeSetDragImage }) => { setCustomNativeDragPreview({ //Important: this numbers are the half of the width and height of var(--gallery-item-size) diff --git a/apps/web/src/common/utils/vscode-bridge.utils.ts b/apps/web/src/common/utils/vscode-bridge.utils.ts index 473bc1a3..cfd347d8 100644 --- a/apps/web/src/common/utils/vscode-bridge.utils.ts +++ b/apps/web/src/common/utils/vscode-bridge.utils.ts @@ -22,7 +22,7 @@ const resolveParentOrigin = (): string => { } }; -const parentOrigin = resolveParentOrigin(); +export const parentOrigin = resolveParentOrigin(); export const sendToExtension = (msg: AppMessage): void => { if (!isVSCodeEnv()) return; diff --git a/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts b/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts new file mode 100644 index 00000000..b79b2467 --- /dev/null +++ b/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts @@ -0,0 +1,33 @@ +import { isMacOS } from '#common/helpers/platform.helpers'; +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'; + +export const shouldUseMacWebviewDragBridge = (): boolean => { + return isVSCodeEnv() && isMacOS(); +}; + +const postMessageToWebviewShell = (message: DragBridgeAppMessage): void => { + window.parent.postMessage(message, parentOrigin); +}; + +export const notifyDragStartToWebviewShell = (shapeType: ShapeType): void => { + if (!shouldUseMacWebviewDragBridge()) { + return; + } + postMessageToWebviewShell({ + type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_START, + payload: { shapeType }, + }); +}; + +export const notifyDragEndToWebviewShell = (): void => { + if (!shouldUseMacWebviewDragBridge()) { + return; + } + postMessageToWebviewShell({ type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END }); +}; diff --git a/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts b/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts new file mode 100644 index 00000000..3df0ec9f --- /dev/null +++ b/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts @@ -0,0 +1,104 @@ +import { useEffect } from 'react'; +import { + type DragBridgeHostMessage, + DRAG_BRIDGE_MESSAGE_TYPE, +} from '@lemoncode/quickmock-bridge-protocol'; +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 { shouldUseMacWebviewDragBridge } from './mac-webview-drag-bridge.utils'; + +// macOS-only workaround for microsoft/vscode#193558: HTML5 drag events +// targeting the inner iframe are dispatched to the iframe element in the +// shell, so pragmatic-drag-and-drop's drop target inside the iframe never +// fires. The shell-side bridge in vscode-extension/src/webview/drag-bridge.ts +// captures the drop and forwards the coordinates back to us via the +// DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP message, and we reproduce the same +// insertion that useMonitorShape would do 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, + 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 + ); + const { scrollLeft, scrollTop } = getScrollFromDiv( + dropRef as unknown as React.MutableRefObject + ); + 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); + }; + }, []); +}; diff --git a/apps/web/src/pods/canvas/canvas.pod.tsx b/apps/web/src/pods/canvas/canvas.pod.tsx index f922cbc7..697a5e7a 100644 --- a/apps/web/src/pods/canvas/canvas.pod.tsx +++ b/apps/web/src/pods/canvas/canvas.pod.tsx @@ -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'; @@ -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]); diff --git a/packages/bridge-protocol/src/constant.ts b/packages/bridge-protocol/src/constant.ts index b96aa128..2afd0822 100644 --- a/packages/bridge-protocol/src/constant.ts +++ b/packages/bridge-protocol/src/constant.ts @@ -12,3 +12,9 @@ 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_END: 'qm:drag-end', + GALLERY_DROP: 'qm:gallery-drop', +} as const; diff --git a/packages/bridge-protocol/src/model.ts b/packages/bridge-protocol/src/model.ts index ccbdedb2..dd44673f 100644 --- a/packages/bridge-protocol/src/model.ts +++ b/packages/bridge-protocol/src/model.ts @@ -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; @@ -39,3 +43,25 @@ export type AppMessage = export type PayloadOf = Extract extends { payload: infer P } ? P : undefined; + +export interface DragStartPayload { + shapeType: string; +} + +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_END }; + +export type DragBridgeHostMessage = { + type: typeof DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP; + payload: GalleryDropPayload; +}; diff --git a/packages/vscode-extension/src/webview/bridge.ts b/packages/vscode-extension/src/webview/bridge.ts index 513d71f6..328d00f6 100644 --- a/packages/vscode-extension/src/webview/bridge.ts +++ b/packages/vscode-extension/src/webview/bridge.ts @@ -3,6 +3,7 @@ import { HOST_MESSAGE_TYPE, type HostMessage, } from '@lemoncode/quickmock-bridge-protocol'; +import { isDragBridgeMessage } from './drag-bridge'; // Reference: https://code.visualstudio.com/api/extension-guides/webview#loading-local-content declare function acquireVsCodeApi(): { postMessage(msg: AppMessage): void }; @@ -21,6 +22,9 @@ export const setupBridge = ( ): void => { window.addEventListener('message', (event: MessageEvent) => { if (event.origin === appOrigin) { + if (isDragBridgeMessage(event.data)) { + return; + } vscode.postMessage(event.data as AppMessage); } else { const msg = event.data as HostMessage; diff --git a/packages/vscode-extension/src/webview/drag-bridge.ts b/packages/vscode-extension/src/webview/drag-bridge.ts new file mode 100644 index 00000000..4a670b51 --- /dev/null +++ b/packages/vscode-extension/src/webview/drag-bridge.ts @@ -0,0 +1,109 @@ +import { + type DragBridgeAppMessage, + DRAG_BRIDGE_MESSAGE_TYPE, +} from '@lemoncode/quickmock-bridge-protocol'; + +// Workaround for macOS-only VS Code webview bug: HTML5 drag events targeting +// the inner iframe are dispatched to the iframe element in the shell instead +// of to the iframe's contents, so the drag-and-drop library inside the iframe +// never sees `dragover`/`drop` (see microsoft/vscode#193558). +// +// On macOS only, this bridge captures those events here in the shell and +// forwards the drop coordinates back to the iframe via postMessage. On +// Linux/Windows the native HTML5 path works inside the iframe, so this bridge +// is a no-op there to keep behaviour unchanged. + +type DragStartMessage = Extract< + DragBridgeAppMessage, + { type: typeof DRAG_BRIDGE_MESSAGE_TYPE.DRAG_START } +>; + +type DragEndMessage = Extract< + DragBridgeAppMessage, + { type: typeof DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END } +>; + +const isRunningOnMacOS = (): boolean => { + return navigator.userAgent.toLowerCase().includes('mac'); +}; + +const isDragStartMessage = (data: unknown): data is DragStartMessage => { + if (!data || typeof data !== 'object') { + return false; + } + const message = data as { type?: unknown; payload?: { shapeType?: unknown } }; + return ( + message.type === DRAG_BRIDGE_MESSAGE_TYPE.DRAG_START && + typeof message.payload?.shapeType === 'string' + ); +}; + +const isDragEndMessage = (data: unknown): data is DragEndMessage => { + if (!data || typeof data !== 'object') { + return false; + } + return ( + (data as { type?: unknown }).type === DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END + ); +}; + +export const isDragBridgeMessage = (data: unknown): boolean => { + return isDragStartMessage(data) || isDragEndMessage(data); +}; + +export const setupDragBridge = ( + iframe: HTMLIFrameElement, + appOrigin: string +): void => { + if (!isRunningOnMacOS()) { + return; + } + + let activeShapeType: string | null = null; + + const handleIncomingMessage = (event: MessageEvent): void => { + if (event.origin !== appOrigin) { + return; + } + if (isDragStartMessage(event.data)) { + activeShapeType = event.data.payload.shapeType; + return; + } + if (isDragEndMessage(event.data)) { + activeShapeType = null; + } + }; + + const handleDragOver = (event: DragEvent): void => { + if (activeShapeType === null) { + return; + } + // The browser only fires `drop` on a target whose `dragover` called + // preventDefault, so this is required to receive the drop event below. + event.preventDefault(); + }; + + const handleDrop = (event: DragEvent): void => { + if (activeShapeType === null) { + return; + } + event.preventDefault(); + const iframeBoundingRect = iframe.getBoundingClientRect(); + iframe.contentWindow?.postMessage( + { + type: DRAG_BRIDGE_MESSAGE_TYPE.GALLERY_DROP, + payload: { + shapeType: activeShapeType, + clientX: event.clientX - iframeBoundingRect.left, + clientY: event.clientY - iframeBoundingRect.top, + }, + }, + appOrigin + ); + activeShapeType = null; + }; + + window.addEventListener('message', handleIncomingMessage); + document.addEventListener('dragover', handleDragOver, true); + document.addEventListener('drop', handleDrop, true); +}; diff --git a/packages/vscode-extension/src/webview/main.ts b/packages/vscode-extension/src/webview/main.ts index 36634d59..bf77a2ea 100644 --- a/packages/vscode-extension/src/webview/main.ts +++ b/packages/vscode-extension/src/webview/main.ts @@ -1,4 +1,5 @@ import { setupBridge } from './bridge'; +import { setupDragBridge } from './drag-bridge'; import { setupThemeSync } from './theme'; const appUrl = document.body.dataset.appUrl; @@ -20,3 +21,4 @@ document.body.appendChild(iframe); setupBridge(iframe, appOrigin); setupThemeSync(iframe, appOrigin); +setupDragBridge(iframe, appOrigin); From c955c05cf696079defe52410d4241731115d9ce4 Mon Sep 17 00:00:00 2001 From: Ivanruii Date: Tue, 12 May 2026 13:36:23 +0200 Subject: [PATCH 2/5] chore: added changeset --- .changeset/tired-mammals-cough.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/tired-mammals-cough.md diff --git a/.changeset/tired-mammals-cough.md b/.changeset/tired-mammals-cough.md new file mode 100644 index 00000000..a3cfa3c8 --- /dev/null +++ b/.changeset/tired-mammals-cough.md @@ -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. \ No newline at end of file From 113cd570c3629813e150a8925acebc5843e78ef4 Mon Sep 17 00:00:00 2001 From: Ivanruii Date: Tue, 12 May 2026 13:45:21 +0200 Subject: [PATCH 3/5] feat: enhance macOS detection --- apps/web/src/common/helpers/platform.helpers.ts | 13 +++++++++---- .../vscode-extension/src/webview/drag-bridge.ts | 8 +++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/common/helpers/platform.helpers.ts b/apps/web/src/common/helpers/platform.helpers.ts index ef7bbf0e..92099714 100644 --- a/apps/web/src/common/helpers/platform.helpers.ts +++ b/apps/web/src/common/helpers/platform.helpers.ts @@ -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); } diff --git a/packages/vscode-extension/src/webview/drag-bridge.ts b/packages/vscode-extension/src/webview/drag-bridge.ts index 4a670b51..2f5892be 100644 --- a/packages/vscode-extension/src/webview/drag-bridge.ts +++ b/packages/vscode-extension/src/webview/drag-bridge.ts @@ -23,8 +23,14 @@ type DragEndMessage = Extract< { type: typeof DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END } >; +interface NavigatorWithUserAgentData extends Navigator { + userAgentData: { platform: string }; +} + const isRunningOnMacOS = (): boolean => { - return navigator.userAgent.toLowerCase().includes('mac'); + return ( + (navigator as NavigatorWithUserAgentData).userAgentData.platform === 'macOS' + ); }; const isDragStartMessage = (data: unknown): data is DragStartMessage => { From d6fa58338b9b384be449de95d48acb2bcaaeb580 Mon Sep 17 00:00:00 2001 From: Ivanruii Date: Wed, 13 May 2026 14:11:52 +0200 Subject: [PATCH 4/5] feat: implement macOS drag-and-drop enhancements for VS Code webview --- .../gallery/components/item-component.tsx | 42 ++++- .../vscode/mac-webview-drag-bridge.utils.ts | 51 +++++- .../use-mac-webview-drag-bridge.hook.ts | 28 +++- packages/bridge-protocol/src/constant.ts | 1 + packages/bridge-protocol/src/model.ts | 10 ++ packages/vscode-extension/src/editor/panel.ts | 2 +- .../src/webview/drag-bridge.ts | 152 +++++++++++++++--- 7 files changed, 253 insertions(+), 33 deletions(-) diff --git a/apps/web/src/common/components/gallery/components/item-component.tsx b/apps/web/src/common/components/gallery/components/item-component.tsx index 0c7f71f9..01b4c222 100644 --- a/apps/web/src/common/components/gallery/components/item-component.tsx +++ b/apps/web/src/common/components/gallery/components/item-component.tsx @@ -1,7 +1,9 @@ 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'; @@ -18,8 +20,22 @@ interface Props { export const ItemComponent: React.FC = props => { const { item } = props; const dragRef = useRef(null); + const thumbnailDataUrlRef = useRef(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; @@ -30,13 +46,37 @@ export const ItemComponent: React.FC = props => { getInitialData: () => ({ type: item.type }), onDragStart: () => { setIsDragging(true); - notifyDragStartToWebviewShell(item.type as ShapeType); + const dataUrl = thumbnailDataUrlRef.current ?? item.thumbnailSrc; + notifyDragStartToWebviewShell(item.type as ShapeType, dataUrl); }, onDrop: () => { setIsDragging(false); notifyDragEndToWebviewShell(); }, onGenerateDragPreview: ({ nativeSetDragImage }) => { + // On macOS inside the VS Code webview the native drag image snapshot + // taken from this nested iframe is unreliable: it renders inconsistently + // or not at all because the OS captures the drag image at the shell + // level. We suppress the broken native preview with a 1x1 transparent + // element and the shell paints its own preview (see drag-bridge.ts). + 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 diff --git a/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts b/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts index b79b2467..12c00138 100644 --- a/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts +++ b/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts @@ -1,4 +1,4 @@ -import { isMacOS } from '#common/helpers/platform.helpers'; +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'; @@ -7,6 +7,15 @@ import { DRAG_BRIDGE_MESSAGE_TYPE, } from '@lemoncode/quickmock-bridge-protocol'; +// In the VS Code webview the app runs inside a nested iframe. The native HTML5 +// drag preview taken from inside that iframe is unreliable on macOS (the OS +// captures the drag image at the shell level and the snapshot is missing or +// inconsistent — see microsoft/vscode#193558). To get a consistent preview on +// every platform we suppress the iframe-side native preview and let the shell +// paint its own preview from a thumbnail data URL the iframe sends on +// drag-start; the shell keeps the preview under the cursor using `dragover` +// events forwarded from the iframe (on macOS the iframe never receives them so +// the shell falls back to its own native `dragover`). export const shouldUseMacWebviewDragBridge = (): boolean => { return isVSCodeEnv() && isMacOS(); }; @@ -15,13 +24,29 @@ const postMessageToWebviewShell = (message: DragBridgeAppMessage): void => { window.parent.postMessage(message, parentOrigin); }; -export const notifyDragStartToWebviewShell = (shapeType: ShapeType): void => { +export const notifyDragStartToWebviewShell = ( + shapeType: ShapeType, + thumbnailDataUrl: string +): void => { if (!shouldUseMacWebviewDragBridge()) { return; } postMessageToWebviewShell({ type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_START, - payload: { shapeType }, + 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 }, }); }; @@ -31,3 +56,23 @@ export const notifyDragEndToWebviewShell = (): void => { } postMessageToWebviewShell({ type: DRAG_BRIDGE_MESSAGE_TYPE.DRAG_END }); }; + +const thumbnailDataUrlCache = new Map>(); + +export const loadThumbnailAsDataUrl = (src: string): Promise => { + const cached = thumbnailDataUrlCache.get(src); + if (cached) return cached; + const promise = fetch(src) + .then(response => response.blob()) + .then( + blob => + new Promise((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; +}; diff --git a/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts b/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts index 3df0ec9f..e9133761 100644 --- a/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts +++ b/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts @@ -1,8 +1,3 @@ -import { useEffect } from 'react'; -import { - type DragBridgeHostMessage, - DRAG_BRIDGE_MESSAGE_TYPE, -} from '@lemoncode/quickmock-bridge-protocol'; import { ShapeType } from '#core/model'; import { useCanvasContext } from '#core/providers'; import { @@ -11,7 +6,15 @@ import { portScreenPositionToDivCoordinates, } from '#pods/canvas/canvas.util'; import { calculateShapeOffsetToXDropCoordinate } from '#pods/canvas/use-monitor.business'; -import { shouldUseMacWebviewDragBridge } from './mac-webview-drag-bridge.utils'; +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-only workaround for microsoft/vscode#193558: HTML5 drag events // targeting the inner iframe are dispatched to the iframe element in the @@ -101,4 +104,17 @@ export const useMacWebviewDragBridge = ( 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); + }; + }, []); }; diff --git a/packages/bridge-protocol/src/constant.ts b/packages/bridge-protocol/src/constant.ts index 2afd0822..182be95b 100644 --- a/packages/bridge-protocol/src/constant.ts +++ b/packages/bridge-protocol/src/constant.ts @@ -15,6 +15,7 @@ export const APP_MESSAGE_TYPE = { 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; diff --git a/packages/bridge-protocol/src/model.ts b/packages/bridge-protocol/src/model.ts index dd44673f..35d7ad9c 100644 --- a/packages/bridge-protocol/src/model.ts +++ b/packages/bridge-protocol/src/model.ts @@ -46,6 +46,12 @@ export type PayloadOf = export interface DragStartPayload { shapeType: string; + thumbnailDataUrl: string; +} + +export interface DragMovePayload { + clientX: number; + clientY: number; } export interface GalleryDropPayload { @@ -59,6 +65,10 @@ 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 = { diff --git a/packages/vscode-extension/src/editor/panel.ts b/packages/vscode-extension/src/editor/panel.ts index f943115f..b60f5ad3 100644 --- a/packages/vscode-extension/src/editor/panel.ts +++ b/packages/vscode-extension/src/editor/panel.ts @@ -18,7 +18,7 @@ export const getHtml = ( - +