10f2a20966
Click or drag on the preview page to pin a numbered comment. Saving a pin only adds it to the stack. Add comments drops the crops and a short prompt into the composer and never sends the turn. Capture takes the visible page then crops in bitmap space, because Electron's rect crop on guest webviews is empty on Windows and shifted on high-DPI.
132 lines
3.4 KiB
TypeScript
132 lines
3.4 KiB
TypeScript
/**
|
|
* Crop a preview webview via webContents.capturePage.
|
|
*
|
|
* Electron's rect argument is unreliable on guest webviews (empty NativeImage
|
|
* on Windows, DPI-shifted crops). Capture the visible viewport, then crop in
|
|
* bitmap space from CSS viewport coordinates.
|
|
*
|
|
* Missing/destroyed guests fail closed — never a blank PNG that would look
|
|
* like a successful shot of nothing.
|
|
*/
|
|
|
|
export interface CaptureRect {
|
|
height: number
|
|
width: number
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
export interface CaptureViewport {
|
|
height: number
|
|
width: number
|
|
}
|
|
|
|
export interface CaptureGuest {
|
|
capturePage: (rect?: CaptureRect) => Promise<CaptureImage>
|
|
isDestroyed: () => boolean
|
|
}
|
|
|
|
export interface CaptureImage {
|
|
crop?: (rect: CaptureRect) => CaptureImage
|
|
getSize?: () => CaptureViewport
|
|
isEmpty: () => boolean
|
|
toPNG: () => Buffer
|
|
}
|
|
|
|
export function normalizeCaptureRect(rect?: CaptureRect): CaptureRect | undefined {
|
|
if (!rect) {
|
|
return undefined
|
|
}
|
|
|
|
return {
|
|
height: Math.max(1, Math.ceil(rect.height)),
|
|
width: Math.max(1, Math.ceil(rect.width)),
|
|
x: Math.max(0, Math.floor(rect.x)),
|
|
y: Math.max(0, Math.floor(rect.y))
|
|
}
|
|
}
|
|
|
|
/** Map a CSS-pixel viewport rect onto a (possibly DPR-scaled) bitmap. */
|
|
export function mapViewportRectToImage(
|
|
rect: CaptureRect,
|
|
viewport: CaptureViewport,
|
|
image: CaptureViewport
|
|
): CaptureRect | null {
|
|
const viewW = Math.max(1, viewport.width)
|
|
const viewH = Math.max(1, viewport.height)
|
|
const scaleX = image.width / viewW
|
|
const scaleY = image.height / viewH
|
|
const left = rect.x * scaleX
|
|
const top = rect.y * scaleY
|
|
const right = (rect.x + rect.width) * scaleX
|
|
const bottom = (rect.y + rect.height) * scaleY
|
|
const x = Math.max(0, Math.floor(left))
|
|
const y = Math.max(0, Math.floor(top))
|
|
const maxX = Math.min(image.width, Math.ceil(right))
|
|
const maxY = Math.min(image.height, Math.ceil(bottom))
|
|
const width = maxX - x
|
|
const height = maxY - y
|
|
|
|
if (width < 1 || height < 1) {
|
|
return null
|
|
}
|
|
|
|
return { height, width, x, y }
|
|
}
|
|
|
|
function toDataUrl(image: CaptureImage): string {
|
|
if (!image || image.isEmpty()) {
|
|
throw new Error('preview capture was empty')
|
|
}
|
|
|
|
return `data:image/png;base64,${image.toPNG().toString('base64')}`
|
|
}
|
|
|
|
export async function capturePreviewContents(
|
|
guest: CaptureGuest | null | undefined,
|
|
rect?: CaptureRect,
|
|
viewport?: CaptureViewport
|
|
): Promise<string> {
|
|
if (!guest || guest.isDestroyed()) {
|
|
throw new Error('preview guest is gone')
|
|
}
|
|
|
|
const image = await guest.capturePage()
|
|
|
|
if (!image || image.isEmpty()) {
|
|
throw new Error('preview capture was empty')
|
|
}
|
|
|
|
const crop = normalizeCaptureRect(rect)
|
|
|
|
if (!crop) {
|
|
return toDataUrl(image)
|
|
}
|
|
|
|
const size = image.getSize?.()
|
|
const view = viewport && viewport.width > 0 && viewport.height > 0 ? viewport : size
|
|
|
|
if (size && view && typeof image.crop === 'function') {
|
|
const mapped = mapViewportRectToImage(crop, view, size)
|
|
|
|
if (mapped) {
|
|
const cropped = image.crop(mapped)
|
|
|
|
if (cropped && !cropped.isEmpty()) {
|
|
return toDataUrl(cropped)
|
|
}
|
|
}
|
|
|
|
// Off-screen CSS rects (document Y after scroll, DPI mismatch) used to
|
|
// throw here and the composer kept the stale pick-time crop. The visible
|
|
// page is a better fallback than the wrong slice of the article.
|
|
return toDataUrl(image)
|
|
}
|
|
|
|
try {
|
|
return toDataUrl(await guest.capturePage(crop))
|
|
} catch {
|
|
return toDataUrl(image)
|
|
}
|
|
}
|