diff --git a/backend/src/index.ts b/backend/src/index.ts index f600d99..1587286 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,7 +16,7 @@ import { sanitizeDrawingData, validateImportedDrawing, sanitizeText, - sanitizeSvg, + sanitizePreview, elementSchema, appStateSchema, } from "./security"; @@ -29,6 +29,7 @@ import { registerDashboardRoutes } from "./routes/dashboard"; import { registerImportExportRoutes } from "./routes/importExport"; import { prisma } from "./db/prisma"; import { createDrawingsCacheStore } from "./server/drawingsCache"; +import { createCollabSessionManager } from "./server/collabSession"; import { registerCsrfProtection } from "./server/csrf"; import { registerSocketHandlers } from "./server/socket"; import { getClientIp } from "./utils/clientIp"; @@ -173,6 +174,7 @@ const { cacheDrawingsResponse, invalidateDrawingsCache, } = createDrawingsCacheStore(DRAWINGS_CACHE_TTL_MS); +const collabSessionManager = createCollabSessionManager({ prisma }); const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; @@ -430,7 +432,7 @@ export const sanitizeDrawingUpdateData = ( Object.assign(data, sanitizedData); } else if (hasPreviewField && typeof data.preview === "string") { // Preview-only updates must not inject default scene fields. - data.preview = sanitizeSvg(data.preview); + data.preview = sanitizePreview(data.preview); Object.assign(data, { ...data, preview: data.preview }); } else if (hasPreviewField && data.preview === null) { // Explicitly allow clearing preview without touching scene data. @@ -564,6 +566,7 @@ registerSocketHandlers({ prisma, authModeService, jwtSecret: config.jwtSecret, + collabSessionManager, }); apiApp.get("/health", (req, res) => { @@ -589,6 +592,7 @@ registerDashboardRoutes(apiApp, { buildDrawingsCacheKey, getCachedDrawingsBody, cacheDrawingsResponse, + collabSessionManager, MAX_PAGE_SIZE, config, logAuditEvent, diff --git a/backend/src/routes/dashboard/drawings.ts b/backend/src/routes/dashboard/drawings.ts index a7bbcd3..2177e59 100644 --- a/backend/src/routes/dashboard/drawings.ts +++ b/backend/src/routes/dashboard/drawings.ts @@ -58,6 +58,7 @@ export const registerDrawingRoutes = ( buildDrawingsCacheKey, getCachedDrawingsBody, cacheDrawingsResponse, + collabSessionManager, MAX_PAGE_SIZE, config, logAuditEvent, @@ -446,6 +447,57 @@ export const registerDrawingRoutes = ( }); })); + app.get("/drawings/:id/scene-meta", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Drawing not found" }); + + const meta = await collabSessionManager.getMeta(id); + if (!meta) { + return res.status(404).json({ error: "Drawing not found" }); + } + + return res.json({ + drawingId: id, + seq: meta.seq, + dbVersion: meta.dbVersion, + updatedAt: access.drawing.updatedAt, + }); + })); + + app.post("/drawings/:id/flush", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Drawing not found" }); + if (!isAtLeastRole(access.role, "editor")) { + return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" }); + } + + await collabSessionManager.flushSession(id); + const meta = await collabSessionManager.getMeta(id); + + return res.json({ + success: true, + drawingId: id, + seq: meta?.seq ?? null, + dbVersion: meta?.dbVersion ?? null, + }); + })); + app.post("/drawings", requireAuth, asyncHandler(async (req, res) => { if (!req.user) return res.status(401).json({ error: "Unauthorized" }); @@ -630,6 +682,50 @@ export const registerDrawingRoutes = ( }); })); + app.put("/drawings/:id/preview", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const id = getRouteIdParam(req.params.id); + if (!id) { + return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + } + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Drawing not found" }); + if (!isAtLeastRole(access.role, "editor")) { + return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" }); + } + + const parsed = drawingUpdateSchema.safeParse({ preview: req.body?.preview }); + if (!parsed.success) { + if (config.nodeEnv === "development") { + console.error("[API] Preview validation failed", { id, errors: parsed.error.issues }); + } + return respondWithValidationErrors(res, parsed.error.issues); + } + + const payload = parsed.data as { preview?: string | null }; + if (payload.preview === undefined) { + return res.status(400).json({ error: "Validation error", message: "Missing preview field" }); + } + + const updated = await prisma.drawing.updateMany({ + where: access.role === "owner" ? { id, userId: req.user.id } : { id }, + data: { preview: payload.preview }, + }); + + if (updated.count === 0) { + return res.status(404).json({ error: "Drawing not found" }); + } + + invalidateDrawingsCache(); + return res.json({ success: true }); + })); + app.delete("/drawings/:id", requireAuth, asyncHandler(async (req, res) => { if (!req.user) return res.status(401).json({ error: "Unauthorized" }); const id = getRouteIdParam(req.params.id); diff --git a/backend/src/routes/dashboard/types.ts b/backend/src/routes/dashboard/types.ts index 103be14..1ccac0a 100644 --- a/backend/src/routes/dashboard/types.ts +++ b/backend/src/routes/dashboard/types.ts @@ -2,6 +2,7 @@ import express from "express"; import { z } from "zod"; import { Prisma, PrismaClient } from "../../generated/client"; import { AuthModeService } from "../../auth/authMode"; +import { CollabSessionManager } from "../../server/collabSession"; export type SortField = "name" | "createdAt" | "updatedAt"; export type SortDirection = "asc" | "desc"; @@ -48,6 +49,7 @@ export type DashboardRouteDeps = { buildDrawingsCacheKey: BuildDrawingsCacheKey; getCachedDrawingsBody: (key: string) => Buffer | null; cacheDrawingsResponse: (key: string, payload: unknown) => Buffer; + collabSessionManager: CollabSessionManager; MAX_PAGE_SIZE: number; config: { nodeEnv: string; diff --git a/backend/src/security.ts b/backend/src/security.ts index 9dbfe3d..fb3bffb 100644 --- a/backend/src/security.ts +++ b/backend/src/security.ts @@ -231,6 +231,31 @@ export const sanitizeSvg = (svgContent: string): string => { return sanitizeSvgImageTags(sanitized).trim(); }; +const SAFE_PREVIEW_DATA_URL_PATTERN = + /^data:image\/(?:webp|png|jpe?g);base64,[a-z0-9+/=\s]+$/i; +const MAX_PREVIEW_SIZE = 300_000; + +export const sanitizePreview = ( + previewContent: string | null | undefined +): string | null => { + if (previewContent === null || previewContent === undefined) return null; + if (typeof previewContent !== "string") return null; + const trimmed = previewContent.trim(); + if (trimmed.length === 0) return null; + if (trimmed.length > MAX_PREVIEW_SIZE) return null; + + if (SAFE_PREVIEW_DATA_URL_PATTERN.test(trimmed)) { + return trimmed; + } + + if (/^]/i.test(trimmed)) { + const sanitized = sanitizeSvg(trimmed); + return sanitized.length > 0 && sanitized.length <= MAX_PREVIEW_SIZE ? sanitized : null; + } + + return null; +}; + export const sanitizeText = ( input: unknown, maxLength: number = 1000 @@ -463,10 +488,7 @@ export const sanitizeDrawingData = (data: { const sanitizedElements = elementSchema.array().parse(data.elements); const sanitizedAppState = appStateSchema.parse(data.appState); - let sanitizedPreview = data.preview; - if (typeof sanitizedPreview === "string") { - sanitizedPreview = sanitizeSvg(sanitizedPreview); - } + const sanitizedPreview = sanitizePreview(data.preview); // Sanitize files object with special handling for dataURL let sanitizedFiles = data.files; diff --git a/backend/src/server/collabSession.ts b/backend/src/server/collabSession.ts new file mode 100644 index 0000000..c317f37 --- /dev/null +++ b/backend/src/server/collabSession.ts @@ -0,0 +1,293 @@ +import { PrismaClient } from "../generated/client"; + +type SceneOp = { + upsertElements?: any[]; + deleteElementIds?: string[]; + filesDelta?: Record; + appStatePatch?: Record; +}; + +type DrawingSession = { + drawingId: string; + seq: number; + dbVersion: number; + elementsById: Map; + filesById: Record; + appState: Record; + dirty: boolean; + lastTouchedAt: number; + participants: Set; + pendingFlushTimer: NodeJS.Timeout | null; +}; + +type CreateCollabSessionManagerDeps = { + prisma: PrismaClient; + flushDebounceMs?: number; + idleEvictMs?: number; +}; + +const ALLOWED_APPSTATE_KEYS = new Set([ + "viewBackgroundColor", + "gridSize", + "zoom", + "scrollX", + "scrollY", + "theme", +]); + +const parseJsonSafely = (rawValue: string | null | undefined, fallback: T): T => { + if (!rawValue) return fallback; + try { + return JSON.parse(rawValue) as T; + } catch { + return fallback; + } +}; + +const sanitizeElements = (elements: unknown): any[] => { + if (!Array.isArray(elements)) return []; + return elements.filter((element) => element && typeof element === "object"); +}; + +const sanitizeDeleteIds = (deleteElementIds: unknown): string[] => { + if (!Array.isArray(deleteElementIds)) return []; + return deleteElementIds.filter((value): value is string => typeof value === "string"); +}; + +const sanitizeFilesDelta = (filesDelta: unknown): Record => { + if (!filesDelta || typeof filesDelta !== "object") return {}; + const next: Record = {}; + for (const [key, value] of Object.entries(filesDelta as Record)) { + if (typeof key !== "string" || key.length === 0) continue; + next[key] = value; + } + return next; +}; + +const sanitizeAppStatePatch = (appStatePatch: unknown): Record => { + if (!appStatePatch || typeof appStatePatch !== "object") return {}; + const next: Record = {}; + for (const [key, value] of Object.entries(appStatePatch as Record)) { + if (!ALLOWED_APPSTATE_KEYS.has(key)) continue; + next[key] = value; + } + return next; +}; + +export const createCollabSessionManager = ({ + prisma, + flushDebounceMs = 2000, + idleEvictMs = 5 * 60 * 1000, +}: CreateCollabSessionManagerDeps) => { + const sessions = new Map(); + const seenClientOps = new Map(); + + const ensureSession = async (drawingId: string): Promise => { + const existing = sessions.get(drawingId); + if (existing) { + existing.lastTouchedAt = Date.now(); + return existing; + } + + const drawing = await prisma.drawing.findUnique({ where: { id: drawingId } }); + if (!drawing) return null; + + const elements = sanitizeElements(parseJsonSafely(drawing.elements, [])); + const session: DrawingSession = { + drawingId, + seq: 0, + dbVersion: drawing.version, + elementsById: new Map(elements.map((element) => [String(element.id), element])), + filesById: parseJsonSafely(drawing.files, {}), + appState: parseJsonSafely(drawing.appState, {}), + dirty: false, + lastTouchedAt: Date.now(), + participants: new Set(), + pendingFlushTimer: null, + }; + + sessions.set(drawingId, session); + return session; + }; + + const getSceneSnapshot = async (drawingId: string) => { + const session = await ensureSession(drawingId); + if (!session) return null; + return { + drawingId, + seq: session.seq, + dbVersion: session.dbVersion, + elements: Array.from(session.elementsById.values()), + appState: session.appState, + files: session.filesById, + }; + }; + + const flushSession = async (drawingId: string): Promise => { + const session = sessions.get(drawingId); + if (!session) return false; + if (!session.dirty) return true; + + if (session.pendingFlushTimer) { + clearTimeout(session.pendingFlushTimer); + session.pendingFlushTimer = null; + } + + const elements = Array.from(session.elementsById.values()); + await prisma.drawing.update({ + where: { id: drawingId }, + data: { + elements: JSON.stringify(elements), + appState: JSON.stringify(session.appState || {}), + files: JSON.stringify(session.filesById || {}), + version: { increment: 1 }, + }, + }); + + session.dbVersion += 1; + session.dirty = false; + session.lastTouchedAt = Date.now(); + return true; + }; + + const scheduleFlush = (session: DrawingSession) => { + if (session.pendingFlushTimer) { + clearTimeout(session.pendingFlushTimer); + } + session.pendingFlushTimer = setTimeout(() => { + void flushSession(session.drawingId).catch((error) => { + console.error("[collab] failed to flush drawing session", { + drawingId: session.drawingId, + error, + }); + }); + }, flushDebounceMs); + }; + + const applyOps = async (params: { + drawingId: string; + clientOpId?: string; + ops: SceneOp[]; + }): Promise<{ seq: number; duplicate: boolean } | null> => { + const session = await ensureSession(params.drawingId); + if (!session) return null; + + const opKey = params.clientOpId ? `${params.drawingId}:${params.clientOpId}` : null; + if (opKey && seenClientOps.has(opKey)) { + return { seq: session.seq, duplicate: true }; + } + + for (const op of params.ops) { + const upsertElements = sanitizeElements(op?.upsertElements); + for (const element of upsertElements) { + const id = typeof element.id === "string" ? element.id : null; + if (!id) continue; + session.elementsById.set(id, element); + } + + const deleteElementIds = sanitizeDeleteIds(op?.deleteElementIds); + for (const deleteId of deleteElementIds) { + const existing = session.elementsById.get(deleteId); + if (existing) { + session.elementsById.set(deleteId, { ...existing, isDeleted: true }); + } + } + + const filesDelta = sanitizeFilesDelta(op?.filesDelta); + if (Object.keys(filesDelta).length > 0) { + session.filesById = { + ...session.filesById, + ...filesDelta, + }; + } + + const appStatePatch = sanitizeAppStatePatch(op?.appStatePatch); + if (Object.keys(appStatePatch).length > 0) { + session.appState = { + ...session.appState, + ...appStatePatch, + }; + } + } + + session.seq += 1; + session.dirty = true; + session.lastTouchedAt = Date.now(); + scheduleFlush(session); + + if (opKey) { + seenClientOps.set(opKey, Date.now()); + } + + return { seq: session.seq, duplicate: false }; + }; + + const joinSession = async (drawingId: string, socketId: string) => { + const session = await ensureSession(drawingId); + if (!session) return null; + session.participants.add(socketId); + session.lastTouchedAt = Date.now(); + return session; + }; + + const leaveSession = async (drawingId: string, socketId: string): Promise => { + const session = sessions.get(drawingId); + if (!session) return; + session.participants.delete(socketId); + session.lastTouchedAt = Date.now(); + + if (session.participants.size === 0) { + await flushSession(drawingId).catch((error) => { + console.error("[collab] failed to flush on last participant leave", { + drawingId, + error, + }); + }); + } + }; + + const getMeta = async (drawingId: string) => { + const session = await ensureSession(drawingId); + if (!session) return null; + return { + drawingId, + seq: session.seq, + dbVersion: session.dbVersion, + dirty: session.dirty, + lastTouchedAt: session.lastTouchedAt, + }; + }; + + setInterval(() => { + const now = Date.now(); + + for (const [key, seenAt] of seenClientOps.entries()) { + if (now - seenAt > 10 * 60 * 1000) { + seenClientOps.delete(key); + } + } + + for (const [drawingId, session] of sessions.entries()) { + const isIdle = now - session.lastTouchedAt > idleEvictMs; + if (!isIdle || session.participants.size > 0) continue; + void flushSession(drawingId) + .catch((error) => { + console.error("[collab] failed to flush idle session", { drawingId, error }); + }) + .finally(() => { + sessions.delete(drawingId); + }); + } + }, 30_000).unref(); + + return { + getSceneSnapshot, + applyOps, + joinSession, + leaveSession, + flushSession, + getMeta, + }; +}; + +export type CollabSessionManager = ReturnType; diff --git a/backend/src/server/socket.ts b/backend/src/server/socket.ts index ce09eaf..001ce2f 100644 --- a/backend/src/server/socket.ts +++ b/backend/src/server/socket.ts @@ -4,6 +4,7 @@ import { PrismaClient } from "../generated/client"; import { AuthModeService } from "../auth/authMode"; import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies"; import { isAtLeastRole, resolveDrawingAccess } from "./drawingAccess"; +import { CollabSessionManager } from "./collabSession"; interface User { id: string; @@ -19,6 +20,7 @@ type RegisterSocketHandlersDeps = { prisma: PrismaClient; authModeService: AuthModeService; jwtSecret: string; + collabSessionManager: CollabSessionManager; }; export const registerSocketHandlers = ({ @@ -26,6 +28,7 @@ export const registerSocketHandlers = ({ prisma, authModeService, jwtSecret, + collabSessionManager, }: RegisterSocketHandlersDeps) => { const roomUsers = new Map(); const socketUserMap = new Map(); @@ -140,6 +143,7 @@ export const registerSocketHandlers = ({ const roomId = `drawing_${drawingId}`; socket.join(roomId); + await collabSessionManager.joinSession(drawingId, socket.id); let trustedUserId = typeof user?.id === "string" && user.id.trim().length > 0 @@ -173,6 +177,11 @@ export const registerSocketHandlers = ({ roomUsers.set(roomId, filteredUsers); io.to(roomId).emit("presence-update", filteredUsers); + + const snapshot = await collabSessionManager.getSceneSnapshot(drawingId); + if (snapshot) { + socket.emit("scene-snapshot", snapshot); + } } catch (err) { console.error("Error in join-room handler:", err); socket.emit("error", { message: "Failed to join room" }); @@ -202,6 +211,45 @@ export const registerSocketHandlers = ({ socket.to(roomId).emit("element-update", data); }); + socket.on("scene-op", async (data) => { + const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; + if (!drawingId) return; + + const role = authorizedDrawingRoles.get(drawingId); + if (!role || !isAtLeastRole(role, "editor")) { + return; + } + + const baseSeq = typeof data?.baseSeq === "number" ? data.baseSeq : 0; + const clientOpId = typeof data?.clientOpId === "string" ? data.clientOpId : undefined; + const incomingOps = Array.isArray(data?.ops) ? data.ops : []; + if (incomingOps.length === 0) return; + + const applied = await collabSessionManager.applyOps({ + drawingId, + clientOpId, + ops: incomingOps, + }); + if (!applied) return; + + const roomId = `drawing_${drawingId}`; + io.to(roomId).emit("scene-op-applied", { + drawingId, + seq: applied.seq, + baseSeq, + ops: incomingOps, + authorUserId: authenticatedUserId ?? socket.id, + clientOpId, + }); + }); + + socket.on("scene-flush", async (data) => { + const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null; + if (!drawingId) return; + if (!authorizedDrawingRoles.has(drawingId)) return; + await collabSessionManager.flushSession(drawingId); + }); + socket.on( "user-activity", ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { @@ -222,6 +270,9 @@ export const registerSocketHandlers = ({ socket.on("disconnect", () => { socketUserMap.delete(socket.id); + for (const drawingId of authorizedDrawingRoles.keys()) { + void collabSessionManager.leaveSession(drawingId, socket.id); + } roomUsers.forEach((users, roomId) => { const index = users.findIndex((u) => u.socketId === socket.id); if (index !== -1) { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 731b80a..24952cc 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -540,6 +540,44 @@ export const updateDrawing = async (id: string, data: Partial) => { return deserializeDrawing(response.data); }; +export const updateDrawingPreview = async ( + id: string, + preview: string | null +): Promise<{ success: true }> => { + const response = await api.put<{ success: true }>(`/drawings/${id}/preview`, { preview }); + return response.data; +}; + +export const getDrawingSceneMeta = async (id: string): Promise<{ + drawingId: string; + seq: number; + dbVersion: number; + updatedAt: string | number; +}> => { + const response = await api.get<{ + drawingId: string; + seq: number; + dbVersion: number; + updatedAt: string | number; + }>(`/drawings/${id}/scene-meta`); + return response.data; +}; + +export const flushDrawingScene = async (id: string): Promise<{ + success: true; + drawingId: string; + seq: number | null; + dbVersion: number | null; +}> => { + const response = await api.post<{ + success: true; + drawingId: string; + seq: number | null; + dbVersion: number | null; + }>(`/drawings/${id}/flush`); + return response.data; +}; + export const deleteDrawing = async (id: string) => { const response = await api.delete<{ success: true }>(`/drawings/${id}`); return response.data; diff --git a/frontend/src/components/DrawingCard.tsx b/frontend/src/components/DrawingCard.tsx index 11551f9..fa34a9e 100644 --- a/frontend/src/components/DrawingCard.tsx +++ b/frontend/src/components/DrawingCard.tsx @@ -7,7 +7,7 @@ import { formatDistanceToNow } from 'date-fns'; import clsx from 'clsx'; // import { exportToSvg } from "@excalidraw/excalidraw"; // Lazy load this instead import { exportDrawingToFile } from '../utils/exportUtils'; -import { previewHasEmbeddedImages } from '../utils/previewSvg'; +import { isPreviewImageDataUrl, previewHasEmbeddedImages } from '../utils/previewSvg'; import * as api from '../api'; @@ -89,6 +89,7 @@ export const DrawingCard: React.FC = ({ const [exportError, setExportError] = useState(null); const [fullData, setFullData] = useState(null); const hasEmbeddedImages = previewHasEmbeddedImages(previewSvg); + const isImagePreview = isPreviewImageDataUrl(previewSvg); const fullDataRef = React.useRef(fullData); fullDataRef.current = fullData; @@ -161,7 +162,7 @@ export const DrawingCard: React.FC = ({ setPreviewSvg(previewHtml); // Save to backend and notify parent - api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error); + api.updateDrawingPreview(drawing.id, previewHtml).catch(console.error); onPreviewGenerated?.(drawing.id, previewHtml); } catch (e) { if (!cancelled) { @@ -277,6 +278,16 @@ export const DrawingCard: React.FC = ({
{previewSvg ? ( + isImagePreview ? ( + + ) : (
svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm transition-transform duration-500", @@ -284,6 +295,7 @@ export const DrawingCard: React.FC = ({ )} dangerouslySetInnerHTML={{ __html: previewSvg }} /> + ) ) : (
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1b47392..79fa736 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -11,6 +11,7 @@ import { ConfirmModal } from '../components/ConfirmModal'; import { useUpload } from '../context/UploadContext'; import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared'; import { useDashboardData } from './dashboard/useDashboardData'; +import { isPreviewImageDataUrl } from '../utils/previewSvg'; const PAGE_SIZE = 24; @@ -699,10 +700,21 @@ export const Dashboard: React.FC = () => {
{d.preview ? ( -
+ isPreviewImageDataUrl(d.preview) ? ( + + ) : ( +
+ ) ) : (
)} diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 1d01e08..38afb11 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -33,13 +33,6 @@ interface Peer extends UserIdentity { isActive: boolean; } -class DrawingSaveConflictError extends Error { - constructor(message = "Drawing version conflict") { - super(message); - this.name = "DrawingSaveConflictError"; - } -} - export const Editor: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -90,8 +83,6 @@ export const Editor: React.FC = () => { const latestFilesRef = useRef(null); const lastSyncedFilesRef = useRef>({}); const latestAppStateRef = useRef(null); - const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record) => void) | null>(null); - const currentDrawingVersionRef = useRef(null); const lastPersistedElementsRef = useRef([]); const saveQueueRef = useRef>(Promise.resolve()); const patchedAddFilesApisRef = useRef>(new WeakSet()); @@ -100,6 +91,67 @@ export const Editor: React.FC = () => { const touchedElementIdsRef = useRef>(new Set()); const pointerDownRef = useRef(false); const finalSyncTimeoutRef = useRef(null); + const serverSceneSeqRef = useRef(0); + const MAX_PREVIEW_PAYLOAD_BYTES = 200_000; + const PREVIEW_WEBP_WIDTH = 480; + const PREVIEW_WEBP_QUALITY = 0.72; + + const emitSceneOp = useCallback( + (ops: any[]) => { + if (!canEditScene) return false; + if (!socketRef.current || !id) return false; + if (!Array.isArray(ops) || ops.length === 0) return false; + + socketRef.current.emit("scene-op", { + drawingId: id, + baseSeq: serverSceneSeqRef.current, + clientOpId: + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`, + ops, + }); + return true; + }, + [canEditScene, id] + ); + + const svgToWebpDataUrl = useCallback( + async (svgMarkup: string): Promise => { + if (typeof window === "undefined") return null; + const blob = new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }); + const objectUrl = URL.createObjectURL(blob); + try { + const img = await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = objectUrl; + }); + + const naturalWidth = Math.max(1, img.naturalWidth || PREVIEW_WEBP_WIDTH); + const naturalHeight = Math.max(1, img.naturalHeight || PREVIEW_WEBP_WIDTH); + const scale = Math.min(1, PREVIEW_WEBP_WIDTH / naturalWidth); + const targetWidth = Math.max(1, Math.round(naturalWidth * scale)); + const targetHeight = Math.max(1, Math.round(naturalHeight * scale)); + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + ctx.clearRect(0, 0, targetWidth, targetHeight); + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + return canvas.toDataURL("image/webp", PREVIEW_WEBP_QUALITY); + } catch { + return null; + } finally { + URL.revokeObjectURL(objectUrl); + } + }, + [] + ); const getRenderableBaselineSnapshot = useCallback((): readonly any[] => { if (hasRenderableElements(lastPersistedElementsRef.current)) { @@ -219,7 +271,6 @@ export const Editor: React.FC = () => { const emitFilesDeltaIfNeeded = useCallback( (nextFiles: Record) => { if (!canEditScene) return false; - if (!socketRef.current || !id) return false; const filesDelta = getFilesDelta(lastSyncedFilesRef.current, nextFiles || {}); if (Object.keys(filesDelta).length === 0) return false; @@ -235,16 +286,16 @@ export const Editor: React.FC = () => { dbg.lastFilesDeltaIds = Object.keys(filesDelta); } - socketRef.current.emit("element-update", { - drawingId: id, - elements: [], - files: filesDelta, - userId: me.id, - }); - - return true; + return emitSceneOp([ + { + upsertElements: [], + deleteElementIds: [], + filesDelta, + appStatePatch: {}, + }, + ]); }, - [id, me.id, canEditScene] + [canEditScene, emitSceneOp] ); const recordElementVersion = useCallback((element: any) => { @@ -281,7 +332,7 @@ export const Editor: React.FC = () => { const emitFinalPointerSync = useCallback(() => { if (!canEditScene) return; - if (!socketRef.current || !id || !excalidrawAPI.current) return; + if (!excalidrawAPI.current) return; const currentElements = excalidrawAPI.current.getSceneElementsIncludingDeleted() || []; const currentFiles = excalidrawAPI.current.getFiles?.() || {}; @@ -307,15 +358,21 @@ export const Editor: React.FC = () => { latestFilesRef.current = currentFiles; } - socketRef.current.emit("element-update", { - drawingId: id, - elements: forcedElements, - files: shouldSyncFiles ? filesDelta : undefined, - userId: me.id, - }); + const deletedIds = forcedElements + .filter((element: any) => element?.isDeleted === true) + .map((element: any) => element.id) + .filter((elementId: unknown): elementId is string => typeof elementId === "string"); + emitSceneOp([ + { + upsertElements: forcedElements, + deleteElementIds: deletedIds, + filesDelta: shouldSyncFiles ? filesDelta : {}, + appStatePatch: {}, + }, + ]); touchedElementIdsRef.current.clear(); - }, [canEditScene, id, me.id, recordElementVersion]); + }, [canEditScene, recordElementVersion, emitSceneOp]); const emitCursorPresence = useCallback((button: string = "up") => { if (!socketRef.current || !id) return; @@ -453,42 +510,95 @@ export const Editor: React.FC = () => { }); }); - socket.on('element-update', ({ elements, files }: { elements: any[]; files?: Record }) => { + const applyRemoteOps = (ops: any[]) => { if (!excalidrawAPI.current) return; isSyncing.current = true; + let nextElements = excalidrawAPI.current.getSceneElementsIncludingDeleted() || []; + let nextFiles = lastSyncedFilesRef.current || {}; - const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); - // Always merge all remote deltas; the version/timestamp reconciliation - // logic already protects newer local edits and prevents stale overwrites. - const remoteElements = Array.isArray(elements) ? elements : []; - const mergedElements = reconcileElements(localElements, remoteElements); + for (const op of ops) { + const remoteElements = Array.isArray(op?.upsertElements) ? op.upsertElements : []; + if (remoteElements.length > 0) { + nextElements = reconcileElements(nextElements, remoteElements); + remoteElements.forEach((element: any) => { + recordElementVersion(element); + }); + } - remoteElements.forEach((el: any) => { - recordElementVersion(el); + const deleteIds = Array.isArray(op?.deleteElementIds) ? op.deleteElementIds : []; + if (deleteIds.length > 0) { + const deleteSet = new Set(deleteIds.filter((value: unknown): value is string => typeof value === "string")); + if (deleteSet.size > 0) { + nextElements = nextElements.map((element: any) => + deleteSet.has(element.id) ? { ...element, isDeleted: true } : element + ); + } + } + + const incomingFiles = op?.filesDelta && typeof op.filesDelta === "object" ? op.filesDelta : {}; + if (Object.keys(incomingFiles).length > 0) { + nextFiles = { ...nextFiles, ...incomingFiles }; + if (typeof excalidrawAPI.current.addFiles === "function") { + excalidrawAPI.current.addFiles(Object.values(incomingFiles)); + } + } + } + + excalidrawAPI.current.updateScene({ elements: nextElements }); + latestElementsRef.current = nextElements; + latestFilesRef.current = nextFiles; + lastSyncedFilesRef.current = nextFiles; + isSyncing.current = false; + }; + + socket.on("scene-snapshot", (snapshot: any) => { + if (!excalidrawAPI.current) return; + if (!snapshot || typeof snapshot !== "object") return; + const snapshotSeq = typeof snapshot.seq === "number" ? snapshot.seq : 0; + if (snapshotSeq < serverSceneSeqRef.current) return; + + const elements = Array.isArray(snapshot.elements) ? snapshot.elements : []; + const files = snapshot.files && typeof snapshot.files === "object" ? snapshot.files : {}; + serverSceneSeqRef.current = snapshotSeq; + + elementVersionMap.current.clear(); + elements.forEach((element: any) => { + recordElementVersion(element); }); - 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(Object.values(incomingFiles)); - } - - excalidrawAPI.current.updateScene({ elements: mergedElements }); - latestElementsRef.current = mergedElements; - if (shouldUpdateFiles) { - latestFilesRef.current = nextFiles; - lastSyncedFilesRef.current = nextFiles; + isSyncing.current = true; + if (Object.keys(files).length > 0 && typeof excalidrawAPI.current.addFiles === "function") { + excalidrawAPI.current.addFiles(Object.values(files)); } + excalidrawAPI.current.updateScene({ elements }); + latestElementsRef.current = elements; + latestFilesRef.current = files; + lastSyncedFilesRef.current = files; isSyncing.current = false; }); + socket.on("scene-op-applied", (payload: any) => { + const seq = typeof payload?.seq === "number" ? payload.seq : null; + if (seq !== null && seq > serverSceneSeqRef.current) { + serverSceneSeqRef.current = seq; + } + const ops = Array.isArray(payload?.ops) ? payload.ops : []; + if (ops.length === 0) return; + applyRemoteOps(ops); + }); + + socket.on('element-update', ({ elements, files }: { elements: any[]; files?: Record }) => { + applyRemoteOps([ + { + upsertElements: Array.isArray(elements) ? elements : [], + deleteElementIds: [], + filesDelta: files || {}, + appStatePatch: {}, + }, + ]); + }); + const handleActivity = (isActive: boolean) => { socket.emit('user-activity', { drawingId: id, isActive }); @@ -511,6 +621,8 @@ export const Editor: React.FC = () => { document.removeEventListener('mouseleave', onMouseLeave); socket.off('presence-update'); socket.off('cursor-move'); + socket.off('scene-snapshot'); + socket.off('scene-op-applied'); socket.off('element-update'); socket.disconnect(); cancelAnimationFrame(animationFrameId.current); @@ -589,10 +701,17 @@ export const Editor: React.FC = () => { const nextFiles = api.getFiles?.() || {}; const didEmit = emitFilesDeltaIfNeeded(nextFiles); - // Persist after file data becomes available so new tabs (tab3) load correctly. - if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) { + // Keep preview in sync after async file data becomes available. + if (didEmit && id && latestAppStateRef.current) { hasSceneChangesSinceLoadRef.current = true; - debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {}); + if (savePreviewRef.current) { + void savePreviewRef.current( + id, + latestElementsRef.current, + latestAppStateRef.current, + latestFilesRef.current || {} + ); + } } }; } @@ -658,168 +777,20 @@ export const Editor: React.FC = () => { scrollToContent: true, }), []); - const recoverFromVersionConflict = useCallback( - async ( - drawingId: string, - localElements: readonly any[], - localFiles: Record - ): Promise => { - try { - const latest = await api.getDrawing(drawingId); - const latestElements = Array.isArray(latest.elements) ? latest.elements : []; - const latestFiles = latest.files || {}; - const mergedFiles = { ...latestFiles, ...(localFiles || {}) }; - const normalizedLocalElements = normalizeImageElementStatus(localElements, mergedFiles); - const mergedElements = reconcileElements(latestElements, normalizedLocalElements); - - if (typeof latest.version === "number") { - currentDrawingVersionRef.current = latest.version; - } - latestElementsRef.current = mergedElements; - initialSceneElementsRef.current = latestElements; - lastPersistedElementsRef.current = latestElements; - latestFilesRef.current = mergedFiles; - lastSyncedFilesRef.current = mergedFiles; - hasSceneChangesSinceLoadRef.current = false; - - elementVersionMap.current.clear(); - mergedElements.forEach((element: any) => { - recordElementVersion(element); - }); - - if (excalidrawAPI.current) { - isSyncing.current = true; - if ( - Object.keys(mergedFiles).length > 0 && - typeof excalidrawAPI.current.addFiles === "function" - ) { - excalidrawAPI.current.addFiles(Object.values(mergedFiles)); - } - excalidrawAPI.current.updateScene({ elements: mergedElements }); - isSyncing.current = false; - } - - toast.info("Loaded latest collaborator changes"); - return true; - } catch (error) { - console.error("[Editor] Failed to recover from version conflict", error); - return false; - } - }, - [normalizeImageElementStatus, recordElementVersion] - ); - const saveDataRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record) => Promise) | null>(null); const savePreviewRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files: any) => Promise) | null>(null); const saveLibraryRef = useRef<((items: any[]) => Promise) | null>(null); - saveDataRef.current = async (drawingId: string, elements: readonly any[], appState: any, files?: Record) => { + saveDataRef.current = async (drawingId: string, _elements: readonly any[], _appState: any, _files?: Record) => { if (!drawingId) return; try { - const persistableAppState = { - ...appState, - viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff', - gridSize: appState?.gridSize || null, - }; - - const candidateElements = Array.isArray(elements) ? elements : []; - const { - snapshot: safeElements, - prevented, - staleEmptySnapshot, - staleNonRenderableSnapshot, - } = resolveSafeSnapshot(candidateElements); - const persistableElements = Array.from(safeElements); - if (suspiciousBlankLoadRef.current && !hasRenderableElements(persistableElements)) { - console.warn("[Editor] Blocking non-renderable save due to suspicious blank load", { - drawingId, - elementCount: persistableElements.length, - }); - return; - } - if (staleEmptySnapshot || staleNonRenderableSnapshot) { - console.warn("[Editor] Skipping stale snapshot save", { - drawingId, - candidateElementCount: candidateElements.length, - fallbackElementCount: persistableElements.length, - prevented, - staleEmptySnapshot, - staleNonRenderableSnapshot, - }); - return; - } - const persistableFiles = files ?? latestFilesRef.current ?? {}; - const normalizedElements = normalizeImageElementStatus( - persistableElements, - persistableFiles - ); - const normalizedElementsForSave = Array.from(normalizedElements); - - console.log("[Editor] Saving drawing", { - drawingId, - elementCount: normalizedElementsForSave.length, - hasRenderableElements: hasRenderableElements(normalizedElementsForSave), - appState: persistableAppState, - }); - - const persistScene = async (): Promise => { - try { - const updated = await api.updateDrawing(drawingId, { - elements: normalizedElementsForSave, - appState: persistableAppState, - files: persistableFiles, - version: currentDrawingVersionRef.current ?? undefined, - }); - if (typeof updated.version === "number") { - currentDrawingVersionRef.current = updated.version; - } - lastPersistedElementsRef.current = normalizedElementsForSave; - console.log("[Editor] Save complete", { drawingId }); - } catch (err) { - if (api.isAxiosError(err) && err.response?.status === 409) { - const reportedVersion = Number(err.response?.data?.currentVersion); - const hasReportedVersion = Number.isInteger(reportedVersion) && reportedVersion > 0; - if (hasReportedVersion) { - currentDrawingVersionRef.current = reportedVersion; - } - - console.warn("[Editor] Version conflict while saving drawing, recovering latest", { - drawingId, - currentVersion: reportedVersion, - }); - - const recovered = await recoverFromVersionConflict( - drawingId, - normalizedElementsForSave, - persistableFiles - ); - if (recovered) { - return; - } - - if (hasReportedVersion) { - console.warn("[Editor] Version conflict recovery failed", { - drawingId, - currentVersion: reportedVersion, - }); - } - - throw new DrawingSaveConflictError(); - } - - throw err; - } - }; - - await persistScene(); + await api.flushDrawingScene(drawingId); + hasSceneChangesSinceLoadRef.current = false; + lastPersistedElementsRef.current = latestElementsRef.current; + console.log("[Editor] Flush complete", { drawingId }); } catch (err) { - if (err instanceof DrawingSaveConflictError) { - console.warn("[Editor] Version conflict while saving drawing", { drawingId }); - toast.error("Drawing changed in another tab and recovery failed. Refresh to load latest."); - throw err; - } - console.error('Failed to save drawing', err); + console.error('Failed to flush drawing', err); toast.error("Failed to save changes"); throw err; } @@ -897,14 +868,29 @@ export const Editor: React.FC = () => { }, files: currentFiles, }); - const preview = svg.outerHTML; + const svgPreview = svg.outerHTML; + let preview = svgPreview; + if (svgPreview.length > MAX_PREVIEW_PAYLOAD_BYTES) { + const webpPreview = await svgToWebpDataUrl(svgPreview); + if (typeof webpPreview === "string" && webpPreview.length <= MAX_PREVIEW_PAYLOAD_BYTES) { + preview = webpPreview; + } else { + console.warn("[Editor] Skipping oversized preview payload", { + drawingId, + svgPreviewBytes: svgPreview.length, + webpPreviewBytes: webpPreview?.length ?? null, + maxPreviewBytes: MAX_PREVIEW_PAYLOAD_BYTES, + }); + return; + } + } console.log("[Editor] Saving preview", { drawingId, elementCount: normalizedSnapshot.length, }); - await api.updateDrawing(drawingId, { preview }); + await api.updateDrawingPreview(drawingId, preview); console.log("[Editor] Preview save complete", { drawingId }); } catch (err) { @@ -924,14 +910,6 @@ export const Editor: React.FC = () => { }; - const debouncedSave = useCallback( - debounce((drawingId, elements, appState, files) => { - enqueueSceneSave(drawingId, elements, appState, files); - }, 1000), - [enqueueSceneSave] // Stable queue wrapper avoids concurrent version conflicts - ); - // Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves. - debouncedSaveRef.current = debouncedSave; const debouncedSavePreview = useCallback( debounce((drawingId, elements, appState, files) => { if (savePreviewRef.current) { @@ -952,27 +930,22 @@ export const Editor: React.FC = () => { useEffect(() => { return () => { - debouncedSave.cancel(); debouncedSavePreview.cancel(); }; - }, [debouncedSave, debouncedSavePreview]); + }, [debouncedSavePreview]); const flushPendingSavesForLifecycle = useCallback(() => { if (!canEditScene || !id) return; emitFinalPointerSync(); - debouncedSave.flush(); debouncedSavePreview.flush(); - - const snapshotElements = excalidrawAPI.current?.getSceneElementsIncludingDeleted?.() ?? latestElementsRef.current; - const snapshotAppState = excalidrawAPI.current?.getAppState?.() ?? latestAppStateRef.current; - const snapshotFiles = excalidrawAPI.current?.getFiles?.() ?? latestFilesRef.current ?? {}; - - if (!snapshotAppState || !saveDataRef.current) return; - - hasSceneChangesSinceLoadRef.current = true; - void enqueueSceneSave(id, snapshotElements, snapshotAppState, snapshotFiles); - }, [canEditScene, id, debouncedSave, debouncedSavePreview, emitFinalPointerSync, enqueueSceneSave]); + if (socketRef.current) { + socketRef.current.emit("scene-flush", { drawingId: id }); + } + void api.flushDrawingScene(id).catch(() => { + // Best-effort flush during lifecycle events. + }); + }, [canEditScene, id, debouncedSavePreview, emitFinalPointerSync]); useEffect(() => { if (!id || !canEditScene) return; @@ -1005,7 +978,7 @@ export const Editor: React.FC = () => { const broadcastChanges = useCallback( throttle((elements: readonly any[], currentFiles?: Record) => { if (!canEditScene) return; - if (!socketRef.current || !id) return; + if (!id) return; const changes: any[] = []; const selectedIds = new Set( @@ -1033,15 +1006,24 @@ export const Editor: React.FC = () => { } if (changes.length > 0 || shouldSyncFiles) { - socketRef.current.emit('element-update', { - drawingId: id, - elements: changes.length > 0 ? changes : [], - files: shouldSyncFiles ? filesDelta : undefined, - userId: me.id - }); + const deletedIds = changes + .filter((element: any) => element?.isDeleted === true) + .map((element: any) => element.id) + .filter((elementId: unknown): elementId is string => typeof elementId === "string"); + emitSceneOp([ + { + upsertElements: changes.length > 0 ? changes : [], + deleteElementIds: deletedIds, + filesDelta: shouldSyncFiles ? filesDelta : {}, + appStatePatch: { + viewBackgroundColor: latestAppStateRef.current?.viewBackgroundColor, + gridSize: latestAppStateRef.current?.gridSize ?? null, + }, + }, + ]); } }, 100, { leading: true, trailing: true }), - [id, hasElementChanged, recordElementVersion, canEditScene] + [id, hasElementChanged, recordElementVersion, canEditScene, emitSceneOp] ); useEffect(() => { @@ -1053,7 +1035,6 @@ export const Editor: React.FC = () => { initialSceneElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; - currentDrawingVersionRef.current = null; lastPersistedElementsRef.current = []; suspiciousBlankLoadRef.current = false; hasSceneChangesSinceLoadRef.current = false; @@ -1063,6 +1044,7 @@ export const Editor: React.FC = () => { window.clearTimeout(finalSyncTimeoutRef.current); finalSyncTimeoutRef.current = null; } + serverSceneSeqRef.current = 0; lastPointerButtonRef.current = "up"; lastPointerSelectionSigRef.current = "{}"; lastPointerRef.current = null; @@ -1119,7 +1101,6 @@ export const Editor: React.FC = () => { initialSceneElementsRef.current = elements; latestFilesRef.current = files; lastSyncedFilesRef.current = files; - currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null; lastPersistedElementsRef.current = elements; elements.forEach((el: any) => { @@ -1165,7 +1146,6 @@ export const Editor: React.FC = () => { initialSceneElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; - currentDrawingVersionRef.current = null; lastPersistedElementsRef.current = []; suspiciousBlankLoadRef.current = false; hasSceneChangesSinceLoadRef.current = false; @@ -1206,8 +1186,10 @@ export const Editor: React.FC = () => { }); } if (!id) return; - await enqueueSceneSave(id, safeElements, appState, files); - savePreviewRef.current(id, safeElements, appState, files); + await Promise.all([ + enqueueSceneSave(id, safeElements, appState, files), + savePreviewRef.current(id, safeElements, appState, files), + ]); toast.success("Saved changes to server"); } } @@ -1323,16 +1305,6 @@ export const Editor: React.FC = () => { const filesSnapshot = currentFiles; latestFilesRef.current = filesSnapshot; - // Trigger Fast Save - console.log("[Editor] Queueing save", { - drawingId: id, - elementCount: allElements.length, - hasRenderableElements: hasRenderable, - }); - if (id) { - debouncedSave(id, allElements, appState, filesSnapshot); - } - // Trigger Slow Preview Gen console.log("[Editor] Queueing preview save", { drawingId: id, @@ -1341,7 +1313,7 @@ export const Editor: React.FC = () => { if (id) { debouncedSavePreview(id, allElements, appState, filesSnapshot); } - }, [debouncedSave, debouncedSavePreview, broadcastChanges, id, resolveSafeSnapshot, canEditScene]); + }, [debouncedSavePreview, broadcastChanges, id, resolveSafeSnapshot, canEditScene]); // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // are still broadcast to collaborators AND persisted to the server. @@ -1357,10 +1329,9 @@ export const Editor: React.FC = () => { 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) { + // Keep preview updated after async image data becomes available. + if (didEmit && latestAppStateRef.current) { hasSceneChangesSinceLoadRef.current = true; - debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles); if (savePreviewRef.current) { void savePreviewRef.current( id, @@ -1460,7 +1431,7 @@ export const Editor: React.FC = () => { // Save drawing and generate preview before navigating try { - if (!(excalidrawAPI.current && saveDataRef.current && savePreviewRef.current)) { + if (!(excalidrawAPI.current && saveDataRef.current)) { // If editor API is not ready, allow navigation instead of trapping the user. shouldNavigate = true; } else if (!hasSceneChangesSinceLoadRef.current) { @@ -1500,7 +1471,7 @@ export const Editor: React.FC = () => { } else { await Promise.all([ enqueueSceneSave(id, safeElements, appState, files, { suppressErrors: false }), - savePreviewRef.current(id, safeElements, appState, files) + savePreviewRef.current?.(id, safeElements, appState, files), ]); console.log("[Editor] Saved on back navigation", { drawingId: id }); shouldNavigate = true; diff --git a/frontend/src/utils/previewSvg.ts b/frontend/src/utils/previewSvg.ts index 1a76c67..c240608 100644 --- a/frontend/src/utils/previewSvg.ts +++ b/frontend/src/utils/previewSvg.ts @@ -65,11 +65,29 @@ export const previewHasEmbeddedImages = ( preview: string | null | undefined ): boolean => typeof preview === "string" && /]/i.test(preview); +export const isPreviewImageDataUrl = ( + preview: string | null | undefined +): boolean => + typeof preview === "string" && + /^data:image\/(?:webp|png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(preview.trim()); + +export const isPreviewSvgMarkup = ( + preview: string | null | undefined +): boolean => typeof preview === "string" && /^\s*]/i.test(preview); + export const normalizePreviewSvg = (preview: string | null | undefined): string | null => { if (typeof preview !== "string" || preview.trim().length === 0) { return preview ?? null; } + if (isPreviewImageDataUrl(preview)) { + return preview.trim(); + } + + if (!isPreviewSvgMarkup(preview)) { + return null; + } + if (typeof DOMParser === "undefined") { return preview; }