openpencil/packages/core/src/render-image.ts
Danila Poyarkov f90e1400b7 P2P collaboration: cursors, follow mode, cleanup
- Broadcast cursor position from canvas mouse move via awareness
- Broadcast selection changes via reactive watcher on selectedIds
- Follow mode: click peer avatar to track their viewport (pan + zoom)
- Zoom broadcast via watcher on store.state.zoom, not just mouse move
- Figma-style cursor arrows: colored fill, white border, name pill
- Stale cursor cleanup: removeAwarenessStates on peer leave
- MQTT signaling (replace Nostr), STUN + TURN ICE servers
- Collab constants extracted to src/constants.ts
- crypto.getRandomValues() replaces Math.random() everywhere
- Provide/inject for collab composable (COLLAB_KEY)
- Update README (collab section, tech stack), CHANGELOG, AGENTS.md
- AGENTS.md: release process, CI workflows, documentation guidelines
2026-03-01 18:11:07 +03:00

140 lines
3.7 KiB
TypeScript

import type { SkiaRenderer } from './renderer'
import type { SceneGraph } from './scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'
export type ExportFormat = 'PNG' | 'JPG' | 'WEBP'
interface RenderOptions {
scale: number
format: ExportFormat
quality?: number
}
function computeContentBounds(
graph: SceneGraph,
nodeIds: string[]
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity
for (const id of nodeIds) {
const node = graph.getNode(id)
if (!node || !node.visible) continue
const abs = graph.getAbsolutePosition(id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + node.width)
maxY = Math.max(maxY, abs.y + node.height)
}
if (!isFinite(minX)) return null
return { minX, minY, maxX, maxY }
}
function ckImageFormat(ck: CanvasKit, format: ExportFormat) {
switch (format) {
case 'JPG':
return ck.ImageFormat.JPEG
case 'WEBP':
return ck.ImageFormat.WEBP
default:
return ck.ImageFormat.PNG
}
}
function renderToSurface(
ck: CanvasKit,
renderer: SkiaRenderer,
graph: SceneGraph,
pageId: string,
width: number,
height: number,
format: ExportFormat,
quality: number,
setup: (canvas: import('canvaskit-wasm').Canvas) => void
): Uint8Array | null {
const surface = ck.MakeSurface(width, height)
if (!surface) return null
try {
const canvas = surface.getCanvas()
setup(canvas)
renderer.renderSceneToCanvas(canvas, graph, pageId)
surface.flush()
const image = surface.makeImageSnapshot()
const encoded = image.encodeToBytes(ckImageFormat(ck, format), quality)
image.delete()
return encoded ? new Uint8Array(encoded) : null
} finally {
surface.delete()
}
}
export function renderNodesToImage(
ck: CanvasKit,
renderer: SkiaRenderer,
graph: SceneGraph,
pageId: string,
nodeIds: string[],
options: RenderOptions
): Uint8Array | null {
const bounds = computeContentBounds(graph, nodeIds)
if (!bounds) return null
const contentW = bounds.maxX - bounds.minX
const contentH = bounds.maxY - bounds.minY
if (contentW <= 0 || contentH <= 0) return null
const pixelW = Math.ceil(contentW * options.scale)
const pixelH = Math.ceil(contentH * options.scale)
if (pixelW <= 0 || pixelH <= 0) return null
const quality = options.quality ?? (options.format === 'PNG' ? 100 : 90)
return renderToSurface(
ck,
renderer,
graph,
pageId,
pixelW,
pixelH,
options.format,
quality,
(canvas) => {
canvas.clear(ck.TRANSPARENT)
canvas.scale(options.scale, options.scale)
canvas.translate(-bounds.minX, -bounds.minY)
}
)
}
export function renderThumbnail(
ck: CanvasKit,
renderer: SkiaRenderer,
graph: SceneGraph,
pageId: string,
width: number,
height: number
): Uint8Array | null {
const page = graph.getNode(pageId)
if (!page || page.childIds.length === 0) return null
const bounds = computeContentBounds(graph, page.childIds)
if (!bounds) return null
const contentW = bounds.maxX - bounds.minX
const contentH = bounds.maxY - bounds.minY
if (contentW <= 0 || contentH <= 0) return null
const scale = Math.min(width / contentW, height / contentH, 2)
return renderToSurface(ck, renderer, graph, pageId, width, height, 'PNG', 100, (canvas) => {
canvas.clear(ck.Color4f(renderer.pageColor.r, renderer.pageColor.g, renderer.pageColor.b, 1))
const offsetX = (width - contentW * scale) / 2 - bounds.minX * scale
const offsetY = (height - contentH * scale) / 2 - bounds.minY * scale
canvas.translate(offsetX, offsetY)
canvas.scale(scale, scale)
})
}