openpencil/packages/core/src/fig-export.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

129 lines
3.6 KiB
TypeScript

import { zipSync, deflateSync } from 'fflate'
import { IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi } from './kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { renderThumbnail } from './render-image'
import type { NodeChange } from './kiwi/codec'
import type { SkiaRenderer } from './renderer'
import type { SceneGraph } from './scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'
const THUMBNAIL_1X1 = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
)
type KiwiNodeChange = NodeChange & Record<string, unknown>
const THUMBNAIL_WIDTH = 400
const THUMBNAIL_HEIGHT = 225
export async function exportFigFile(
graph: SceneGraph,
ck?: CanvasKit,
renderer?: SkiaRenderer,
pageId?: string
): Promise<Uint8Array> {
await initCodec()
const compiled = getCompiledSchema()
const schemaDeflated = deflateSync(getSchemaBytes())
const docGuid = { sessionID: 0, localID: 0 }
const localIdCounter = { value: 2 }
const nodeChanges: KiwiNodeChange[] = [
{
guid: docGuid,
type: 'DOCUMENT',
name: 'Document',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
strokeWeight: 1,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
documentColorProfile: 'SRGB'
}
]
const blobs: Uint8Array[] = []
const pages = graph.getPages()
for (let p = 0; p < pages.length; p++) {
const page = pages[p]
const canvasLocalID = localIdCounter.value++
const canvasGuid = { sessionID: 0, localID: canvasLocalID }
nodeChanges.push({
guid: canvasGuid,
parentIndex: { guid: docGuid, position: fractionalPosition(p) },
type: 'CANVAS',
name: page.name,
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
strokeWeight: 1,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
backgroundOpacity: 1,
backgroundColor: { r: 0.96, g: 0.96, b: 0.96, a: 1 },
backgroundEnabled: true
})
const children = graph.getChildren(page.id)
for (let i = 0; i < children.length; i++) {
nodeChanges.push(...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs))
}
}
const msg: Record<string, unknown> = {
type: 'NODE_CHANGES',
sessionID: 0,
ackID: 0,
nodeChanges
}
if (blobs.length > 0) {
msg.blobs = blobs.map((bytes) => ({ bytes }))
}
const kiwiData = compiled.encodeMessage(msg)
const currentPageId = pageId ?? pages[0]?.id
const thumbnailPng =
(ck && renderer && currentPageId
? renderThumbnail(ck, renderer, graph, currentPageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
: null) ?? THUMBNAIL_1X1
const metaJson = JSON.stringify({
version: 1,
app: 'OpenPencil',
createdAt: new Date().toISOString()
})
if (IS_TAURI) {
const { invoke } = await import('@tauri-apps/api/core')
return new Uint8Array(
await invoke<number[]>('build_fig_file', {
schemaDeflated: Array.from(schemaDeflated),
kiwiData: Array.from(kiwiData),
thumbnailPng: Array.from(thumbnailPng),
metaJson
})
)
}
const canvasData = buildFigKiwi(schemaDeflated, kiwiData)
return zipSync({
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(metaJson)
})
}