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 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..cf4d2f56 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,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'; @@ -14,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; @@ -24,9 +44,37 @@ export const ItemComponent: React.FC = 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); + }, + 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 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/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..08f09f40 --- /dev/null +++ b/apps/web/src/core/vscode/mac-webview-drag-bridge.utils.ts @@ -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>(); + +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 new file mode 100644 index 00000000..3c42106d --- /dev/null +++ b/apps/web/src/core/vscode/use-mac-webview-drag-bridge.hook.ts @@ -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, + 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); + }; + }, []); + + 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/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..182be95b 100644 --- a/packages/bridge-protocol/src/constant.ts +++ b/packages/bridge-protocol/src/constant.ts @@ -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; diff --git a/packages/bridge-protocol/src/model.ts b/packages/bridge-protocol/src/model.ts index ccbdedb2..35d7ad9c 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,35 @@ export type AppMessage = export type PayloadOf = Extract 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; +}; 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 = ( - +