images in preview

This commit is contained in:
Zimeng Xiong
2026-02-07 17:21:58 -08:00
parent 2aa749a2f0
commit 35bbbb9599
15 changed files with 654 additions and 77 deletions
+50
View File
@@ -0,0 +1,50 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"dist",
"coverage",
"playwright-report",
"test-results",
"node_modules",
],
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
files: ["**/*.{test,spec}.{ts,tsx}"],
languageOptions: {
globals: {
...globals.vitest,
},
},
}
);
+84 -9
View File
@@ -1,10 +1,12 @@
import axios from "axios";
import type { Drawing, Collection, DrawingSummary } from "../types";
import { normalizePreviewSvg } from "../utils/previewSvg";
export const API_URL = import.meta.env.VITE_API_URL || "/api";
export const api = axios.create({
baseURL: API_URL,
withCredentials: true,
});
// Re-export axios for type checking
@@ -18,14 +20,19 @@ export { api as default };
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";
const AUTH_STATUS_TTL_MS = 5000;
type RetriableRequestConfig = {
_retry?: boolean;
_csrfRetry?: boolean;
_authModeRetry?: boolean;
url?: string;
headers?: Record<string, string>;
};
let authEnabledProbeCache: { value: boolean; fetchedAt: number } | null = null;
const getAuthToken = (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
@@ -39,7 +46,8 @@ let csrfTokenPromise: Promise<void> | null = null;
export const fetchCsrfToken = async (): Promise<void> => {
try {
const response = await axios.get<{ token: string; header: string }>(
`${API_URL}/csrf-token`
`${API_URL}/csrf-token`,
{ withCredentials: true }
);
csrfToken = response.data.token;
csrfHeaderName = response.data.header || "x-csrf-token";
@@ -71,7 +79,47 @@ const clearStoredAuth = () => {
localStorage.removeItem(USER_KEY);
};
const redirectToLogin = () => {
const readCachedAuthEnabled = (): boolean | null => {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(AUTH_ENABLED_CACHE_KEY);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
};
const cacheAuthEnabled = (enabled: boolean) => {
if (typeof window === "undefined") return;
authEnabledProbeCache = { value: enabled, fetchedAt: Date.now() };
localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled));
};
const getAuthEnabledStatus = async (): Promise<boolean | null> => {
const now = Date.now();
if (authEnabledProbeCache && now - authEnabledProbeCache.fetchedAt < AUTH_STATUS_TTL_MS) {
return authEnabledProbeCache.value;
}
try {
const response = await axios.get<{ authEnabled?: boolean; enabled?: boolean }>(
`${API_URL}/auth/status`,
{ withCredentials: true }
);
const enabled =
typeof response.data?.authEnabled === "boolean"
? response.data.authEnabled
: typeof response.data?.enabled === "boolean"
? response.data.enabled
: true;
cacheAuthEnabled(enabled);
return enabled;
} catch {
return readCachedAuthEnabled();
}
};
const redirectToLogin = async () => {
const authEnabled = await getAuthEnabledStatus();
if (authEnabled === false) return;
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
@@ -87,9 +135,13 @@ const refreshAccessToken = async (): Promise<string> => {
throw new Error("Missing refresh token");
}
const refreshResponse = await axios.post(`${API_URL}/auth/refresh`, {
refreshToken,
});
const refreshResponse = await axios.post(
`${API_URL}/auth/refresh`,
{
refreshToken,
},
{ withCredentials: true }
);
const nextAccessToken = String(refreshResponse.data.accessToken || "");
if (!nextAccessToken) {
@@ -173,6 +225,15 @@ api.interceptors.response.use(
const url = String(originalRequest.url || "");
const isAuthRoute = url.includes('/auth/');
const hasRefreshToken = Boolean(localStorage.getItem(REFRESH_TOKEN_KEY));
const authEnabled = !isAuthRoute ? await getAuthEnabledStatus() : true;
if (!isAuthRoute && authEnabled === false) {
if (!originalRequest._authModeRetry) {
originalRequest._authModeRetry = true;
return api(originalRequest as any);
}
return Promise.reject(error);
}
if (!isAuthRoute && hasRefreshToken && !originalRequest._retry) {
try {
@@ -183,14 +244,14 @@ api.interceptors.response.use(
return api(originalRequest as any);
} catch {
clearStoredAuth();
redirectToLogin();
await redirectToLogin();
return Promise.reject(error);
}
}
if (!isAuthRoute) {
clearStoredAuth();
redirectToLogin();
await redirectToLogin();
}
}
@@ -243,14 +304,28 @@ const deserializeDrawingSummary = (drawing: unknown): DrawingSummary => {
if (typeof drawing !== 'object' || drawing === null) {
throw new Error('Invalid drawing data');
}
return deserializeTimestamps(drawing as HasTimestamps & DrawingSummary);
const parsed = drawing as HasTimestamps & DrawingSummary;
return deserializeTimestamps({
...parsed,
preview:
typeof parsed.preview === "string"
? normalizePreviewSvg(parsed.preview)
: parsed.preview,
});
};
const deserializeDrawing = (drawing: unknown): Drawing => {
if (typeof drawing !== 'object' || drawing === null) {
throw new Error('Invalid drawing data');
}
return deserializeTimestamps(drawing as HasTimestamps & Drawing);
const parsed = drawing as HasTimestamps & Drawing;
return deserializeTimestamps({
...parsed,
preview:
typeof parsed.preview === "string"
? normalizePreviewSvg(parsed.preview)
: parsed.preview,
});
};
export interface PaginatedDrawings<T> {
+32 -2
View File
@@ -7,6 +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 * as api from '../api';
@@ -16,6 +17,31 @@ type HydratedDrawingData = {
files: Record<string, any>;
};
const normalizeImageElementsForPreview = (
elements: any[] = [],
files: Record<string, any> = {}
): any[] =>
elements.map((element) => {
if (!element || element.type !== "image" || typeof element.fileId !== "string") {
return element;
}
const file = files[element.fileId];
const hasImageData =
typeof file?.dataURL === "string" &&
file.dataURL.startsWith("data:image/") &&
file.dataURL.length > 0;
if (!hasImageData || element.status === "saved") {
return element;
}
return {
...element,
status: "saved",
};
});
interface DrawingCardProps {
drawing: DrawingSummary;
collections: Collection[];
@@ -60,6 +86,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [fullData, setFullData] = useState<HydratedDrawingData | null>(null);
const hasEmbeddedImages = previewHasEmbeddedImages(previewSvg);
const fullDataRef = React.useRef(fullData);
fullDataRef.current = fullData;
@@ -118,7 +145,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
if (cancelled) return;
const svg = await exportToSvg({
elements: data.elements,
elements: normalizeImageElementsForPreview(data.elements, data.files || {}),
appState: {
...data.appState,
exportBackground: true,
@@ -248,7 +275,10 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
{previewSvg ? (
<div
className="w-full h-full p-3 sm:p-4 lg:p-5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0 transition-transform duration-500 group-hover:scale-105"
className={clsx(
"w-full h-full p-3 sm:p-4 lg:p-5 flex items-center justify-center [&>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 }}
/>
) : (
+39
View File
@@ -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<string, string>([
["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(
<MemoryRouter>
<AuthProvider>
<Probe />
</AuthProvider>
</MemoryRouter>
);
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();
});
});
+13 -2
View File
@@ -30,6 +30,7 @@ const AuthContext = createContext<AuthContextType | undefined>(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<User | null>(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);
}
+1 -1
View File
@@ -719,7 +719,7 @@ export const Dashboard: React.FC = () => {
{d.preview ? (
<div
className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm relative z-10"
className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm relative z-10"
dangerouslySetInnerHTML={{ __html: d.preview }}
/>
) : (
+113 -29
View File
@@ -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<string, any> | 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<string, any>) => {
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<void> => {
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<string, any>) => {
(
drawingId: string,
elements: readonly any[],
appState: any,
files?: Record<string, any>,
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 (
@@ -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 = [
'<svg width="1456.7890625" height="1213.81640625">',
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#fff"></rect>',
'<path d="M0 0 L20 20"></path>',
"</svg>",
].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 = '<svg viewBox="0 0 100 50" width="200" height="100"></svg>';
const normalized = normalizePreviewSvg(raw);
expect(normalized).toContain('viewBox="0 0 100 50"');
});
it("detects embedded image tags", () => {
const raw = '<svg><image href="data:image/png;base64,AAAA"></image></svg>';
expect(previewHasEmbeddedImages(raw)).toBe(true);
expect(previewHasEmbeddedImages("<svg><rect/></svg>")).toBe(false);
});
it("repairs flattened image previews that are hidden by white canvas rect", () => {
const raw = [
'<svg viewBox="0 0 500 700" width="1000" height="1400">',
'<image width="100%" height="100%" href="data:image/png;base64,AAAA"></image>',
'<defs></defs>',
'<rect x="0" y="0" width="500" height="700" fill="#ffffff"></rect>',
"</svg>",
].join("");
const normalized = normalizePreviewSvg(raw);
expect(normalized).toContain('fill="transparent"');
});
});
+1 -1
View File
@@ -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<string, unknown>)
+108
View File
@@ -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" && /<image[\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 (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;
}
};