Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
0bfd6f0
feat: implement macOS drag-and-drop bridge for VS Code webview
Ivanruii May 12, 2026
c955c05
chore: added changeset
Ivanruii May 12, 2026
113cd57
feat: enhance macOS detection
Ivanruii May 12, 2026
d6fa583
feat: implement macOS drag-and-drop enhancements for VS Code webview
Ivanruii May 13, 2026
884871f
refactor: streamline macOS drag-and-drop comments and logic for clarity
Ivanruii May 14, 2026
bb2913f
feat(vscode-extension): hide open and save buttons on vscode
Ivanruii May 14, 2026
57a9de0
fix: ensure text color consistency in dropdown items and selected state
Ivanruii May 14, 2026
eff9a4d
feat(vscode-extension): add new wireframe button to status bar
Ivanruii May 14, 2026
a385bac
chore: added changeset
Ivanruii May 14, 2026
ceb9d97
refactor(toolbar): streamline button rendering logic for VSCode envir…
Ivanruii May 14, 2026
3f552e1
fixes
zumaichi May 17, 2026
bca1805
feat: add utility to check if screen position is inside a div element…
Ivanruii May 18, 2026
a33a04d
fix: improve drag start handling and native drag preview for macOS
Ivanruii May 18, 2026
28e35ec
fix
zumaichi May 18, 2026
2973813
Merge pull request #858 from Lemoncode/Fix-z-index-from-settings-and-…
zumaichi May 18, 2026
552ec97
wip
Ivanruii May 19, 2026
e193472
Merge branch 'dev' into fix/#847-macos-drag-and-drop
Ivanruii May 19, 2026
045a587
Merge pull request #848 from Lemoncode/fix/#847-macos-drag-and-drop
manudous May 19, 2026
f4ca513
Merge branch 'dev' into feature/#849-hide-open-save-toolbar-vscode
Ivanruii May 19, 2026
1a85b57
Merge pull request #851 from Lemoncode/feature/#849-hide-open-save-to…
manudous May 19, 2026
6a8e6b7
Merge branch 'dev' into fix/#850-properties-text-color
Ivanruii May 19, 2026
6b15dd4
Merge pull request #853 from Lemoncode/fix/#850-properties-text-color
manudous May 19, 2026
e592ee4
Merge branch 'dev' into feature/#852-create-wireframe-button-on-statu…
Ivanruii May 19, 2026
e0aafb1
wip
Ivanruii May 19, 2026
e9bd5bb
feat: added constants files for new-wireframe button
Ivanruii May 19, 2026
ef73447
feat: update new wireframe status bar item to use theme color token
Ivanruii May 19, 2026
58f5895
feat: update ITEM_COLOR_THEME_TOKEN to use statusBar.foreground
Ivanruii May 19, 2026
aeace9d
Merge pull request #854 from Lemoncode/feature/#852-create-wireframe-…
manudous May 19, 2026
171e666
chore: remove sponsorship and custom component library sections from …
Ivanruii May 20, 2026
4dea294
chore: remove sponsorship and custom component library sections from …
Ivanruii May 20, 2026
5559598
docs: add MCP setup instructions to README
Ivanruii May 20, 2026
1a3e31b
docs: add section for VS Code extension in README
Ivanruii May 20, 2026
50c29e4
Merge branch 'dev' into feature/vscode-extension-readme
Ivanruii May 20, 2026
79b48b7
chore: added changeset
Ivanruii May 20, 2026
4ee3667
Merge pull request #861 from Lemoncode/feature/vscode-extension-readme
Ivanruii May 20, 2026
481e3ea
chore: version packages
github-actions[bot] May 20, 2026
1b7fa11
Merge pull request #860 from Lemoncode/changeset-release/dev
nasdan May 20, 2026
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ npm run dev

Open your browser and go to http://localhost:5173 (if this port is busy it will be changed to the next available port)

## VS Code extension

If you prefer to work inside VS Code, install the [QuickMock VS Code extension](./packages/vscode-extension/README.md). It adds a custom editor for `.qm` files and also configures the MCP server for AI tools.

## 🤝 Contributing

Your feedback and contributions are welcome! If you have ideas for new features or have found a bug, we would love to hear about it. Here's how you can contribute:
Expand Down
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,39 @@ 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;
if (dataUrl) {
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() && thumbnailDataUrlRef.current) {
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.container {
z-index: 3;
z-index: 5;
position: fixed;
top: 0;
left: 0;
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;
};
122 changes: 122 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,122 @@
import { ShapeType } from '#core/model';
import { useCanvasContext } from '#core/providers';
import {
convertFromDivElementCoordsToKonvaCoords,
getScrollFromDiv,
isScreenPositionInsideDivElement,
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 };
if (!isScreenPositionInsideDivElement(dropDivElement, screenPosition)) {
return;
}

const relativeDivPosition = portScreenPositionToDivCoordinates(
dropDivElement,
screenPosition
);
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
14 changes: 14 additions & 0 deletions apps/web/src/pods/canvas/canvas.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export const portScreenPositionToDivCoordinates = (
return { x, y };
};

export const isScreenPositionInsideDivElement = (
divElement: HTMLDivElement,
screenPosition: Coord
) => {
const { left, right, top, bottom } = divElement.getBoundingClientRect();

return (
screenPosition.x >= left &&
screenPosition.x <= right &&
screenPosition.y >= top &&
screenPosition.y <= bottom
);
};

interface PositionInfo {
screenPosition: Coord;
relativeDivPosition: Coord;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
cursor: pointer;
background: white;
border-radius: 2px;
color: black;
}

.arrowIcon {
Expand Down Expand Up @@ -60,10 +61,12 @@
gap: var(--space-s);
font-size: var(--fs-xs);
cursor: pointer;
color: black;
}

.dropdownItem:hover {
background-color: var(--primary-100);
color: var(--text-color);
}

.linePreview {
Expand Down
Loading
Loading