fix(collab): validate synchronized node data (#517)
* fix(collab): validate synchronized node data - Exclude renderer-only text picture caches from Yjs payloads - Normalize source metadata and geometry at the remote boundary - Cover malformed payloads and typed geometry round trips * fix(collab): reject malformed geometry fills * fix(collab): validate nested fill metadata * test(collab): preserve valid nested fill metadata
This commit is contained in:
parent
72b0560ce5
commit
90f2498bcf
|
|
@ -41,6 +41,7 @@
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Harden collaboration node synchronization against malformed remote source metadata and geometry while excluding derived text-renderer caches.
|
||||||
- Transfer native `.fig` exports over binary Tauri IPC instead of JSON byte arrays, preventing large desktop saves from being truncated or exhausting WebView memory. (#484)
|
- Transfer native `.fig` exports over binary Tauri IPC instead of JSON byte arrays, preventing large desktop saves from being truncated or exhausting WebView memory. (#484)
|
||||||
- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior.
|
- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior.
|
||||||
- Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397)
|
- Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397)
|
||||||
|
|
|
||||||
193
src/app/collab/node-codec.ts
Normal file
193
src/app/collab/node-codec.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import type {
|
||||||
|
Fill,
|
||||||
|
FillType,
|
||||||
|
GeometryPath,
|
||||||
|
SceneNode,
|
||||||
|
SourceMetadata
|
||||||
|
} from '@open-pencil/scene-graph'
|
||||||
|
import { copyFills } from '@open-pencil/scene-graph/copy'
|
||||||
|
import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defaults'
|
||||||
|
import type { Matrix, Vector } from '@open-pencil/scene-graph/primitives'
|
||||||
|
|
||||||
|
const DERIVED_NODE_FIELDS = new Set<keyof SceneNode>(['textPicture'])
|
||||||
|
const FILL_TYPES = new Set<FillType>([
|
||||||
|
'SOLID',
|
||||||
|
'GRADIENT_LINEAR',
|
||||||
|
'GRADIENT_RADIAL',
|
||||||
|
'GRADIENT_ANGULAR',
|
||||||
|
'GRADIENT_DIAMOND',
|
||||||
|
'IMAGE',
|
||||||
|
'VIDEO',
|
||||||
|
'PATTERN',
|
||||||
|
'NOISE',
|
||||||
|
'CUSTOM'
|
||||||
|
])
|
||||||
|
|
||||||
|
type YjsNodeLike = {
|
||||||
|
entries(): IterableIterator<[string, unknown]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeNodeForYjs(node: SceneNode): Record<string, unknown> {
|
||||||
|
const encoded: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
if (DERIVED_NODE_FIELDS.has(key as keyof SceneNode)) continue
|
||||||
|
encoded[key] = structuredClone(value)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncEncodedNodeToYMap(
|
||||||
|
node: SceneNode,
|
||||||
|
ynode: { delete(key: string): void; set(key: string, value: unknown): void }
|
||||||
|
): void {
|
||||||
|
for (const key of DERIVED_NODE_FIELDS) ynode.delete(key)
|
||||||
|
for (const [key, value] of Object.entries(encodeNodeForYjs(node))) ynode.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeNodeFromYjs(ynode: YjsNodeLike): Partial<SceneNode> {
|
||||||
|
const props: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of ynode.entries()) {
|
||||||
|
if (DERIVED_NODE_FIELDS.has(key as keyof SceneNode)) continue
|
||||||
|
props[key] = structuredClone(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
props.source = normalizeSourceMetadata(props.source)
|
||||||
|
if ('fillGeometry' in props) props.fillGeometry = normalizeGeometryPaths(props.fillGeometry)
|
||||||
|
if ('strokeGeometry' in props) props.strokeGeometry = normalizeGeometryPaths(props.strokeGeometry)
|
||||||
|
props.textPicture = null
|
||||||
|
return props as Partial<SceneNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSourceMetadata(source: unknown): SourceMetadata {
|
||||||
|
const defaults = createDefaultSourceMetadata()
|
||||||
|
if (!isRecord(source)) return defaults
|
||||||
|
|
||||||
|
const fig = isRecord(source.fig) ? source.fig : {}
|
||||||
|
return {
|
||||||
|
format: source.format === 'fig' ? 'fig' : null,
|
||||||
|
id: stringOrNull(source.id),
|
||||||
|
orderKey: stringOrNull(source.orderKey),
|
||||||
|
editedFields: stringArray(source.editedFields),
|
||||||
|
fig: {
|
||||||
|
rawSize: normalizeVector(fig.rawSize),
|
||||||
|
rawTransform: normalizeMatrix(fig.rawTransform),
|
||||||
|
rawNodeFields: isRecord(fig.rawNodeFields) ? structuredClone(fig.rawNodeFields) : {},
|
||||||
|
layout: isRecord(fig.layout)
|
||||||
|
? (structuredClone(fig.layout) as SourceMetadata['fig']['layout'])
|
||||||
|
: null,
|
||||||
|
symbolOverrides: arrayOrEmpty(fig.symbolOverrides),
|
||||||
|
componentPropAssignments: arrayOrEmpty(fig.componentPropAssignments),
|
||||||
|
derivedSymbolData: arrayOrEmpty(fig.derivedSymbolData),
|
||||||
|
derivedSymbolDataLayoutVersion: numberOrNull(fig.derivedSymbolDataLayoutVersion),
|
||||||
|
uniformScaleFactor: numberOrNull(fig.uniformScaleFactor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeometryPaths(value: unknown): GeometryPath[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const paths: GeometryPath[] = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item) || !(item.commandsBlob instanceof Uint8Array)) continue
|
||||||
|
const path: GeometryPath = {
|
||||||
|
windingRule: item.windingRule === 'EVENODD' ? 'EVENODD' : 'NONZERO',
|
||||||
|
commandsBlob: new Uint8Array(item.commandsBlob)
|
||||||
|
}
|
||||||
|
const fills = normalizeFills(item.fills)
|
||||||
|
if (fills.length > 0) path.fills = copyFills(fills)
|
||||||
|
if (typeof item.fillStyleId === 'string') path.fillStyleId = item.fillStyleId
|
||||||
|
paths.push(path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVector(value: unknown): Vector | null {
|
||||||
|
if (!isRecord(value) || !isFiniteNumber(value.x) || !isFiniteNumber(value.y)) return null
|
||||||
|
return { x: value.x, y: value.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatrix(value: unknown): Matrix | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
const entries = [value.m00, value.m01, value.m02, value.m10, value.m11, value.m12]
|
||||||
|
if (!entries.every((item) => isFiniteNumber(item))) return null
|
||||||
|
return {
|
||||||
|
m00: value.m00 as number,
|
||||||
|
m01: value.m01 as number,
|
||||||
|
m02: value.m02 as number,
|
||||||
|
m10: value.m10 as number,
|
||||||
|
m11: value.m11 as number,
|
||||||
|
m12: value.m12 as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFills(value: unknown): Fill[] {
|
||||||
|
return Array.isArray(value) ? value.filter(isFill) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFill(value: unknown): value is Fill {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
typeof value.type === 'string' &&
|
||||||
|
FILL_TYPES.has(value.type as FillType) &&
|
||||||
|
isColor(value.color) &&
|
||||||
|
isFiniteNumber(value.opacity) &&
|
||||||
|
typeof value.visible === 'boolean' &&
|
||||||
|
isOptionalGradientStops(value.gradientStops) &&
|
||||||
|
isOptionalMatrix(value.gradientTransform) &&
|
||||||
|
isOptionalMatrix(value.imageTransform) &&
|
||||||
|
isOptionalVector(value.patternSpacing) &&
|
||||||
|
isOptionalVector(value.noiseSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionalGradientStops(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
value === undefined ||
|
||||||
|
(Array.isArray(value) &&
|
||||||
|
value.every((stop) => isRecord(stop) && isFiniteNumber(stop.position) && isColor(stop.color)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionalMatrix(value: unknown): boolean {
|
||||||
|
return value === undefined || normalizeMatrix(value) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionalVector(value: unknown): boolean {
|
||||||
|
return value === undefined || normalizeVector(value) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColor(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
isFiniteNumber(value.r) &&
|
||||||
|
isFiniteNumber(value.g) &&
|
||||||
|
isFiniteNumber(value.b) &&
|
||||||
|
isFiniteNumber(value.a)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayOrEmpty(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? structuredClone(value) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === 'string')
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
|
|
||||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
import { decodeNodeFromYjs, syncEncodedNodeToYMap } from '@/app/collab/node-codec'
|
||||||
|
|
||||||
import type { EditorStore } from '@/app/editor/active-store'
|
import type { EditorStore } from '@/app/editor/active-store'
|
||||||
|
|
||||||
type YNodes = Y.Map<Y.Map<unknown>>
|
type YNodes = Y.Map<Y.Map<unknown>>
|
||||||
|
|
@ -37,21 +36,6 @@ function logCollabSyncError(context: string, error: unknown) {
|
||||||
console.error(`[Collab] ${context}:`, error)
|
console.error(`[Collab] ${context}:`, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone across the graph/Yjs boundary to avoid shared mutable nested data.
|
|
||||||
export function syncNodePropsToYMap(node: SceneNode, ynode: Y.Map<unknown>) {
|
|
||||||
for (const [key, value] of Object.entries(node)) {
|
|
||||||
ynode.set(key, structuredClone(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function yNodeToProps(ynode: Y.Map<unknown>): Record<string, unknown> {
|
|
||||||
const props: Record<string, unknown> = {}
|
|
||||||
for (const [key, value] of ynode.entries()) {
|
|
||||||
props[key] = structuredClone(value)
|
|
||||||
}
|
|
||||||
return props
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bindCollabGraphEvents({
|
export function bindCollabGraphEvents({
|
||||||
store,
|
store,
|
||||||
getYdoc,
|
getYdoc,
|
||||||
|
|
@ -156,7 +140,7 @@ export function createYjsGraphSync({
|
||||||
ynode = new Y.Map()
|
ynode = new Y.Map()
|
||||||
ynodes.set(nodeId, ynode)
|
ynodes.set(nodeId, ynode)
|
||||||
}
|
}
|
||||||
syncNodePropsToYMap(node, ynode)
|
syncEncodedNodeToYMap(node, ynode)
|
||||||
|
|
||||||
if (localYimages) {
|
if (localYimages) {
|
||||||
for (const fill of node.fills) {
|
for (const fill of node.fills) {
|
||||||
|
|
@ -189,7 +173,7 @@ export function createYjsGraphSync({
|
||||||
ynode = new Y.Map()
|
ynode = new Y.Map()
|
||||||
ynodes.set(node.id, ynode)
|
ynodes.set(node.id, ynode)
|
||||||
}
|
}
|
||||||
syncNodePropsToYMap(node, ynode)
|
syncEncodedNodeToYMap(node, ynode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (localYimages) {
|
if (localYimages) {
|
||||||
|
|
@ -244,20 +228,20 @@ export function createYjsGraphSync({
|
||||||
function applyYnodeToGraph(nodeId: string, ynode: Y.Map<unknown>) {
|
function applyYnodeToGraph(nodeId: string, ynode: Y.Map<unknown>) {
|
||||||
const store = getStore()
|
const store = getStore()
|
||||||
const existing = store.graph.getNode(nodeId)
|
const existing = store.graph.getNode(nodeId)
|
||||||
const props = yNodeToProps(ynode)
|
const props = decodeNodeFromYjs(ynode)
|
||||||
const parentId = typeof props.parentId === 'string' ? props.parentId : null
|
const parentId = typeof props.parentId === 'string' ? props.parentId : null
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
store.graph.updateNode(nodeId, props as Partial<SceneNode>)
|
store.graph.updateNode(nodeId, props)
|
||||||
if (parentId === null) store.graph.rootId = nodeId
|
if (parentId === null) store.graph.rootId = nodeId
|
||||||
ensureCurrentPageExists(store)
|
ensureCurrentPageExists(store)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = props.type as SceneNode['type'] | undefined
|
const type = props.type
|
||||||
if (!type) return
|
if (!type) return
|
||||||
// Parent childIds may arrive before or after the child node.
|
// Parent childIds may arrive before or after the child node.
|
||||||
store.graph.createNodeWithId(nodeId, type, parentId, props as Partial<SceneNode>)
|
store.graph.createNodeWithId(nodeId, type, parentId, props)
|
||||||
if (parentId === null) store.graph.rootId = nodeId
|
if (parentId === null) store.graph.rootId = nodeId
|
||||||
ensureCurrentPageExists(store)
|
ensureCurrentPageExists(store)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,10 @@ import * as Y from 'yjs'
|
||||||
import type { Fill, GeometryPath, SceneNode } from '@open-pencil/scene-graph'
|
import type { Fill, GeometryPath, SceneNode } from '@open-pencil/scene-graph'
|
||||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||||
import { nodeVisualBounds } from '@open-pencil/scene-graph/geometry'
|
import { nodeVisualBounds } from '@open-pencil/scene-graph/geometry'
|
||||||
|
import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defaults'
|
||||||
|
|
||||||
import {
|
import { decodeNodeFromYjs, syncEncodedNodeToYMap } from '@/app/collab/node-codec'
|
||||||
createYjsGraphSync,
|
import { createYjsGraphSync, registerYjsObservers } from '@/app/collab/yjs-sync'
|
||||||
registerYjsObservers,
|
|
||||||
syncNodePropsToYMap,
|
|
||||||
yNodeToProps
|
|
||||||
} from '@/app/collab/yjs-sync'
|
|
||||||
import { createEditorStore } from '@/app/editor/session'
|
import { createEditorStore } from '@/app/editor/session'
|
||||||
|
|
||||||
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
|
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
|
||||||
|
|
@ -19,7 +16,7 @@ import { connectYDocs } from '#tests/helpers/yjs'
|
||||||
|
|
||||||
// Test copy of the private apply path.
|
// Test copy of the private apply path.
|
||||||
function applyYnodeToGraph(peer: SceneGraph, nodeId: string, ynode: Y.Map<unknown>) {
|
function applyYnodeToGraph(peer: SceneGraph, nodeId: string, ynode: Y.Map<unknown>) {
|
||||||
const props = yNodeToProps(ynode)
|
const props = decodeNodeFromYjs(ynode)
|
||||||
if (peer.getNode(nodeId)) {
|
if (peer.getNode(nodeId)) {
|
||||||
peer.updateNode(nodeId, props as Partial<SceneNode>)
|
peer.updateNode(nodeId, props as Partial<SceneNode>)
|
||||||
return
|
return
|
||||||
|
|
@ -38,7 +35,7 @@ function seedHostIntoYjs(host: SceneGraph): Y.Map<Y.Map<unknown>> {
|
||||||
for (const node of host.getAllNodes()) {
|
for (const node of host.getAllNodes()) {
|
||||||
const ynode = new Y.Map<unknown>()
|
const ynode = new Y.Map<unknown>()
|
||||||
ynodes.set(node.id, ynode)
|
ynodes.set(node.id, ynode)
|
||||||
syncNodePropsToYMap(node, ynode)
|
syncEncodedNodeToYMap(node, ynode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return ynodes
|
return ynodes
|
||||||
|
|
@ -149,6 +146,97 @@ describe('collab yjs-sync', () => {
|
||||||
expect(page.childIds).toContain('remote-id')
|
expect(page.childIds).toContain('remote-id')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('excludes derived text pictures from collaboration payloads', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const page = firstPage(graph)
|
||||||
|
const text = graph.createNode('TEXT', page.id, {
|
||||||
|
text: 'Shared text',
|
||||||
|
textPicture: new Uint8Array([4, 5, 6])
|
||||||
|
})
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const ynode = new Y.Map<unknown>()
|
||||||
|
doc.getMap<Y.Map<unknown>>('nodes').set(text.id, ynode)
|
||||||
|
|
||||||
|
syncEncodedNodeToYMap(text, ynode)
|
||||||
|
|
||||||
|
expect(ynode.has('textPicture')).toBe(false)
|
||||||
|
ynode.set('textPicture', new Uint8Array([9]))
|
||||||
|
expect(decodeNodeFromYjs(ynode).textPicture).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizes malformed source metadata and geometry at the remote boundary', () => {
|
||||||
|
const doc = new Y.Doc()
|
||||||
|
const ynode = new Y.Map<unknown>()
|
||||||
|
doc.getMap<Y.Map<unknown>>('nodes').set('remote', ynode)
|
||||||
|
ynode.set('source', { format: 'fig', fig: { rawNodeFields: 'invalid' } })
|
||||||
|
ynode.set('fillGeometry', [
|
||||||
|
{
|
||||||
|
windingRule: 'EVENODD',
|
||||||
|
commandsBlob: new Uint8Array([0]),
|
||||||
|
fills: [
|
||||||
|
null,
|
||||||
|
'invalid',
|
||||||
|
{
|
||||||
|
type: 'GRADIENT_LINEAR',
|
||||||
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
|
opacity: 1,
|
||||||
|
visible: true,
|
||||||
|
gradientStops: 'invalid'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'NOISE',
|
||||||
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
|
opacity: 1,
|
||||||
|
visible: true,
|
||||||
|
noiseSize: 'invalid'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'GRADIENT_LINEAR',
|
||||||
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
|
opacity: 0.8,
|
||||||
|
visible: true,
|
||||||
|
gradientStops: [
|
||||||
|
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
||||||
|
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 }
|
||||||
|
],
|
||||||
|
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'SOLID',
|
||||||
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
||||||
|
opacity: 1,
|
||||||
|
visible: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ windingRule: 'NONZERO', commandsBlob: 'invalid' }
|
||||||
|
])
|
||||||
|
ynode.set('strokeGeometry', 'invalid')
|
||||||
|
|
||||||
|
const props = decodeNodeFromYjs(ynode)
|
||||||
|
const source = props.source as SceneNode['source']
|
||||||
|
const fillGeometry = props.fillGeometry as GeometryPath[]
|
||||||
|
|
||||||
|
expect(source).toEqual({
|
||||||
|
...createDefaultSourceMetadata(),
|
||||||
|
format: 'fig'
|
||||||
|
})
|
||||||
|
expect(fillGeometry).toHaveLength(1)
|
||||||
|
expect(fillGeometry[0]?.commandsBlob).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(fillGeometry[0]?.fills).toHaveLength(2)
|
||||||
|
expect(fillGeometry[0]?.fills?.[0]).toMatchObject({
|
||||||
|
type: 'GRADIENT_LINEAR',
|
||||||
|
opacity: 0.8,
|
||||||
|
gradientStops: [
|
||||||
|
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
||||||
|
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 }
|
||||||
|
],
|
||||||
|
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||||
|
})
|
||||||
|
expect(fillGeometry[0]?.fills?.[1]?.type).toBe('SOLID')
|
||||||
|
expect(props.strokeGeometry).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('binary geometry fields round-trip as Uint8Array, not strings', () => {
|
test('binary geometry fields round-trip as Uint8Array, not strings', () => {
|
||||||
const host = new SceneGraph()
|
const host = new SceneGraph()
|
||||||
const page = firstPage(host)
|
const page = firstPage(host)
|
||||||
|
|
@ -163,9 +251,9 @@ describe('collab yjs-sync', () => {
|
||||||
const doc = new Y.Doc()
|
const doc = new Y.Doc()
|
||||||
const ynode = new Y.Map<unknown>()
|
const ynode = new Y.Map<unknown>()
|
||||||
doc.getMap<Y.Map<unknown>>('nodes').set(ellipse.id, ynode)
|
doc.getMap<Y.Map<unknown>>('nodes').set(ellipse.id, ynode)
|
||||||
syncNodePropsToYMap(ellipse, ynode)
|
syncEncodedNodeToYMap(ellipse, ynode)
|
||||||
blob[0] = 99
|
blob[0] = 99
|
||||||
const props = yNodeToProps(ynode)
|
const props = decodeNodeFromYjs(ynode)
|
||||||
|
|
||||||
expect(typeof ynode.get('fillGeometry')).not.toBe('string')
|
expect(typeof ynode.get('fillGeometry')).not.toBe('string')
|
||||||
const decoded = props.fillGeometry as GeometryPath[]
|
const decoded = props.fillGeometry as GeometryPath[]
|
||||||
|
|
@ -207,11 +295,11 @@ describe('collab yjs-sync', () => {
|
||||||
doc.transact(() => {
|
doc.transact(() => {
|
||||||
const pageYnode = new Y.Map<unknown>()
|
const pageYnode = new Y.Map<unknown>()
|
||||||
ynodes.set(hostPage.id, pageYnode)
|
ynodes.set(hostPage.id, pageYnode)
|
||||||
syncNodePropsToYMap({ ...hostPage, childIds: [] } as SceneNode, pageYnode)
|
syncEncodedNodeToYMap({ ...hostPage, childIds: [] } as SceneNode, pageYnode)
|
||||||
|
|
||||||
const rectYnode = new Y.Map<unknown>()
|
const rectYnode = new Y.Map<unknown>()
|
||||||
ynodes.set(rect.id, rectYnode)
|
ynodes.set(rect.id, rectYnode)
|
||||||
syncNodePropsToYMap(rect, rectYnode)
|
syncEncodedNodeToYMap(rect, rectYnode)
|
||||||
})
|
})
|
||||||
|
|
||||||
const peer = new SceneGraph()
|
const peer = new SceneGraph()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue