Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 54 additions & 34 deletions examples/excalidraw-example/src/hooks/useLoroSync.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { throttle } from "throttle-debounce"; // TODO: REVIEW [stability] replace custom throttle with lib
import { LoroDoc, EphemeralStore, LoroEventBatch, LoroMap } from "loro-crdt";
import {
LoroDoc,
EphemeralStore,
LoroEventBatch,
LoroMap,
type Value,
} from "loro-crdt";
import { LoroWebsocketClient } from "loro-websocket/client";
import { LoroAdaptor, LoroEphemeralAdaptor } from "loro-adaptors";
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
import type {
AppState as ExcalidrawAppState,
ExcalidrawImperativeAPI,
} from "@excalidraw/excalidraw/types/types";
import type { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types";

interface UseLoroSyncOptions {
roomId: string;
Expand All @@ -14,35 +24,28 @@ interface UseLoroSyncOptions {
excalidrawAPI: React.RefObject<ExcalidrawImperativeAPI>;
}

interface Collaborator {
interface PresenceEntry extends Record<string, Value> {
userId: string;
userName: string;
userColor: string;
cursor?: { x: number; y: number };
cursor?: CursorPosition;
selectedElementIds?: string[];
lastActive: number;
}

interface CursorPosition {
interface CursorPosition extends Record<string, Value> {
x: number;
y: number;
}
interface Collaborator extends PresenceEntry {}
type AppState = ExcalidrawAppState;

// Minimal type definitions for Excalidraw (to avoid import issues)
export interface ExcalidrawElement {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
version: number;
[key: string]: any;
}

export interface AppState {
[key: string]: any;
}
type SceneUpdateArgs = Parameters<
ExcalidrawImperativeAPI["updateScene"]
>[0];
type SceneElements = NonNullable<SceneUpdateArgs["elements"]>;
type SceneAppStateUpdate = NonNullable<SceneUpdateArgs["appState"]>;
type PresenceStoreState = Record<string, PresenceEntry>;

export function useLoroSync({
roomId,
Expand All @@ -54,7 +57,8 @@ export function useLoroSync({
}: UseLoroSyncOptions) {
const docRef = useRef<LoroDoc | null>(null);
const clientRef = useRef<LoroWebsocketClient | null>(null);
const ephemeralRef = useRef<EphemeralStore<Record<string, any>> | null>(null);
const ephemeralRef =
useRef<EphemeralStore<PresenceStoreState> | null>(null);

const [isConnected, setIsConnected] = useState(false);
const [collaborators, setCollaborators] = useState<Map<string, Collaborator>>(new Map());
Expand All @@ -65,7 +69,7 @@ export function useLoroSync({
useEffect(() => {
const doc = new LoroDoc();
const client = new LoroWebsocketClient({ url: wsUrl });
const ephemeral = new EphemeralStore<Record<string, any>>(30000); // 30 second timeout
const ephemeral = new EphemeralStore<PresenceStoreState>(30000); // 30 second timeout

docRef.current = doc;
clientRef.current = client;
Expand All @@ -81,7 +85,8 @@ export function useLoroSync({
if (event.by !== "local") {
// Build scene data from doc and apply to Excalidraw. Avoid echo via flag.
// TODO: REVIEW [avoid echo] We set a guard so the next Excalidraw onChange from updateScene is ignored.
const newElements = (elementsContainer.toJSON() || []) as ExcalidrawElement[];
const newElements =
(elementsContainer.toJSON() || []) as SceneElements;
const newAppState: Partial<AppState> = {};
for (const [key, value] of appStateContainer.entries()) {
newAppState[key as keyof AppState] = value;
Expand All @@ -90,7 +95,11 @@ export function useLoroSync({
// Update checksum to match scene state
const checksum = newElements.reduce((acc, e) => acc + (e?.version || 0), 0);
lastChecksumRef.current = checksum;
excalidrawAPI.current?.updateScene({ elements: newElements as any, appState: newAppState as any });
const sceneUpdate: SceneUpdateArgs = { elements: newElements };
if (Object.keys(newAppState).length > 0) {
sceneUpdate.appState = newAppState as SceneAppStateUpdate;
}
excalidrawAPI.current?.updateScene(sceneUpdate);
}
});

Expand Down Expand Up @@ -197,6 +206,17 @@ export function useLoroSync({

const doc = docRef.current;
const list = doc.getList("elements");
const getMapAt = (index: number): LoroMap | undefined => {
const value = list.get(index);
return value instanceof LoroMap ? value : undefined;
};
const ensureMapAt = (index: number): LoroMap => {
const map = getMapAt(index);
if (!map) {
throw new Error(`Expected LoroMap at index ${index}`);
}
return map;
};

// Filter out deleted
const filtered = elements.filter(e => !e.isDeleted);
Expand All @@ -205,9 +225,9 @@ export function useLoroSync({
const buildIndex = () => {
const idx = new Map<string, number>();
for (let i = 0; i < list.length; i++) {
const m = list.get(i) as unknown as LoroMap | undefined;
if (!m) continue;
const id = m.get("id") as string | undefined;
const map = getMapAt(i);
if (!map) continue;
const id = map.get("id") as string | undefined;
if (id) idx.set(id, i);
}
return idx;
Expand All @@ -223,9 +243,9 @@ export function useLoroSync({
if (pos == null) {
// New element: insert at the desired position
list.insertContainer(i, new LoroMap());
const m = list.get(i) as unknown as LoroMap;
const map = ensureMapAt(i);
for (const [k, v] of Object.entries(target)) {
m.set(k, v);
map.set(k, v);
}
changed = true;
indexMap = buildIndex();
Expand All @@ -237,21 +257,21 @@ export function useLoroSync({
list.delete(pos, 1);
const adjI = pos < i ? i - 1 : i;
list.insertContainer(adjI, new LoroMap());
const m = list.get(adjI) as unknown as LoroMap;
const map = ensureMapAt(adjI);
for (const [k, v] of Object.entries(target)) {
m.set(k, v);
map.set(k, v);
}
changed = true;
indexMap = buildIndex();
continue;
}

// Same position: update only if version changed
const m = list.get(i) as unknown as LoroMap;
const prevVersion = m.get("version");
const map = ensureMapAt(i);
const prevVersion = map.get("version");
if (prevVersion !== target.version) {
for (const [k, v] of Object.entries(target)) {
m.set(k, v);
map.set(k, v);
}
changed = true;
}
Expand Down
5 changes: 3 additions & 2 deletions examples/excalidraw-example/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
}
16 changes: 10 additions & 6 deletions packages/loro-adaptors/src/elo-loro-adaptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,20 @@ export class EloLoroAdaptor implements CrdtDocAdaptor {

if (spans.length === 1) {
const { keyId, key } = await this.config.getPrivateKey();
const s = spans[0]!;
const peerIdBytes = new TextEncoder().encode(String(s.peer));
const [span] = spans;
if (!span) {
throw new Error("Expected delta span when packaging single update");
}
const peerIdBytes = new TextEncoder().encode(String(span.peer));
const iv = this.config.ivFactory
? this.config.ivFactory()
: undefined;
const { record } = await encryptDeltaSpan(
updates,
{
peerId: peerIdBytes,
start: s.start,
end: s.start + s.length,
start: span.start,
end: span.start + span.length,
keyId,
iv,
},
Expand Down Expand Up @@ -247,10 +250,11 @@ export class EloLoroAdaptor implements CrdtDocAdaptor {
const mode = "snapshot";
const plaintext = this.doc.export({ mode });
const vvObj = vvToObject(this.doc.version());
const encoder = new TextEncoder();
const vvEntries: Array<{ peerId: Uint8Array; counter: number }> =
Object.keys(vvObj).map(peer => ({
peerId: new TextEncoder().encode(peer),
counter: vvObj[peer]!,
peerId: encoder.encode(peer),
counter: vvObj[peer],
}));
const iv = this.config.ivFactory ? this.config.ivFactory() : undefined;
const { record } = await encryptSnapshot(
Expand Down
1 change: 1 addition & 0 deletions packages/loro-adaptors/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./types";
export * from "./adaptors";
export * from "./server";
4 changes: 4 additions & 0 deletions packages/loro-adaptors/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./server-registry";
export * from "./server-loro-adaptor";
export * from "./server-loro-ephemeral-adaptor";
export * from "./server-yjs-awareness-adaptor";
Loading