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..0193493c 100644 --- a/apps/web/src/common/components/gallery/components/item-component.tsx +++ b/apps/web/src/common/components/gallery/components/item-component.tsx @@ -21,12 +21,44 @@ export const ItemComponent: React.FC = props => { invariant(el); - return draggable({ + // Workaround para bug de macOS en webview de VS Code (issue #193558 / #256444). + // VS Code intercepta drags HTML5 que no tengan un MIME "conocido" en su + // dataTransfer y evita que los eventos `dragover`/`drop` se entreguen al + // iframe interior. Añadiendo `text/plain` en fase captura, antes de que + // PDND ejecute su propio handler de dragstart, marcamos el drag como + // "estándar" y el routing nativo pasa al iframe correctamente. + const macOsWebviewMimeWorkaround = (ev: DragEvent) => { + if (!ev.dataTransfer) return; + try { + ev.dataTransfer.setData('text/plain', `quickmock-shape:${item.type}`); + ev.dataTransfer.effectAllowed = 'move'; + } catch { + // setData solo es válido durante dragstart; ignoramos si ya se cerró. + } + }; + el.addEventListener('dragstart', macOsWebviewMimeWorkaround, true); + + const cleanupPdnd = draggable({ element: el, getInitialData: () => ({ type: item.type }), - onDragStart: () => setIsDragging(true), - onDrop: () => setIsDragging(false), + onDragStart: () => { + console.log('[DRAG-START]', item.type); + setIsDragging(true); + // Signal to the webview shell (parent) which shape type is being + // dragged, so it can forward the drop coordinates back. Required + // workaround for macOS VS Code webview drag bug. + window.parent.postMessage( + { type: 'qm:drag-start', shapeType: item.type }, + '*' + ); + }, + onDrop: () => { + console.log('[DRAG-END]', item.type); + setIsDragging(false); + window.parent.postMessage({ type: 'qm:drag-end' }, '*'); + }, onGenerateDragPreview: ({ nativeSetDragImage }) => { + console.log('[GEN-PREVIEW]', item.type); 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 @@ -56,6 +88,11 @@ export const ItemComponent: React.FC = props => { }); }, }); + + return () => { + el.removeEventListener('dragstart', macOsWebviewMimeWorkaround, true); + cleanupPdnd(); + }; }, []); return ( diff --git a/apps/web/src/core/providers/canvas/canvas.provider.tsx b/apps/web/src/core/providers/canvas/canvas.provider.tsx index 07871155..14490bde 100644 --- a/apps/web/src/core/providers/canvas/canvas.provider.tsx +++ b/apps/web/src/core/providers/canvas/canvas.provider.tsx @@ -249,6 +249,7 @@ export const CanvasProvider: React.FC = props => { y: number, otherProps?: OtherProps ) => { + console.log('drop', type, x, y); if (loadSampleDocument) { setLoadSampleDocument(false); } diff --git a/apps/web/src/pods/canvas/canvas.pod.tsx b/apps/web/src/pods/canvas/canvas.pod.tsx index f922cbc7..b0aa529b 100644 --- a/apps/web/src/pods/canvas/canvas.pod.tsx +++ b/apps/web/src/pods/canvas/canvas.pod.tsx @@ -5,6 +5,7 @@ import { Layer, Line, Rect, Stage, Transformer } from 'react-konva'; import { useTransform } from './use-transform.hook'; import { renderShapeComponent } from './shape-renderer'; import { useDropShape } from './use-drop-shape.hook'; +import { useMacWebviewDragBridge } from './use-mac-webview-drag-bridge.hook'; import { useMonitorShape } from './use-monitor-shape.hook'; import classes from './canvas.pod.module.css'; import { EditableComponent } from '#common/components/inline-edit'; @@ -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/apps/web/src/pods/canvas/use-drop-shape.hook.ts b/apps/web/src/pods/canvas/use-drop-shape.hook.ts index cd7505d5..88ff47d2 100644 --- a/apps/web/src/pods/canvas/use-drop-shape.hook.ts +++ b/apps/web/src/pods/canvas/use-drop-shape.hook.ts @@ -7,17 +7,98 @@ export const useDropShape = () => { const dropRef = useRef(null); useEffect(() => { - const el = dropRef.current; + const el = dropRef.current as HTMLElement | null; invariant(el); - return dropTargetForElements({ + const sniff = (ev: DragEvent) => { + console.log(`[NATIVE-${ev.type}]`, { + target: (ev.target as HTMLElement)?.tagName, + targetClass: (ev.target as HTMLElement)?.className, + currentTarget: (ev.currentTarget as HTMLElement)?.tagName, + defaultPrevented: ev.defaultPrevented, + clientX: ev.clientX, + clientY: ev.clientY, + }); + }; + el.addEventListener('dragenter', sniff); + el.addEventListener('dragover', sniff); + el.addEventListener('dragleave', sniff); + el.addEventListener('drop', sniff); + + const docSniff = (ev: DragEvent) => { + const elAtPoint = document.elementFromPoint(ev.clientX, ev.clientY); + console.log(`[DOC-${ev.type}]`, { + target: (ev.target as HTMLElement)?.tagName, + elementAtPoint: elAtPoint?.tagName, + elementAtPointClass: elAtPoint?.className, + }); + }; + document.addEventListener('dragenter', docSniff, true); + document.addEventListener('dragover', docSniff, true); + document.addEventListener('drop', docSniff, true); + document.addEventListener('dragend', docSniff, true); + + const winSniff = (ev: DragEvent) => { + console.log(`[WIN-${ev.type}]`, { + target: (ev.target as HTMLElement)?.tagName, + clientX: ev.clientX, + clientY: ev.clientY, + }); + }; + window.addEventListener('dragenter', winSniff, true); + window.addEventListener('dragover', winSniff, true); + window.addEventListener('drop', winSniff, true); + window.addEventListener('dragend', winSniff, true); + + const bodySniff = (ev: DragEvent) => { + console.log(`[BODY-${ev.type}]`); + }; + document.body.addEventListener('dragenter', bodySniff, true); + document.body.addEventListener('dragover', bodySniff, true); + document.body.addEventListener('drop', bodySniff, true); + document.body.addEventListener('dragend', bodySniff, true); + + const cleanupSniff = () => { + el.removeEventListener('dragenter', sniff); + el.removeEventListener('dragover', sniff); + el.removeEventListener('dragleave', sniff); + el.removeEventListener('drop', sniff); + document.removeEventListener('dragenter', docSniff, true); + document.removeEventListener('dragover', docSniff, true); + document.removeEventListener('drop', docSniff, true); + document.removeEventListener('dragend', docSniff, true); + window.removeEventListener('dragenter', winSniff, true); + window.removeEventListener('dragover', winSniff, true); + window.removeEventListener('drop', winSniff, true); + window.removeEventListener('dragend', winSniff, true); + document.body.removeEventListener('dragenter', bodySniff, true); + document.body.removeEventListener('dragover', bodySniff, true); + document.body.removeEventListener('drop', bodySniff, true); + document.body.removeEventListener('dragend', bodySniff, true); + }; + + const cleanupPdnd = dropTargetForElements({ element: el, getData: () => ({ destination: 'canvas' }), - onDragEnter: () => setIsDraggedOver(true), - onDragLeave: () => setIsDraggedOver(false), - onDrop: () => setIsDraggedOver(false), + onDragEnter: () => { + console.log('[DT-ENTER] canvas drop target'); + setIsDraggedOver(true); + }, + onDragLeave: () => { + console.log('[DT-LEAVE] canvas drop target'); + setIsDraggedOver(false); + }, + onDrop: () => { + console.log('[DT-DROP] canvas drop target'); + setIsDraggedOver(false); + }, }); + + return () => { + cleanupSniff(); + cleanupPdnd(); + }; }); return { dropRef, isDraggedOver }; diff --git a/apps/web/src/pods/canvas/use-mac-webview-drag-bridge.hook.ts b/apps/web/src/pods/canvas/use-mac-webview-drag-bridge.hook.ts new file mode 100644 index 00000000..88bd7667 --- /dev/null +++ b/apps/web/src/pods/canvas/use-mac-webview-drag-bridge.hook.ts @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { + convertFromDivElementCoordsToKonvaCoords, + getScrollFromDiv, + portScreenPositionToDivCoordinates, +} from './canvas.util'; +import { ShapeType } from '#core/model'; +import { useCanvasContext } from '#core/providers'; +import { calculateShapeOffsetToXDropCoordinate } from './use-monitor.business'; + +// Receives gallery-drop messages forwarded by the webview shell on macOS +// (workaround for microsoft/vscode#193558: HTML5 drag events targeting the +// inner iframe are dispatched to the iframe element in the shell instead of +// to its contents, so PDND never sees them). +export const useMacWebviewDragBridge = ( + dropRef: React.MutableRefObject, + addNewShape: (type: ShapeType, x: number, y: number) => void +) => { + const { stageRef } = useCanvasContext(); + + useEffect(() => { + const onMessage = (ev: MessageEvent) => { + const data = ev.data as + | { + type?: string; + shapeType?: ShapeType; + clientX?: number; + clientY?: number; + } + | undefined; + if ( + data?.type !== 'qm:gallery-drop' || + !data.shapeType || + typeof data.clientX !== 'number' || + typeof data.clientY !== 'number' + ) { + return; + } + + const dropDiv = dropRef.current as HTMLDivElement | null; + const stage = stageRef.current; + if (!dropDiv || !stage) return; + + const screenPosition = { x: data.clientX, y: data.clientY }; + const { x: divRelativeX, y: divRelativeY } = + portScreenPositionToDivCoordinates(dropDiv, screenPosition); + const { scrollLeft, scrollTop } = getScrollFromDiv( + dropRef as unknown as React.MutableRefObject + ); + const konvaCoord = convertFromDivElementCoordsToKonvaCoords(stage, { + screenPosition, + relativeDivPosition: { x: divRelativeX, y: divRelativeY }, + scroll: { x: scrollLeft, y: scrollTop }, + }); + + const positionX = + konvaCoord.x - + calculateShapeOffsetToXDropCoordinate(konvaCoord.x, data.shapeType); + const positionY = konvaCoord.y; + + addNewShape(data.shapeType, positionX, positionY); + }; + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, []); +}; diff --git a/apps/web/src/pods/canvas/use-monitor-shape.hook.ts b/apps/web/src/pods/canvas/use-monitor-shape.hook.ts index e7af1f21..862b6229 100644 --- a/apps/web/src/pods/canvas/use-monitor-shape.hook.ts +++ b/apps/web/src/pods/canvas/use-monitor-shape.hook.ts @@ -19,7 +19,14 @@ export const useMonitorShape = ( useEffect(() => { return monitorForElements({ + onDragStart: args => + console.log('[MON-START]', { sourceData: args.source.data }), onDrop({ source, location }) { + console.log('[MON-DROP]', { + sourceType: source.data.type, + targetsCount: location.current.dropTargets.length, + firstTargetData: location.current.dropTargets[0]?.data, + }); const destination = location.current.dropTargets[0]; if (!destination) return; invariant(destination); @@ -56,6 +63,7 @@ export const useMonitorShape = ( calculateShapeOffsetToXDropCoordinate(konvaCoord.x, type); positionY = konvaCoord.y; } + console.log('[CALL-ADD-SHAPE]', { type, positionX, positionY }); addNewShape(type, positionX, positionY); } }, diff --git a/apps/web/src/pods/thumb-pages/monitor-drop-thumb.hook.ts b/apps/web/src/pods/thumb-pages/monitor-drop-thumb.hook.ts index 39d0decf..029a0998 100644 --- a/apps/web/src/pods/thumb-pages/monitor-drop-thumb.hook.ts +++ b/apps/web/src/pods/thumb-pages/monitor-drop-thumb.hook.ts @@ -9,6 +9,10 @@ export const useMonitorDropThumb = () => { useEffect(() => { return monitorForElements({ onDrop({ source, location }) { + console.log('[MON-THUMB-DROP]', { + sourceData: source.data, + targetsCount: location.current.dropTargets.length, + }); const destination = location.current.dropTargets[0]; if (!destination || source.data.pageId === destination.data.pageId) { return; diff --git a/packages/vscode-extension/src/webview/bridge.ts b/packages/vscode-extension/src/webview/bridge.ts index 513d71f6..f08ef4bd 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,7 @@ 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..ee3a5f97 --- /dev/null +++ b/packages/vscode-extension/src/webview/drag-bridge.ts @@ -0,0 +1,60 @@ +// Workaround for macOS-only bug in VS Code webview: HTML5 drag events targeting +// the inner iframe are dispatched to the iframe element in the shell, not to +// the iframe's contents (see microsoft/vscode#193558). We capture them here and +// forward the drop coordinates back to the iframe via postMessage. + +const DRAG_START = 'qm:drag-start'; +const DRAG_END = 'qm:drag-end'; +const GALLERY_DROP = 'qm:gallery-drop'; + +export const isDragBridgeMessage = (data: unknown): boolean => { + const t = (data as { type?: string } | undefined)?.type; + return t === DRAG_START || t === DRAG_END; +}; + +export const setupDragBridge = ( + iframe: HTMLIFrameElement, + appOrigin: string +): void => { + let activeShapeType: string | null = null; + + window.addEventListener('message', (event: MessageEvent) => { + if (event.origin !== appOrigin) return; + const data = event.data as + | { type?: string; shapeType?: string } + | undefined; + if (data?.type === DRAG_START && typeof data.shapeType === 'string') { + activeShapeType = data.shapeType; + } else if (data?.type === DRAG_END) { + activeShapeType = null; + } + }); + + document.addEventListener( + 'dragover', + ev => { + if (activeShapeType !== null) ev.preventDefault(); + }, + true + ); + + document.addEventListener( + 'drop', + ev => { + if (activeShapeType === null) return; + ev.preventDefault(); + const rect = iframe.getBoundingClientRect(); + iframe.contentWindow?.postMessage( + { + type: GALLERY_DROP, + shapeType: activeShapeType, + clientX: ev.clientX - rect.left, + clientY: ev.clientY - rect.top, + }, + appOrigin + ); + activeShapeType = null; + }, + 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);