Skip to content

Commit 791f439

Browse files
authored
Merge pull request #839 from Lemoncode/dev
Merge to main
2 parents c13d8c2 + f74c78a commit 791f439

114 files changed

Lines changed: 3973 additions & 168 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"editor.codeActionsOnSave": {
66
"source.organizeImports": "always",
77
"source.removeUnusedImports": "always"
8-
}
8+
},
9+
"quickmock.appUrl": "http://localhost:5173/editor.html"
910
}

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@atlaskit/pragmatic-drag-and-drop": "1.7.10",
23+
"@lemoncode/quickmock-bridge-protocol": "*",
2324
"@fontsource-variable/montserrat": "5.0.20",
2425
"@fontsource/balsamiq-sans": "5.0.21",
2526
"@uiw/react-color-chrome": "2.10.1",

apps/web/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { ModalDialogComponent } from './common/components/modal-dialog';
2+
import { useVSCodeSync } from '#core/vscode/use-vscode-sync.hook';
23
import { MainScene } from './scenes/main.scene';
34

45
function App() {
6+
useVSCodeSync();
7+
58
return (
69
<>
710
<ModalDialogComponent />
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ContentBbox } from '@lemoncode/quickmock-bridge-protocol';
2+
import type { useCanvasContext } from '#core/providers';
3+
4+
const CONTENT_PADDING = 16;
5+
6+
export function computeContentBbox(
7+
shapes: ReturnType<typeof useCanvasContext>['shapes'],
8+
stageRef: ReturnType<typeof useCanvasContext>['stageRef']
9+
): ContentBbox | undefined {
10+
const stage = stageRef.current;
11+
if (!stage || shapes.length === 0) return undefined;
12+
13+
const scale = stage.scaleX();
14+
const stageX = stage.x();
15+
const stageY = stage.y();
16+
const container = stage.container().getBoundingClientRect();
17+
18+
const minX = Math.min(...shapes.map(s => s.x));
19+
const minY = Math.min(...shapes.map(s => s.y));
20+
const maxX = Math.max(...shapes.map(s => s.x + s.width));
21+
const maxY = Math.max(...shapes.map(s => s.y + s.height));
22+
23+
return {
24+
x: Math.max(0, container.left + stageX + minX * scale - CONTENT_PADDING),
25+
y: Math.max(0, container.top + stageY + minY * scale - CONTENT_PADDING),
26+
width: (maxX - minX) * scale + CONTENT_PADDING * 2,
27+
height: (maxY - minY) * scale + CONTENT_PADDING * 2,
28+
};
29+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const isVSCodeEnv = (): boolean => {
2+
return new URLSearchParams(window.location.search).get('env') === 'vscode';
3+
};
4+
5+
export const isHeadlessEnv = (): boolean => {
6+
return new URLSearchParams(window.location.search).get('headless') === '1';
7+
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type {
2+
AppMessage,
3+
HostMessage,
4+
PayloadOf,
5+
} from '@lemoncode/quickmock-bridge-protocol';
6+
import { isVSCodeEnv } from './env.utils';
7+
8+
type HandlerFor<T extends HostMessage['type']> = (
9+
payload: PayloadOf<HostMessage, T>
10+
) => void;
11+
12+
type AnyHandler = (payload: unknown) => void;
13+
14+
const handlers = new Map<string, Set<AnyHandler>>();
15+
16+
const resolveParentOrigin = (): string => {
17+
if (!document.referrer) return '*';
18+
try {
19+
return new URL(document.referrer).origin;
20+
} catch {
21+
return '*';
22+
}
23+
};
24+
25+
const parentOrigin = resolveParentOrigin();
26+
27+
export const sendToExtension = (msg: AppMessage): void => {
28+
if (!isVSCodeEnv()) return;
29+
window.parent.postMessage(msg, parentOrigin);
30+
};
31+
32+
export const onMessage = <T extends HostMessage['type']>(
33+
type: T,
34+
handler: HandlerFor<T>
35+
): (() => void) => {
36+
if (!isVSCodeEnv()) return () => {};
37+
38+
const existing = handlers.get(type) ?? new Set<AnyHandler>();
39+
existing.add(handler as AnyHandler);
40+
handlers.set(type, existing);
41+
42+
return () => {
43+
const set = handlers.get(type);
44+
if (!set) return;
45+
set.delete(handler as AnyHandler);
46+
if (set.size === 0) handlers.delete(type);
47+
};
48+
};
49+
50+
if (isVSCodeEnv()) {
51+
window.addEventListener('message', (event: MessageEvent) => {
52+
if (event.source !== window.parent) return;
53+
54+
const msg = event.data as Partial<HostMessage> | undefined;
55+
if (!msg?.type) return;
56+
57+
const set = handlers.get(msg.type);
58+
if (!set) return;
59+
60+
const payload = (msg as { payload?: unknown }).payload;
61+
for (const handler of set) handler(payload);
62+
});
63+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { computeContentBbox } from '#common/utils/compute-content-bbox.utils.ts';
2+
import { isHeadlessEnv } from '#common/utils/env.utils.ts';
3+
import { sendToExtension } from '#common/utils/vscode-bridge.utils.ts';
4+
import { useCanvasContext } from '#core/providers';
5+
import { APP_MESSAGE_TYPE } from '@lemoncode/quickmock-bridge-protocol';
6+
import { useEffect } from 'react';
7+
8+
export function useHeadlessRenderComplete(hasReceivedFileRef: {
9+
current: boolean;
10+
}): void {
11+
const { howManyLoadedDocuments, shapes, stageRef } = useCanvasContext();
12+
13+
useEffect(() => {
14+
if (!isHeadlessEnv() || !hasReceivedFileRef.current) return;
15+
16+
let innerRafId = 0;
17+
// Double rAF: the first frame runs after React commits; the second waits
18+
// for Konva to paint the updated canvas, so Puppeteer's screenshot reflects it.
19+
// There was a previous issue when the canvas was blank because the screenshot ran before Konva painted.
20+
const outerRafId = requestAnimationFrame(() => {
21+
innerRafId = requestAnimationFrame(() => {
22+
sendToExtension({
23+
type: APP_MESSAGE_TYPE.RENDER_COMPLETE,
24+
payload: computeContentBbox(shapes, stageRef),
25+
});
26+
});
27+
});
28+
29+
return () => {
30+
cancelAnimationFrame(outerRafId);
31+
cancelAnimationFrame(innerRafId);
32+
};
33+
}, [howManyLoadedDocuments]);
34+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { isHeadlessEnv, isVSCodeEnv } from '#common/utils/env.utils.ts';
2+
import { sendToExtension } from '#common/utils/vscode-bridge.utils.ts';
3+
import { useCanvasContext } from '#core/providers';
4+
import { APP_MESSAGE_TYPE } from '@lemoncode/quickmock-bridge-protocol';
5+
import { useEffect, useRef } from 'react';
6+
import { serializeDocument } from './vscode-sync.utils';
7+
8+
const AUTO_SAVE_DEBOUNCE_MS = 500;
9+
10+
export function useVSCodeAutoSave(hasReceivedFileRef: {
11+
current: boolean;
12+
}): void {
13+
const { fullDocument, howManyLoadedDocuments } = useCanvasContext();
14+
15+
const prevLoadCountRef = useRef(howManyLoadedDocuments);
16+
const lastSavedContentRef = useRef('');
17+
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
18+
19+
useEffect(() => {
20+
if (!isVSCodeEnv() || isHeadlessEnv() || !hasReceivedFileRef.current)
21+
return;
22+
23+
if (prevLoadCountRef.current !== howManyLoadedDocuments) {
24+
prevLoadCountRef.current = howManyLoadedDocuments;
25+
lastSavedContentRef.current = serializeDocument(fullDocument);
26+
return;
27+
}
28+
29+
const content = serializeDocument(fullDocument);
30+
31+
if (content === lastSavedContentRef.current) return;
32+
33+
debounceTimerRef.current = setTimeout(() => {
34+
sendToExtension({
35+
type: APP_MESSAGE_TYPE.SAVE,
36+
payload: { content },
37+
});
38+
lastSavedContentRef.current = content;
39+
debounceTimerRef.current = null;
40+
}, AUTO_SAVE_DEBOUNCE_MS);
41+
42+
return () => {
43+
if (debounceTimerRef.current !== null) {
44+
clearTimeout(debounceTimerRef.current);
45+
debounceTimerRef.current = null;
46+
}
47+
};
48+
}, [fullDocument, howManyLoadedDocuments]);
49+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { isHeadlessEnv, isVSCodeEnv } from '#common/utils/env.utils.ts';
2+
import {
3+
onMessage,
4+
sendToExtension,
5+
} from '#common/utils/vscode-bridge.utils.ts';
6+
import { QuickMockFileContract } from '#core/local-disk/local-disk.model';
7+
import { useCanvasContext } from '#core/providers';
8+
import {
9+
APP_MESSAGE_TYPE,
10+
HOST_MESSAGE_TYPE,
11+
type LoadFilePayload,
12+
} from '@lemoncode/quickmock-bridge-protocol';
13+
import { useEffect, useRef } from 'react';
14+
import { deserializeDocument } from './vscode-sync.utils';
15+
16+
export function useVSCodeFileLoad(): { current: boolean } {
17+
const { loadDocument, setFileName } = useCanvasContext();
18+
19+
const loadDocumentRef = useRef(loadDocument);
20+
const setFileNameRef = useRef(setFileName);
21+
useEffect(() => {
22+
loadDocumentRef.current = loadDocument;
23+
setFileNameRef.current = setFileName;
24+
});
25+
26+
const hasReceivedFileRef = useRef(false);
27+
28+
useEffect(() => {
29+
if (!isVSCodeEnv()) return;
30+
31+
const unsubscribe = onMessage(
32+
HOST_MESSAGE_TYPE.LOAD_FILE,
33+
(payload: LoadFilePayload) => {
34+
hasReceivedFileRef.current = true;
35+
setFileNameRef.current(payload.fileName);
36+
loadDocumentRef.current(
37+
deserializeDocument(payload.data as QuickMockFileContract)
38+
);
39+
}
40+
);
41+
42+
sendToExtension({
43+
type: isHeadlessEnv()
44+
? APP_MESSAGE_TYPE.READY
45+
: APP_MESSAGE_TYPE.WEBVIEW_READY,
46+
});
47+
48+
return unsubscribe;
49+
}, []);
50+
51+
return hasReceivedFileRef;
52+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useHeadlessRenderComplete } from './use-headless-render-complete.hook';
2+
import { useVSCodeAutoSave } from './use-vscode-auto-save.hook';
3+
import { useVSCodeFileLoad } from './use-vscode-file-load.hook';
4+
import { useVSCodeTheme } from './use-vscode-theme.hook';
5+
6+
/**
7+
* Wires the full VS Code webview bridge. Each inner hook no-ops when not
8+
* running inside a webview, so this can be called unconditionally.
9+
*/
10+
export function useVSCodeSync(): void {
11+
const hasReceivedFileRef = useVSCodeFileLoad();
12+
useVSCodeAutoSave(hasReceivedFileRef);
13+
useHeadlessRenderComplete(hasReceivedFileRef);
14+
useVSCodeTheme();
15+
}

0 commit comments

Comments
 (0)