Files
ExcaliDash/frontend/src/pages/editor/shared.ts
T
2026-02-07 15:51:35 -08:00

125 lines
4.0 KiB
TypeScript

export interface ElementVersionInfo {
version: number;
versionNonce: number;
}
export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.id !== right.id) return false;
if ((left.version ?? 0) !== (right.version ?? 0)) return false;
if ((left.versionNonce ?? 0) !== (right.versionNonce ?? 0)) return false;
}
return true;
};
export const hasRenderableElements = (elements: readonly any[] = []): boolean =>
elements.some((element: any) => !element?.isDeleted);
/**
* Guard against transient empty snapshots (e.g. hydration/reload races) from
* overwriting a previously persisted non-empty drawing.
*/
export const isSuspiciousEmptySnapshot = (
previousPersisted: readonly any[] = [],
nextSnapshot: readonly any[] = []
): boolean => {
if (!Array.isArray(nextSnapshot) || nextSnapshot.length > 0) return false;
return hasRenderableElements(previousPersisted);
};
/**
* Detects a stale empty snapshot that is older than the current in-memory scene.
* This prevents race conditions where an outdated empty `onChange` event can
* overwrite a newer non-empty scene.
*/
export const isStaleEmptySnapshot = (
latestSnapshot: readonly any[] = [],
candidateSnapshot: readonly any[] = []
): boolean => {
if (!Array.isArray(candidateSnapshot) || candidateSnapshot.length > 0) return false;
if (!hasRenderableElements(latestSnapshot)) return false;
return !haveSameElements(latestSnapshot, candidateSnapshot);
};
/**
* Detects a stale snapshot that has no renderable elements while the latest
* in-memory scene still has renderable content.
*
* This covers cases where Excalidraw emits a transient non-renderable scene
* (e.g. hydration race) that should not overwrite newer content.
*/
export const isStaleNonRenderableSnapshot = (
latestSnapshot: readonly any[] = [],
candidateSnapshot: readonly any[] = []
): boolean => {
if (!Array.isArray(candidateSnapshot)) return false;
if (hasRenderableElements(candidateSnapshot)) return false;
if (!hasRenderableElements(latestSnapshot)) return false;
return !haveSameElements(latestSnapshot, candidateSnapshot);
};
const buildFileSignature = (file: any): string => {
const mimeType = typeof file?.mimeType === "string" ? file.mimeType : "";
const id = typeof file?.id === "string" ? file.id : "";
const dataURL = typeof file?.dataURL === "string" ? file.dataURL : "";
const prefix = dataURL.slice(0, 32);
const suffix = dataURL.slice(-32);
return `${id}|${mimeType}|${dataURL.length}|${prefix}|${suffix}`;
};
export const getFilesDelta = (
previous: Record<string, any>,
next: Record<string, any>
): Record<string, any> => {
const delta: Record<string, any> = {};
const prev = previous || {};
const nxt = next || {};
for (const fileId of Object.keys(nxt)) {
const nextFile = nxt[fileId];
const nextHasDataUrl = typeof nextFile?.dataURL === "string" && nextFile.dataURL.length > 0;
if (!nextHasDataUrl) continue;
const prevFile = prev[fileId];
if (!prevFile) {
delta[fileId] = nextFile;
continue;
}
if (buildFileSignature(prevFile) !== buildFileSignature(nextFile)) {
delta[fileId] = nextFile;
}
}
return delta;
};
export const UIOptions = {
canvasActions: {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
toggleTheme: true,
},
};
export { getInitialsFromName } from "../../utils/user";
export const getColorFromString = (str: string): string => {
const COLORS = [
"#ef4444", "#f97316", "#f59e0b", "#84cc16", "#22c55e", "#10b981",
"#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6",
"#a855f7", "#d946ef", "#ec4899", "#f43f5e",
];
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return COLORS[Math.abs(hash) % COLORS.length];
};