Skip to content

Commit 0662adb

Browse files
authored
Merge pull request #460 from node-projects/copilot/create-electron-display-media-writer
Add ElectronPngWriterService using BrowserWindow.capturePage()
2 parents 12ef812 + a2fa59e commit 0662adb

File tree

2 files changed

+156
-0
lines changed

2 files changed

+156
-0
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { IDesignItem } from "../../item/IDesignItem.js";
2+
import { requestAnimationFramePromise, sleep } from "../../helper/Helper.js";
3+
import { IPngCreatorService } from "./IPngCreatorService.js";
4+
import { DesignerCanvas } from "../../widgets/designerView/designerCanvas.js";
5+
6+
/**
7+
* PNG writer service for Electron that uses BrowserWindow.capturePage()
8+
* instead of getDisplayMedia(). No recording rect is shown and no user
9+
* permission prompt is needed.
10+
*
11+
* The constructor accepts a capturePageFn callback that should invoke
12+
* BrowserWindow.capturePage() (via IPC from the renderer) and return
13+
* a data-URL of the captured image.
14+
*
15+
* Example usage from an Electron renderer process:
16+
*
17+
* const { ipcRenderer } = require('electron');
18+
*
19+
* const capturePageFn = async (rect) => {
20+
* // main process calls: mainWindow.webContents.capturePage(rect)
21+
* return await ipcRenderer.invoke('capture-page', rect);
22+
* };
23+
*
24+
* serviceContainer.register('pngCreatorService',
25+
* new ElectronPngWriterService(capturePageFn));
26+
*/
27+
export class ElectronPngWriterService implements IPngCreatorService {
28+
private _capturePageFn: (rect: { x: number, y: number, width: number, height: number }) => Promise<string>;
29+
30+
/**
31+
* @param capturePageFn A function that captures a portion of the current
32+
* BrowserWindow and returns a data-URL (e.g. "data:image/png;base64,…").
33+
* The rect is in CSS pixels relative to the page (matching Electron's
34+
* capturePage rectangle).
35+
*/
36+
constructor(capturePageFn: (rect: { x: number, y: number, width: number, height: number }) => Promise<string>) {
37+
this._capturePageFn = capturePageFn;
38+
}
39+
40+
async takePng(designItems: IDesignItem[], options?: { margin?: number, removeSelection?: boolean }): Promise<Uint8Array> {
41+
if (!designItems || designItems.length === 0) {
42+
return null;
43+
}
44+
45+
const designerCanvas = designItems[0].instanceServiceContainer.designerCanvas;
46+
const selectionService = designItems[0].instanceServiceContainer.selectionService;
47+
const oldZoomFactor = designerCanvas.zoomFactor;
48+
const oldPos = designerCanvas.canvasOffset;
49+
const oldSelected = selectionService.selectedElements;
50+
51+
try {
52+
(<DesignerCanvas>designerCanvas).disableBackgroud();
53+
designerCanvas.zoomFactor = 1;
54+
if (options?.removeSelection) {
55+
selectionService.setSelectedElements([]);
56+
}
57+
designerCanvas.canvasOffset = { x: 0, y: 0 };
58+
await requestAnimationFramePromise();
59+
60+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
61+
for (const item of designItems) {
62+
const rect = designerCanvas.getNormalizedElementCoordinates(item.element);
63+
minX = Math.min(minX, rect.x);
64+
minY = Math.min(minY, rect.y);
65+
maxX = Math.max(maxX, rect.x + rect.width);
66+
maxY = Math.max(maxY, rect.y + rect.height);
67+
}
68+
69+
const margin = options?.margin ?? 0;
70+
minX -= margin;
71+
minY -= margin;
72+
maxX += margin;
73+
maxY += margin;
74+
75+
const totalWidth = Math.ceil(maxX - minX);
76+
const totalHeight = Math.ceil(maxY - minY);
77+
78+
const viewportW = designerCanvas.canvas.offsetWidth;
79+
const viewportH = designerCanvas.canvas.offsetHeight;
80+
81+
// Inset by 1 CSS pixel on each edge to avoid border artifacts when stitching tiles
82+
const borderInset = 1;
83+
const effectiveW = viewportW - 2 * borderInset;
84+
const effectiveH = viewportH - 2 * borderInset;
85+
86+
const numTilesX = Math.ceil(totalWidth / effectiveW);
87+
const numTilesY = Math.ceil(totalHeight / effectiveH);
88+
89+
const dpr = window.devicePixelRatio || 1;
90+
const captureW = Math.ceil(viewportW * dpr);
91+
const captureH = Math.ceil(viewportH * dpr);
92+
const insetPx = Math.ceil(borderInset * dpr);
93+
const effectiveCaptureW = captureW - 2 * insetPx;
94+
const effectiveCaptureH = captureH - 2 * insetPx;
95+
96+
const finalCanvas = document.createElement('canvas');
97+
finalCanvas.width = Math.ceil(totalWidth * dpr);
98+
finalCanvas.height = Math.ceil(totalHeight * dpr);
99+
const finalCtx = finalCanvas.getContext('2d');
100+
101+
// Use the outer viewport element to determine the capture rectangle
102+
// so that coordinates are not affected by the canvasOffset CSS transform
103+
const viewportElement = (<DesignerCanvas>designerCanvas).outercanvas2;
104+
105+
for (let iy = 0; iy < numTilesY; iy++) {
106+
for (let ix = 0; ix < numTilesX; ix++) {
107+
const tileX = minX + ix * effectiveW;
108+
const tileY = minY + iy * effectiveH;
109+
110+
// Shift by borderInset so the 1px border falls outside the effective region
111+
designerCanvas.canvasOffset = { x: -(tileX - borderInset), y: -(tileY - borderInset) };
112+
// Wait for CSS transform to apply and the renderer to paint the new frame
113+
await requestAnimationFramePromise();
114+
await sleep(100);
115+
116+
const vpRect = viewportElement.getBoundingClientRect();
117+
const dataUrl = await this._capturePageFn({
118+
x: Math.round(vpRect.left),
119+
y: Math.round(vpRect.top),
120+
width: Math.round(vpRect.width),
121+
height: Math.round(vpRect.height)
122+
});
123+
124+
const img = await this._loadImage(dataUrl);
125+
// Draw only the inner region, skipping the 1px border on all sides
126+
finalCtx.drawImage(
127+
img,
128+
insetPx, insetPx, effectiveCaptureW, effectiveCaptureH,
129+
ix * effectiveCaptureW, iy * effectiveCaptureH, effectiveCaptureW, effectiveCaptureH
130+
);
131+
}
132+
}
133+
134+
const blob = await new Promise<Blob>(resolve => finalCanvas.toBlob(resolve, 'image/png'));
135+
const arrayBuffer = await blob.arrayBuffer();
136+
return new Uint8Array(arrayBuffer);
137+
} finally {
138+
designerCanvas.zoomFactor = oldZoomFactor;
139+
designerCanvas.canvasOffset = oldPos;
140+
(<DesignerCanvas>designerCanvas).enableBackground();
141+
if (options?.removeSelection) {
142+
selectionService.setSelectedElements(oldSelected);
143+
}
144+
}
145+
}
146+
147+
private _loadImage(dataUrl: string): Promise<HTMLImageElement> {
148+
return new Promise((resolve, reject) => {
149+
const img = new Image();
150+
img.onload = () => resolve(img);
151+
img.onerror = reject;
152+
img.src = dataUrl;
153+
});
154+
}
155+
}

packages/web-component-designer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export * from "./elements/services/placementService/SnaplinesProviderService.js"
5151
export type { ISnaplinesProviderService } from "./elements/services/placementService/ISnaplinesProviderService.js";
5252

5353
export * from "./elements/services/pngCreatorService/DisplayMediaPngWriterService.js";
54+
export * from "./elements/services/pngCreatorService/ElectronPngWriterService.js";
5455
export type { IPngCreatorService } from "./elements/services/pngCreatorService/IPngCreatorService.js";
5556

5657
export * from "./elements/services/elementAtPointService/ElementAtPointService.js";

0 commit comments

Comments
 (0)