svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm transition-transform duration-500",
+ !hasEmbeddedImages && "dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0"
+ )}
dangerouslySetInnerHTML={{ __html: previewSvg }}
/>
) : (
diff --git a/frontend/src/context/AuthContext.test.tsx b/frontend/src/context/AuthContext.test.tsx
index 6599069..df8082d 100644
--- a/frontend/src/context/AuthContext.test.tsx
+++ b/frontend/src/context/AuthContext.test.tsx
@@ -87,4 +87,43 @@ describe("AuthProvider", () => {
expect(storage.get("excalidash-refresh-token")).toBeUndefined();
expect(storage.get("excalidash-user")).toBeUndefined();
});
+
+ it("uses cached auth-disabled mode when /auth/status is temporarily unavailable", async () => {
+ const storage = new Map
([
+ ["excalidash-auth-enabled", "false"],
+ ["excalidash-access-token", "token"],
+ ["excalidash-refresh-token", "refresh"],
+ ["excalidash-user", JSON.stringify({ id: "u1" })],
+ ]);
+ Object.defineProperty(window, "localStorage", {
+ configurable: true,
+ value: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ storage.set(key, value);
+ },
+ removeItem: (key: string) => {
+ storage.delete(key);
+ },
+ },
+ });
+
+ vi.spyOn(axios, "get").mockRejectedValueOnce(new Error("network down"));
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("loading").textContent).toBe("false");
+ });
+ expect(screen.getByTestId("auth-enabled").textContent).toBe("false");
+ expect(storage.get("excalidash-access-token")).toBeUndefined();
+ expect(storage.get("excalidash-refresh-token")).toBeUndefined();
+ expect(storage.get("excalidash-user")).toBeUndefined();
+ });
});
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
index 8848ee2..9602002 100644
--- a/frontend/src/context/AuthContext.tsx
+++ b/frontend/src/context/AuthContext.tsx
@@ -30,6 +30,7 @@ const AuthContext = createContext(undefined);
const TOKEN_KEY = 'excalidash-access-token';
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
const USER_KEY = 'excalidash-user';
+const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState(null);
@@ -52,6 +53,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
? statusResponse.data.enabled
: true;
setAuthEnabled(enabled);
+ localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled));
setBootstrapRequired(Boolean(statusResponse.data?.bootstrapRequired));
// In single-user mode, do not require login.
@@ -63,8 +65,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return;
}
} catch {
- // If status fails, default to auth-enabled mode to avoid exposing
- // single-user UI paths accidentally. Backend remains the source of truth.
+ const cachedAuthEnabled = localStorage.getItem(AUTH_ENABLED_CACHE_KEY);
+ if (cachedAuthEnabled === "false") {
+ setAuthEnabled(false);
+ setBootstrapRequired(false);
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
+ localStorage.removeItem(USER_KEY);
+ setUser(null);
+ return;
+ }
+ // If status fails and no cached mode exists, default to auth-enabled mode.
setAuthEnabled(true);
setBootstrapRequired(false);
}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 8a35e38..74ad12e 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -719,7 +719,7 @@ export const Dashboard: React.FC = () => {
{d.preview ? (
) : (
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx
index 19b51de..4b388ca 100644
--- a/frontend/src/pages/Editor.tsx
+++ b/frontend/src/pages/Editor.tsx
@@ -30,6 +30,13 @@ 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();
@@ -174,6 +181,39 @@ export const Editor: React.FC = () => {
[getRenderableBaselineSnapshot]
);
+ const normalizeImageElementStatus = useCallback(
+ (elements: readonly any[] = [], files?: Record | null): readonly any[] => {
+ if (!Array.isArray(elements) || elements.length === 0) return elements;
+ const fileMap = files || {};
+ let changed = false;
+
+ const normalized = elements.map((element: any) => {
+ if (!element || element.type !== "image" || typeof element.fileId !== "string") {
+ return element;
+ }
+
+ const file = fileMap[element.fileId];
+ const hasImageData =
+ typeof file?.dataURL === "string" &&
+ file.dataURL.startsWith("data:image/") &&
+ file.dataURL.length > 0;
+
+ if (!hasImageData || element.status === "saved") {
+ return element;
+ }
+
+ changed = true;
+ return {
+ ...element,
+ status: "saved",
+ };
+ });
+
+ return changed ? normalized : elements;
+ },
+ []
+ );
+
const emitFilesDeltaIfNeeded = useCallback(
(nextFiles: Record) => {
if (!socketRef.current || !id) return false;
@@ -519,18 +559,23 @@ export const Editor: React.FC = () => {
return;
}
const persistableFiles = files ?? latestFilesRef.current ?? {};
+ const normalizedElements = normalizeImageElementStatus(
+ persistableElements,
+ persistableFiles
+ );
+ const normalizedElementsForSave = Array.from(normalizedElements);
console.log("[Editor] Saving drawing", {
drawingId,
- elementCount: persistableElements.length,
- hasRenderableElements: hasRenderableElements(persistableElements),
+ elementCount: normalizedElementsForSave.length,
+ hasRenderableElements: hasRenderableElements(normalizedElementsForSave),
appState: persistableAppState,
});
const persistScene = async (attempt: number): Promise => {
try {
const updated = await api.updateDrawing(drawingId, {
- elements: persistableElements,
+ elements: normalizedElementsForSave,
appState: persistableAppState,
files: persistableFiles,
version: currentDrawingVersionRef.current ?? undefined,
@@ -538,7 +583,7 @@ export const Editor: React.FC = () => {
if (typeof updated.version === "number") {
currentDrawingVersionRef.current = updated.version;
}
- lastPersistedElementsRef.current = persistableElements;
+ lastPersistedElementsRef.current = normalizedElementsForSave;
console.log("[Editor] Save complete", { drawingId });
} catch (err) {
if (api.isAxiosError(err) && err.response?.status === 409) {
@@ -557,9 +602,7 @@ export const Editor: React.FC = () => {
return;
}
- console.warn("[Editor] Version conflict while saving drawing", { drawingId });
- toast.error("Drawing changed in another tab. Refresh to load latest.");
- return;
+ throw new DrawingSaveConflictError();
}
throw err;
@@ -568,17 +611,38 @@ export const Editor: React.FC = () => {
await persistScene(0);
} catch (err) {
+ if (err instanceof DrawingSaveConflictError) {
+ console.warn("[Editor] Version conflict while saving drawing", { drawingId });
+ toast.error("Drawing changed in another tab. Refresh to load latest.");
+ throw err;
+ }
console.error('Failed to save drawing', err);
toast.error("Failed to save changes");
+ throw err;
}
};
const enqueueSceneSave = useCallback(
- (drawingId: string, elements: readonly any[], appState: any, files?: Record) => {
+ (
+ drawingId: string,
+ elements: readonly any[],
+ appState: any,
+ files?: Record,
+ options?: { suppressErrors?: boolean }
+ ) => {
+ const suppressErrors = options?.suppressErrors ?? true;
saveQueueRef.current = saveQueueRef.current
.catch(() => undefined)
.then(async () => {
if (!saveDataRef.current) return;
+ if (suppressErrors) {
+ try {
+ await saveDataRef.current(drawingId, elements, appState, files);
+ } catch {
+ // Background autosaves already surface their own toast via saveDataRef.
+ }
+ return;
+ }
await saveDataRef.current(drawingId, elements, appState, files);
});
return saveQueueRef.current;
@@ -590,7 +654,12 @@ export const Editor: React.FC = () => {
if (!drawingId) return;
try {
- const candidateSnapshot = latestElementsRef.current ?? elements;
+ const snapshotFromArgs = Array.isArray(elements) ? elements : [];
+ const snapshotFromRef = latestElementsRef.current ?? [];
+ const candidateSnapshot =
+ hasRenderableElements(snapshotFromArgs) || !hasRenderableElements(snapshotFromRef)
+ ? snapshotFromArgs
+ : snapshotFromRef;
const {
snapshot: currentSnapshot,
prevented: preventedPreviewOverwrite,
@@ -598,6 +667,7 @@ export const Editor: React.FC = () => {
staleNonRenderableSnapshot: staleNonRenderablePreview,
} = resolveSafeSnapshot(candidateSnapshot);
const currentFiles = latestFilesRef.current ?? files;
+ const normalizedSnapshot = normalizeImageElementStatus(currentSnapshot, currentFiles);
if (suspiciousBlankLoadRef.current && !hasRenderableElements(currentSnapshot)) {
console.warn("[Editor] Blocking non-renderable preview due to suspicious blank load", {
drawingId,
@@ -616,7 +686,7 @@ export const Editor: React.FC = () => {
}
const svg = await exportToSvg({
- elements: currentSnapshot,
+ elements: normalizedSnapshot,
appState: {
...appState,
exportBackground: true,
@@ -628,7 +698,7 @@ export const Editor: React.FC = () => {
console.log("[Editor] Saving preview", {
drawingId,
- elementCount: currentSnapshot.length,
+ elementCount: normalizedSnapshot.length,
});
await api.updateDrawing(drawingId, { preview });
@@ -1013,6 +1083,14 @@ export const Editor: React.FC = () => {
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
hasSceneChangesSinceLoadRef.current = true;
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles);
+ if (savePreviewRef.current) {
+ void savePreviewRef.current(
+ id,
+ latestElementsRef.current,
+ latestAppStateRef.current,
+ nextFiles
+ );
+ }
}
}, 1000);
@@ -1044,18 +1122,21 @@ export const Editor: React.FC = () => {
if (isSavingOnLeave) return; // Prevent double clicks
setIsSavingOnLeave(true);
+ let shouldNavigate = false;
// Save drawing and generate preview before navigating
try {
- if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
- if (!hasSceneChangesSinceLoadRef.current) {
- console.log("[Editor] Skipping back-navigation save: no scene changes since load", {
- drawingId: id,
- });
- navigate('/');
- return;
- }
- if (!id) return;
+ if (!(excalidrawAPI.current && saveDataRef.current && savePreviewRef.current)) {
+ // If editor API is not ready, allow navigation instead of trapping the user.
+ shouldNavigate = true;
+ } else if (!hasSceneChangesSinceLoadRef.current) {
+ console.log("[Editor] Skipping back-navigation save: no scene changes since load", {
+ drawingId: id,
+ });
+ shouldNavigate = true;
+ } else if (!id) {
+ shouldNavigate = true;
+ } else {
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const {
snapshot: safeElements,
@@ -1081,22 +1162,25 @@ export const Editor: React.FC = () => {
elementCount: safeElements.length,
});
toast.warning("Blank scene detected on load. Skipping save to protect existing data.");
- navigate('/');
- return;
+ shouldNavigate = true;
+ } else {
+ await Promise.all([
+ enqueueSceneSave(id, safeElements, appState, files, { suppressErrors: false }),
+ savePreviewRef.current(id, safeElements, appState, files)
+ ]);
+ console.log("[Editor] Saved on back navigation", { drawingId: id });
+ shouldNavigate = true;
}
-
- await Promise.all([
- enqueueSceneSave(id, safeElements, appState, files),
- savePreviewRef.current(id, safeElements, appState, files)
- ]);
- console.log("[Editor] Saved on back navigation", { drawingId: id });
}
} catch (err) {
console.error('Failed to save on back navigation', err);
+ toast.error("Failed to save changes. Please retry before leaving.");
} finally {
setIsSavingOnLeave(false);
}
- navigate('/');
+ if (shouldNavigate) {
+ navigate('/');
+ }
};
return (
diff --git a/frontend/src/utils/__tests__/previewSvg.test.ts b/frontend/src/utils/__tests__/previewSvg.test.ts
new file mode 100644
index 0000000..a18168c
--- /dev/null
+++ b/frontend/src/utils/__tests__/previewSvg.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { normalizePreviewSvg, previewHasEmbeddedImages } from "../previewSvg";
+
+describe("normalizePreviewSvg", () => {
+ it("adds viewBox from background rect when missing", () => {
+ const raw = [
+ '",
+ ].join("");
+
+ const normalized = normalizePreviewSvg(raw);
+
+ expect(normalized).toContain('viewBox="0 0 728.39453125 606.908203125"');
+ expect(normalized).toContain('preserveAspectRatio="xMidYMid meet"');
+ });
+
+ it("leaves existing viewBox unchanged", () => {
+ const raw = '';
+ const normalized = normalizePreviewSvg(raw);
+
+ expect(normalized).toContain('viewBox="0 0 100 50"');
+ });
+
+ it("detects embedded image tags", () => {
+ const raw = '';
+ expect(previewHasEmbeddedImages(raw)).toBe(true);
+ expect(previewHasEmbeddedImages("")).toBe(false);
+ });
+
+ it("repairs flattened image previews that are hidden by white canvas rect", () => {
+ const raw = [
+ '",
+ ].join("");
+
+ const normalized = normalizePreviewSvg(raw);
+
+ expect(normalized).toContain('fill="transparent"');
+ });
+});
diff --git a/frontend/src/utils/importUtils.ts b/frontend/src/utils/importUtils.ts
index a274f44..1cc195d 100644
--- a/frontend/src/utils/importUtils.ts
+++ b/frontend/src/utils/importUtils.ts
@@ -231,7 +231,7 @@ export const importLegacyFiles = async (
// Import each drawing entry
for (let i = 0; i < drawings.length; i += 1) {
const d = drawings[i] as LegacyExportDrawing;
- const elements = Array.isArray(d.elements) ? d.elements : null;
+ const elements = Array.isArray(d.elements) ? (d.elements as any[]) : null;
const appState =
typeof d.appState === "object" && d.appState !== null
? (d.appState as Record)
diff --git a/frontend/src/utils/previewSvg.ts b/frontend/src/utils/previewSvg.ts
new file mode 100644
index 0000000..1a76c67
--- /dev/null
+++ b/frontend/src/utils/previewSvg.ts
@@ -0,0 +1,108 @@
+const parseDimension = (value: string | null): number | null => {
+ if (!value) return null;
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+};
+
+const parseCoordinate = (value: string | null): number | null => {
+ if (!value) return null;
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) ? parsed : null;
+};
+
+const parseViewBox = (value: string | null): { width: number; height: number } | null => {
+ if (!value) return null;
+ const parts = value.trim().split(/[,\s]+/).map((part) => Number.parseFloat(part));
+ if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return null;
+ const [, , width, height] = parts;
+ if (width <= 0 || height <= 0) return null;
+ return { width, height };
+};
+
+const isNear = (a: number, b: number, epsilon = 0.5): boolean => Math.abs(a - b) <= epsilon;
+
+const maybeRepairFlattenedImagePreview = (svg: SVGSVGElement) => {
+ const rootImage = Array.from(svg.children).find(
+ (child) =>
+ child.tagName.toLowerCase() === "image" &&
+ /^(100%|1(?:\.0+)?%?)$/i.test(child.getAttribute("width") ?? "") &&
+ /^(100%|1(?:\.0+)?%?)$/i.test(child.getAttribute("height") ?? "") &&
+ /^data:image\//i.test(child.getAttribute("href") ?? child.getAttribute("xlink:href") ?? "")
+ );
+ if (!rootImage) return;
+
+ const hasPattern = svg.querySelector("pattern") !== null;
+ const hasUrlFill = Array.from(svg.querySelectorAll("[fill]")).some((node) =>
+ /^url\(#/i.test(node.getAttribute("fill") ?? "")
+ );
+ if (hasPattern || hasUrlFill) return;
+
+ const viewBox = parseViewBox(svg.getAttribute("viewBox"));
+ const fallbackWidth = parseDimension(svg.getAttribute("width"));
+ const fallbackHeight = parseDimension(svg.getAttribute("height"));
+ const canvasWidth = viewBox?.width ?? fallbackWidth;
+ const canvasHeight = viewBox?.height ?? fallbackHeight;
+ if (!canvasWidth || !canvasHeight) return;
+
+ const candidateRect = Array.from(svg.children).find((child) => {
+ if (child.tagName.toLowerCase() !== "rect") return false;
+ const fill = (child.getAttribute("fill") || "").trim().toLowerCase();
+ if (fill !== "#fff" && fill !== "#ffffff" && fill !== "white") return false;
+ const x = parseCoordinate(child.getAttribute("x"));
+ const y = parseCoordinate(child.getAttribute("y"));
+ const width = parseDimension(child.getAttribute("width"));
+ const height = parseDimension(child.getAttribute("height"));
+ if (x !== 0 || y !== 0 || !width || !height) return false;
+ return isNear(width, canvasWidth) && isNear(height, canvasHeight);
+ });
+
+ if (candidateRect) {
+ candidateRect.setAttribute("fill", "transparent");
+ }
+};
+
+export const previewHasEmbeddedImages = (
+ preview: string | null | undefined
+): boolean => typeof preview === "string" && /]/i.test(preview);
+
+export const normalizePreviewSvg = (preview: string | null | undefined): string | null => {
+ if (typeof preview !== "string" || preview.trim().length === 0) {
+ return preview ?? null;
+ }
+
+ if (typeof DOMParser === "undefined") {
+ return preview;
+ }
+
+ try {
+ const doc = new DOMParser().parseFromString(preview, "image/svg+xml");
+ const svg = doc.documentElement;
+ if (!svg || svg.tagName.toLowerCase() !== "svg") {
+ return preview;
+ }
+
+ if (!svg.hasAttribute("viewBox")) {
+ const backgroundRect = svg.querySelector("rect[x='0'][y='0']");
+ const width =
+ parseDimension(backgroundRect?.getAttribute("width") ?? null) ??
+ parseDimension(svg.getAttribute("width"));
+ const height =
+ parseDimension(backgroundRect?.getAttribute("height") ?? null) ??
+ parseDimension(svg.getAttribute("height"));
+
+ if (width && height) {
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ }
+ }
+
+ if (!svg.hasAttribute("preserveAspectRatio")) {
+ svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
+ }
+
+ maybeRepairFlattenedImagePreview(svg as unknown as SVGSVGElement);
+
+ return svg.outerHTML;
+ } catch {
+ return preview;
+ }
+};