fix: sync pasted/uploaded images across collaborating tabs (#36)
* fix: sync pasted/uploaded images across collaborating tabs - Implement file delta synchronization to broadcast image file data - Add periodic file sync check to catch async file data arrival - Wrap Excalidraw addFiles API to automatically emit file changes - Enhance socket element-update to include file payloads - Add comprehensive E2E test for image collaboration scenarios - Improve CORS flexibility for development localhost ports Fixes #25: New images not appearing when collaborating - collaborators now see uploaded images immediately instead of placeholder until refresh. * perf: increase file sync polling interval from 500ms to 1000ms Reduces CPU overhead while still catching async file arrivals. Most updates go through the addFiles wrapper anyway. --------- Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
This commit is contained in:
committed by
Zimeng Xiong
parent
77c22916a8
commit
865285fbb7
+20
-4
@@ -96,6 +96,22 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
|
|||||||
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
||||||
console.log("Allowed origins:", allowedOrigins);
|
console.log("Allowed origins:", allowedOrigins);
|
||||||
|
|
||||||
|
const isDev = (process.env.NODE_ENV || "development") !== "production";
|
||||||
|
const isLocalDevOrigin = (origin: string): boolean => {
|
||||||
|
// Allow any localhost/127.0.0.1 port in dev (Vite often picks a free port).
|
||||||
|
return (
|
||||||
|
/^http:\/\/localhost:\d+$/i.test(origin) ||
|
||||||
|
/^http:\/\/127\.0\.0\.1:\d+$/i.test(origin)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedOrigin = (origin?: string): boolean => {
|
||||||
|
if (!origin) return true; // non-browser clients / same-origin
|
||||||
|
if (allowedOrigins.includes(origin)) return true;
|
||||||
|
if (isDev && isLocalDevOrigin(origin)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const uploadDir = path.resolve(__dirname, "../uploads");
|
const uploadDir = path.resolve(__dirname, "../uploads");
|
||||||
|
|
||||||
const moveFile = async (source: string, destination: string) => {
|
const moveFile = async (source: string, destination: string) => {
|
||||||
@@ -138,7 +154,7 @@ app.set("trust proxy", 1);
|
|||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
const io = new Server(httpServer, {
|
const io = new Server(httpServer, {
|
||||||
cors: {
|
cors: {
|
||||||
origin: allowedOrigins,
|
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
maxHttpBufferSize: 1e8,
|
maxHttpBufferSize: 1e8,
|
||||||
@@ -236,7 +252,7 @@ const upload = multer({
|
|||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: allowedOrigins,
|
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
|
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
|
||||||
exposedHeaders: ["x-csrf-token"],
|
exposedHeaders: ["x-csrf-token"],
|
||||||
@@ -402,7 +418,7 @@ const csrfProtectionMiddleware = (
|
|||||||
const refererValue = Array.isArray(referer) ? referer[0] : referer;
|
const refererValue = Array.isArray(referer) ? referer[0] : referer;
|
||||||
|
|
||||||
if (originValue) {
|
if (originValue) {
|
||||||
if (!allowedOrigins.includes(originValue)) {
|
if (!isAllowedOrigin(originValue)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: "CSRF origin mismatch",
|
error: "CSRF origin mismatch",
|
||||||
message: "Origin not allowed",
|
message: "Origin not allowed",
|
||||||
@@ -411,7 +427,7 @@ const csrfProtectionMiddleware = (
|
|||||||
} else if (refererValue) {
|
} else if (refererValue) {
|
||||||
// If no Origin but Referer exists, validate its *origin* (avoid prefix bypass)
|
// If no Origin but Referer exists, validate its *origin* (avoid prefix bypass)
|
||||||
const refererOrigin = getOriginFromReferer(refererValue);
|
const refererOrigin = getOriginFromReferer(refererValue);
|
||||||
if (!refererOrigin || !allowedOrigins.includes(refererOrigin)) {
|
if (!refererOrigin || !isAllowedOrigin(refererOrigin)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: "CSRF referer mismatch",
|
error: "CSRF referer mismatch",
|
||||||
message: "Referer not allowed",
|
message: "Referer not allowed",
|
||||||
|
|||||||
@@ -174,6 +174,37 @@ export async function getDrawing(
|
|||||||
return (await response.json()) as DrawingRecord;
|
return (await response.json()) as DrawingRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDrawing(
|
||||||
|
request: APIRequestContext,
|
||||||
|
id: string,
|
||||||
|
data: Partial<DrawingRecord>
|
||||||
|
): Promise<DrawingRecord> {
|
||||||
|
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||||
|
|
||||||
|
let response = await request.put(`${API_URL}/drawings/${id}`, {
|
||||||
|
headers,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok() && response.status() === 403) {
|
||||||
|
await refreshCsrfInfo(request);
|
||||||
|
const retryHeaders = await withCsrfHeaders(request, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
response = await request.put(`${API_URL}/drawings/${id}`, {
|
||||||
|
headers: retryHeaders,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Failed to update drawing ${id}: ${response.status()} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as DrawingRecord;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteDrawing(
|
export async function deleteDrawing(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { test, expect, type Page, type BrowserContext } from "@playwright/test";
|
||||||
|
import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpers/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for:
|
||||||
|
* - Issue #25: pasted image doesn't load in other tabs
|
||||||
|
* - Follow-up: deleting the image in one tab should remove it from all tabs
|
||||||
|
*
|
||||||
|
* NOTE:
|
||||||
|
* We drive the editor via Excalidraw's API (exposed in dev/test builds) to make
|
||||||
|
* the test deterministic and to specifically model the async "element first,
|
||||||
|
* file data later" behavior seen with paste/import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const waitForEditorReady = async (page: Page) => {
|
||||||
|
await page.goto(page.url() || "/");
|
||||||
|
// Excalidraw renders a canvas; this is our "loaded" signal.
|
||||||
|
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
// @ts-expect-error - injected in dev build
|
||||||
|
return !!(window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditorTab = async (context: BrowserContext, drawingId: string) => {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/editor/${drawingId}`);
|
||||||
|
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
// @ts-expect-error - injected in dev build
|
||||||
|
return !!(window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
});
|
||||||
|
// Wait for socket connection (critical for realtime sync assertions).
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
// @ts-expect-error - injected in dev build
|
||||||
|
return (window as any).__EXCALIDASH_SOCKET_STATUS__?.connected === true;
|
||||||
|
});
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForFileInEditor = async (page: Page, fileId: string) => {
|
||||||
|
// Excalidraw may clear `dataURL` from in-memory files for perf/memory,
|
||||||
|
// so the stable signal is that the file entry exists.
|
||||||
|
const timeoutMs = 30000;
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const ok = await page.evaluate((id) => {
|
||||||
|
const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
const files = api?.getFiles?.() || {};
|
||||||
|
const entry = files?.[id];
|
||||||
|
return !!entry && typeof entry.mimeType === "string";
|
||||||
|
}, fileId);
|
||||||
|
if (ok) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for file ${fileId} to exist in editor`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const injectImageElementThenFile = async (page: Page) => {
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
if (!api) throw new Error("Missing __EXCALIDASH_EXCALIDRAW_API__");
|
||||||
|
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(20));
|
||||||
|
const fileId = Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
const elementId = `img_${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
// Tiny PNG data URL
|
||||||
|
const dataURL =
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAIElEQVR42mP8z8Dwn4EIwDiqgWjAqIGhBo4aGAAAcO0Gg+o1P8oAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const element = {
|
||||||
|
id: elementId,
|
||||||
|
type: "image",
|
||||||
|
x: 120,
|
||||||
|
y: 120,
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
angle: 0,
|
||||||
|
strokeColor: "#1e1e1e",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
fillStyle: "solid",
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeStyle: "solid",
|
||||||
|
roundness: null,
|
||||||
|
roughness: 0,
|
||||||
|
opacity: 100,
|
||||||
|
groupIds: [],
|
||||||
|
frameId: null,
|
||||||
|
seed: Math.floor(Math.random() * 2 ** 31),
|
||||||
|
version: 1,
|
||||||
|
versionNonce: Math.floor(Math.random() * 2 ** 31),
|
||||||
|
isDeleted: false,
|
||||||
|
boundElements: null,
|
||||||
|
link: null,
|
||||||
|
locked: false,
|
||||||
|
index: "a1",
|
||||||
|
updated: now,
|
||||||
|
status: "pending",
|
||||||
|
fileId,
|
||||||
|
scale: [1, 1],
|
||||||
|
crop: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const before = api.getSceneElementsIncludingDeleted();
|
||||||
|
api.updateScene({ elements: [...before, element] });
|
||||||
|
|
||||||
|
// Simulate async file arrival (paste/import often behaves like this)
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
api.addFiles({
|
||||||
|
[fileId]: {
|
||||||
|
id: fileId,
|
||||||
|
mimeType: "image/png",
|
||||||
|
dataURL,
|
||||||
|
created: Date.now(),
|
||||||
|
lastRetrieved: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fileId, elementId };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForElementPresent = async (page: Page, elementId: string) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
const els = api?.getSceneElementsIncludingDeleted?.() || [];
|
||||||
|
const el = els.find((e: any) => e?.id === id);
|
||||||
|
return !!el && el.isDeleted !== true;
|
||||||
|
},
|
||||||
|
elementId,
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForElementDeletedEverywhere = async (page: Page, elementId: string) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
const els = api?.getSceneElementsIncludingDeleted?.() || [];
|
||||||
|
const el = els.find((e: any) => e?.id === id);
|
||||||
|
return !!el && el.isDeleted === true;
|
||||||
|
},
|
||||||
|
elementId,
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("Issue #25 - image sync + deletion across tabs", () => {
|
||||||
|
const createdDrawingIds: string[] = [];
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
for (const id of createdDrawingIds) {
|
||||||
|
try {
|
||||||
|
await deleteDrawing(request, id);
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createdDrawingIds.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("image added in tab1 appears in tab2 and tab3; deletion propagates to all tabs", async ({
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
const drawing = await createDrawing(request, {
|
||||||
|
name: `Issue25_ImageCollab_${Date.now()}`,
|
||||||
|
elements: [],
|
||||||
|
files: {},
|
||||||
|
});
|
||||||
|
createdDrawingIds.push(drawing.id);
|
||||||
|
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page1 = await openEditorTab(context, drawing.id);
|
||||||
|
const page2 = await openEditorTab(context, drawing.id);
|
||||||
|
|
||||||
|
// Create the image in tab1 (element first, file later) to model paste/import.
|
||||||
|
const { fileId, elementId } = await injectImageElementThenFile(page1);
|
||||||
|
|
||||||
|
// Tab2 should receive the element and the file in real-time.
|
||||||
|
await waitForElementPresent(page2, elementId);
|
||||||
|
await waitForFileInEditor(page2, fileId);
|
||||||
|
|
||||||
|
// Persist the current state explicitly (ensures tab3 loads it even if the editor didn't auto-save).
|
||||||
|
const snapshot = await page1.evaluate(() => {
|
||||||
|
const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
const elements = api.getSceneElementsIncludingDeleted();
|
||||||
|
const files = api.getFiles?.() || {};
|
||||||
|
const appState = api.getAppState?.() || {};
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
files,
|
||||||
|
appState: {
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor ?? "#ffffff",
|
||||||
|
gridSize: appState.gridSize ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await updateDrawing(request, drawing.id, snapshot);
|
||||||
|
|
||||||
|
// Open tab3 and ensure it loads (persistence path)
|
||||||
|
const page3 = await openEditorTab(context, drawing.id);
|
||||||
|
await waitForFileInEditor(page3, fileId);
|
||||||
|
|
||||||
|
// Force the "tab2 doesn't disappear" repro: keep the image selected in tab2.
|
||||||
|
await page2.evaluate((id) => {
|
||||||
|
const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
const appState = api.getAppState();
|
||||||
|
api.updateScene({
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedElementIds: { ...(appState.selectedElementIds || {}), [id]: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, elementId);
|
||||||
|
|
||||||
|
// Delete the image from tab1 (programmatic delete to ensure broadcast)
|
||||||
|
await page1.evaluate((id) => {
|
||||||
|
const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__;
|
||||||
|
const els = api.getSceneElementsIncludingDeleted();
|
||||||
|
const target = els.find((e: any) => e?.id === id);
|
||||||
|
if (!target) throw new Error("Target element not found");
|
||||||
|
const updated = {
|
||||||
|
...target,
|
||||||
|
isDeleted: true,
|
||||||
|
version: (target.version ?? 0) + 1,
|
||||||
|
versionNonce: Math.floor(Math.random() * 2 ** 31),
|
||||||
|
updated: Date.now(),
|
||||||
|
};
|
||||||
|
api.updateScene({ elements: els.map((e: any) => (e.id === id ? updated : e)) });
|
||||||
|
}, elementId);
|
||||||
|
|
||||||
|
// All tabs should converge to deleted
|
||||||
|
await waitForElementDeletedEverywhere(page2, elementId);
|
||||||
|
await waitForElementDeletedEverywhere(page3, elementId);
|
||||||
|
|
||||||
|
// Also verify persistence layer captured the file (tab3 load case)
|
||||||
|
const persisted = await getDrawing(request, drawing.id);
|
||||||
|
const persistedFile = persisted.files?.[fileId];
|
||||||
|
expect(typeof persistedFile?.dataURL).toBe("string");
|
||||||
|
expect((persistedFile?.dataURL || "").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
+193
-12
@@ -37,6 +37,44 @@ const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildFileSignature = (file: any): string => {
|
||||||
|
const mimeType = typeof file?.mimeType === "string" ? file.mimeType : "";
|
||||||
|
const id = typeof file?.id === "string" ? file.id : "";
|
||||||
|
const dataURL = typeof file?.dataURL === "string" ? file.dataURL : "";
|
||||||
|
// Avoid keeping the whole dataURL for comparisons; use a cheap signature.
|
||||||
|
const prefix = dataURL.slice(0, 32);
|
||||||
|
const suffix = dataURL.slice(-32);
|
||||||
|
return `${id}|${mimeType}|${dataURL.length}|${prefix}|${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilesDelta = (
|
||||||
|
previous: Record<string, any>,
|
||||||
|
next: Record<string, any>
|
||||||
|
): Record<string, any> => {
|
||||||
|
const delta: Record<string, any> = {};
|
||||||
|
const prev = previous || {};
|
||||||
|
const nxt = next || {};
|
||||||
|
|
||||||
|
for (const fileId of Object.keys(nxt)) {
|
||||||
|
const nextFile = nxt[fileId];
|
||||||
|
const nextHasDataUrl = typeof nextFile?.dataURL === "string" && nextFile.dataURL.length > 0;
|
||||||
|
// Only sync files that actually have data; otherwise other tabs can't render yet.
|
||||||
|
if (!nextHasDataUrl) continue;
|
||||||
|
|
||||||
|
const prevFile = prev[fileId];
|
||||||
|
if (!prevFile) {
|
||||||
|
delta[fileId] = nextFile;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildFileSignature(prevFile) !== buildFileSignature(nextFile)) {
|
||||||
|
delta[fileId] = nextFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
};
|
||||||
|
|
||||||
const UIOptions = {
|
const UIOptions = {
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
saveToActiveFile: false,
|
saveToActiveFile: false,
|
||||||
@@ -79,6 +117,39 @@ export const Editor: React.FC = () => {
|
|||||||
const animationFrameId = useRef<number>(0);
|
const animationFrameId = useRef<number>(0);
|
||||||
const latestElementsRef = useRef<readonly any[]>([]);
|
const latestElementsRef = useRef<readonly any[]>([]);
|
||||||
const latestFilesRef = useRef<any>(null);
|
const latestFilesRef = useRef<any>(null);
|
||||||
|
const lastSyncedFilesRef = useRef<Record<string, any>>({});
|
||||||
|
const latestAppStateRef = useRef<any>(null);
|
||||||
|
const debouncedSaveRef = useRef<((elements: readonly any[], appState: any) => void) | null>(null);
|
||||||
|
|
||||||
|
const emitFilesDeltaIfNeeded = useCallback(
|
||||||
|
(nextFiles: Record<string, any>) => {
|
||||||
|
if (!socketRef.current || !id) return false;
|
||||||
|
const filesDelta = getFilesDelta(lastSyncedFilesRef.current, nextFiles || {});
|
||||||
|
if (Object.keys(filesDelta).length === 0) return false;
|
||||||
|
|
||||||
|
latestFilesRef.current = nextFiles;
|
||||||
|
lastSyncedFilesRef.current = nextFiles;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const dbg = ((window as any).__EXCALIDASH_E2E_DEBUG__ ||= {
|
||||||
|
fileEmits: 0,
|
||||||
|
lastFilesDeltaIds: [] as string[],
|
||||||
|
});
|
||||||
|
dbg.fileEmits += 1;
|
||||||
|
dbg.lastFilesDeltaIds = Object.keys(filesDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
socketRef.current.emit("element-update", {
|
||||||
|
drawingId: id,
|
||||||
|
elements: [],
|
||||||
|
files: filesDelta,
|
||||||
|
userId: me.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[id, me.id]
|
||||||
|
);
|
||||||
|
|
||||||
const recordElementVersion = useCallback((element: any) => {
|
const recordElementVersion = useCallback((element: any) => {
|
||||||
elementVersionMap.current.set(element.id, {
|
elementVersionMap.current.set(element.id, {
|
||||||
@@ -117,6 +188,19 @@ export const Editor: React.FC = () => {
|
|||||||
});
|
});
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
// DEV-only: expose socket status for E2E tests to wait for connection.
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
(window as any).__EXCALIDASH_SOCKET_STATUS__ = {
|
||||||
|
connected: socket.connected,
|
||||||
|
};
|
||||||
|
socket.on("connect", () => {
|
||||||
|
(window as any).__EXCALIDASH_SOCKET_STATUS__ = { connected: true };
|
||||||
|
});
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
(window as any).__EXCALIDASH_SOCKET_STATUS__ = { connected: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
socket.emit('join-room', { drawingId: id, user: me });
|
socket.emit('join-room', { drawingId: id, user: me });
|
||||||
|
|
||||||
// Start the render loop for cursors
|
// Start the render loop for cursors
|
||||||
@@ -161,7 +245,7 @@ export const Editor: React.FC = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('element-update', ({ elements }: { elements: any[] }) => {
|
socket.on('element-update', ({ elements, files }: { elements: any[]; files?: Record<string, any> }) => {
|
||||||
if (!excalidrawAPI.current) return;
|
if (!excalidrawAPI.current) return;
|
||||||
|
|
||||||
isSyncing.current = true;
|
isSyncing.current = true;
|
||||||
@@ -169,7 +253,11 @@ export const Editor: React.FC = () => {
|
|||||||
const currentAppState = excalidrawAPI.current.getAppState();
|
const currentAppState = excalidrawAPI.current.getAppState();
|
||||||
const mySelectedIds = currentAppState.selectedElementIds || {};
|
const mySelectedIds = currentAppState.selectedElementIds || {};
|
||||||
|
|
||||||
const validRemoteElements = elements.filter((el: any) => !mySelectedIds[el.id]);
|
// Don't overwrite elements I'm actively editing/dragging in this tab,
|
||||||
|
// BUT always apply remote deletions so all tabs converge.
|
||||||
|
const validRemoteElements = elements.filter(
|
||||||
|
(el: any) => el?.isDeleted || !mySelectedIds[el.id]
|
||||||
|
);
|
||||||
|
|
||||||
const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
||||||
const mergedElements = reconcileElements(localElements, validRemoteElements);
|
const mergedElements = reconcileElements(localElements, validRemoteElements);
|
||||||
@@ -178,11 +266,28 @@ export const Editor: React.FC = () => {
|
|||||||
recordElementVersion(el);
|
recordElementVersion(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const incomingFiles = files || {};
|
||||||
|
const shouldUpdateFiles = Object.keys(incomingFiles).length > 0;
|
||||||
|
const nextFiles = shouldUpdateFiles
|
||||||
|
? { ...lastSyncedFilesRef.current, ...incomingFiles }
|
||||||
|
: lastSyncedFilesRef.current;
|
||||||
|
|
||||||
|
if (shouldUpdateFiles && typeof excalidrawAPI.current.addFiles === "function") {
|
||||||
|
// Excalidraw manages binary files separately from scene elements; updateScene(files)
|
||||||
|
// is not reliable for syncing pasted images across tabs.
|
||||||
|
excalidrawAPI.current.addFiles(incomingFiles);
|
||||||
|
}
|
||||||
|
|
||||||
excalidrawAPI.current.updateScene({ elements: mergedElements });
|
excalidrawAPI.current.updateScene({ elements: mergedElements });
|
||||||
latestElementsRef.current = mergedElements;
|
latestElementsRef.current = mergedElements;
|
||||||
|
if (shouldUpdateFiles) {
|
||||||
|
latestFilesRef.current = nextFiles;
|
||||||
|
lastSyncedFilesRef.current = nextFiles;
|
||||||
|
}
|
||||||
isSyncing.current = false;
|
isSyncing.current = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const handleActivity = (isActive: boolean) => {
|
const handleActivity = (isActive: boolean) => {
|
||||||
socket.emit('user-activity', { drawingId: id, isActive });
|
socket.emit('user-activity', { drawingId: id, isActive });
|
||||||
};
|
};
|
||||||
@@ -230,8 +335,33 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
const setExcalidrawAPI = useCallback((api: any) => {
|
const setExcalidrawAPI = useCallback((api: any) => {
|
||||||
excalidrawAPI.current = api;
|
excalidrawAPI.current = api;
|
||||||
|
// DEV-only: expose API for debugging/e2e reproduction of collaboration bugs.
|
||||||
|
// This is intentionally not relied upon by app logic.
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
(window as any).__EXCALIDASH_EXCALIDRAW_API__ = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
||||||
|
// are broadcast immediately even if Excalidraw doesn't trigger `onChange` for files.
|
||||||
|
if (api && typeof api.addFiles === "function") {
|
||||||
|
const originalAddFiles = api.addFiles.bind(api);
|
||||||
|
api.addFiles = (files: Record<string, any>) => {
|
||||||
|
originalAddFiles(files);
|
||||||
|
|
||||||
|
// Avoid rebroadcast loops when we are applying remote updates.
|
||||||
|
if (isSyncing.current) return;
|
||||||
|
|
||||||
|
const nextFiles = api.getFiles?.() || {};
|
||||||
|
const didEmit = emitFilesDeltaIfNeeded(nextFiles);
|
||||||
|
|
||||||
|
// Persist after file data becomes available so new tabs (tab3) load correctly.
|
||||||
|
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||||
|
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
}, []);
|
}, [emitFilesDeltaIfNeeded]);
|
||||||
|
|
||||||
// Handle #addLibrary URL hash parameter for importing libraries from links
|
// Handle #addLibrary URL hash parameter for importing libraries from links
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -380,6 +510,8 @@ export const Editor: React.FC = () => {
|
|||||||
}, 1000),
|
}, 1000),
|
||||||
[] // Empty dependency array = Stable across renders
|
[] // Empty dependency array = Stable across renders
|
||||||
);
|
);
|
||||||
|
// Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves.
|
||||||
|
debouncedSaveRef.current = debouncedSave;
|
||||||
const debouncedSavePreview = useCallback(
|
const debouncedSavePreview = useCallback(
|
||||||
debounce((elements, appState, files) => {
|
debounce((elements, appState, files) => {
|
||||||
if (savePreviewRef.current) {
|
if (savePreviewRef.current) {
|
||||||
@@ -399,7 +531,7 @@ export const Editor: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const broadcastChanges = useCallback(
|
const broadcastChanges = useCallback(
|
||||||
throttle((elements: readonly any[]) => {
|
throttle((elements: readonly any[], currentFiles?: Record<string, any>) => {
|
||||||
if (!socketRef.current || !id) return;
|
if (!socketRef.current || !id) return;
|
||||||
|
|
||||||
const changes: any[] = [];
|
const changes: any[] = [];
|
||||||
@@ -411,10 +543,23 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (changes.length > 0) {
|
const nextFiles = currentFiles || excalidrawAPI.current?.getFiles() || {};
|
||||||
|
const filesDelta = getFilesDelta(lastSyncedFilesRef.current, nextFiles);
|
||||||
|
const shouldSyncFiles = Object.keys(filesDelta).length > 0;
|
||||||
|
|
||||||
|
if (Object.keys(nextFiles || {}).length > 0) {
|
||||||
|
latestFilesRef.current = nextFiles;
|
||||||
|
}
|
||||||
|
if (shouldSyncFiles) {
|
||||||
|
// Keep our baseline in sync so we only send deltas next time.
|
||||||
|
lastSyncedFilesRef.current = nextFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length > 0 || shouldSyncFiles) {
|
||||||
socketRef.current.emit('element-update', {
|
socketRef.current.emit('element-update', {
|
||||||
drawingId: id,
|
drawingId: id,
|
||||||
elements: changes,
|
elements: changes.length > 0 ? changes : [],
|
||||||
|
files: shouldSyncFiles ? filesDelta : undefined,
|
||||||
userId: me.id
|
userId: me.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -428,6 +573,7 @@ export const Editor: React.FC = () => {
|
|||||||
elementVersionMap.current.clear();
|
elementVersionMap.current.clear();
|
||||||
latestElementsRef.current = [];
|
latestElementsRef.current = [];
|
||||||
latestFilesRef.current = {};
|
latestFilesRef.current = {};
|
||||||
|
lastSyncedFilesRef.current = {};
|
||||||
excalidrawAPI.current = null;
|
excalidrawAPI.current = null;
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setIsSceneLoading(true);
|
setIsSceneLoading(true);
|
||||||
@@ -453,6 +599,7 @@ export const Editor: React.FC = () => {
|
|||||||
const files = data.files || {};
|
const files = data.files || {};
|
||||||
latestElementsRef.current = elements;
|
latestElementsRef.current = elements;
|
||||||
latestFilesRef.current = files;
|
latestFilesRef.current = files;
|
||||||
|
lastSyncedFilesRef.current = files;
|
||||||
|
|
||||||
elements.forEach((el: any) => {
|
elements.forEach((el: any) => {
|
||||||
recordElementVersion(el);
|
recordElementVersion(el);
|
||||||
@@ -465,6 +612,9 @@ export const Editor: React.FC = () => {
|
|||||||
gridSize: persistedAppState.gridSize ?? null,
|
gridSize: persistedAppState.gridSize ?? null,
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
};
|
};
|
||||||
|
// Ensure we always have an appState available for file-only persistence triggers
|
||||||
|
// (some Excalidraw file updates may not trigger onChange with appState).
|
||||||
|
latestAppStateRef.current = hydratedAppState;
|
||||||
|
|
||||||
setInitialData({
|
setInitialData({
|
||||||
elements,
|
elements,
|
||||||
@@ -478,6 +628,7 @@ export const Editor: React.FC = () => {
|
|||||||
toast.error("Failed to load drawing");
|
toast.error("Failed to load drawing");
|
||||||
latestElementsRef.current = [];
|
latestElementsRef.current = [];
|
||||||
latestFilesRef.current = {};
|
latestFilesRef.current = {};
|
||||||
|
lastSyncedFilesRef.current = {};
|
||||||
setInitialData(buildEmptyScene());
|
setInitialData(buildEmptyScene());
|
||||||
} finally {
|
} finally {
|
||||||
setIsSceneLoading(false);
|
setIsSceneLoading(false);
|
||||||
@@ -507,7 +658,7 @@ export const Editor: React.FC = () => {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCanvasChange = useCallback((elements: readonly any[], appState: any) => {
|
const handleCanvasChange = useCallback((elements: readonly any[], appState: any, files?: Record<string, any>) => {
|
||||||
if (isUnmounting.current) {
|
if (isUnmounting.current) {
|
||||||
console.log("[Editor] Ignoring change during unmount", { drawingId: id });
|
console.log("[Editor] Ignoring change during unmount", { drawingId: id });
|
||||||
return;
|
return;
|
||||||
@@ -515,6 +666,13 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
if (isSyncing.current) return;
|
if (isSyncing.current) return;
|
||||||
|
|
||||||
|
latestAppStateRef.current = appState;
|
||||||
|
|
||||||
|
const currentFiles = files || excalidrawAPI.current?.getFiles() || {};
|
||||||
|
if (Object.keys(currentFiles).length > 0) {
|
||||||
|
latestFilesRef.current = currentFiles;
|
||||||
|
}
|
||||||
|
|
||||||
// Get ALL elements including deleted (fixes the "deletion not syncing" bug)
|
// Get ALL elements including deleted (fixes the "deletion not syncing" bug)
|
||||||
const allElements = excalidrawAPI.current
|
const allElements = excalidrawAPI.current
|
||||||
? excalidrawAPI.current.getSceneElementsIncludingDeleted()
|
? excalidrawAPI.current.getSceneElementsIncludingDeleted()
|
||||||
@@ -551,7 +709,7 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger Sync (Throttled)
|
// Trigger Sync (Throttled)
|
||||||
broadcastChanges(allElements);
|
broadcastChanges(allElements, currentFiles);
|
||||||
|
|
||||||
// Trigger Fast Save
|
// Trigger Fast Save
|
||||||
console.log("[Editor] Queueing save", {
|
console.log("[Editor] Queueing save", {
|
||||||
@@ -562,15 +720,38 @@ export const Editor: React.FC = () => {
|
|||||||
debouncedSave(allElements, appState);
|
debouncedSave(allElements, appState);
|
||||||
|
|
||||||
// Trigger Slow Preview Gen
|
// Trigger Slow Preview Gen
|
||||||
const files = excalidrawAPI.current?.getFiles() || {};
|
const filesSnapshot = currentFiles;
|
||||||
latestFilesRef.current = files;
|
latestFilesRef.current = filesSnapshot;
|
||||||
console.log("[Editor] Queueing preview save", {
|
console.log("[Editor] Queueing preview save", {
|
||||||
drawingId: id,
|
drawingId: id,
|
||||||
fileCount: Object.keys(files).length,
|
fileCount: Object.keys(filesSnapshot).length,
|
||||||
});
|
});
|
||||||
debouncedSavePreview(allElements, appState, files);
|
debouncedSavePreview(allElements, appState, filesSnapshot);
|
||||||
}, [debouncedSave, debouncedSavePreview, broadcastChanges]);
|
}, [debouncedSave, debouncedSavePreview, broadcastChanges]);
|
||||||
|
|
||||||
|
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
||||||
|
// are still broadcast to collaborators AND persisted to the server.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !isReady) return;
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
if (isUnmounting.current) return;
|
||||||
|
if (isSyncing.current) return;
|
||||||
|
if (!socketRef.current) return;
|
||||||
|
if (!excalidrawAPI.current) return;
|
||||||
|
|
||||||
|
const nextFiles = excalidrawAPI.current.getFiles?.() || {};
|
||||||
|
const didEmit = emitFilesDeltaIfNeeded(nextFiles);
|
||||||
|
|
||||||
|
// Persist after file data becomes available (covers the "tab 3" case).
|
||||||
|
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||||
|
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [id, isReady, emitFilesDeltaIfNeeded]);
|
||||||
|
|
||||||
const handleRenameSubmit = async (e: React.FormEvent) => {
|
const handleRenameSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (newName.trim() && id) {
|
if (newName.trim() && id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user