- 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
140 lines
3.7 KiB
TypeScript
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)
|
|
})
|
|
}
|