images in preview
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user