- 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
129 lines
3.6 KiB
TypeScript
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)
|
|
})
|
|
}
|