Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02736d663a | |||
| de254d46f2 | |||
| dd0f381ed1 | |||
| c40a5f46a0 | |||
| 8fcca43b0d | |||
| f20412cdfb |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.4.3",
|
"version": "0.4.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import request from "supertest";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
|
import JSZip from "jszip";
|
||||||
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
|
import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils";
|
||||||
|
|
||||||
type LegacyDbOptions = {
|
type LegacyDbOptions = {
|
||||||
@@ -156,6 +157,111 @@ const createLegacySqliteDb = (opts: LegacyDbOptions): string => {
|
|||||||
return filePath;
|
return filePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createExcalidashArchiveWithDuplicateDrawingIds = async (): Promise<string> => {
|
||||||
|
const dir = createTempDir();
|
||||||
|
const filePath = path.join(dir, "duplicate-drawing-ids.excalidash");
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
format: "excalidash",
|
||||||
|
formatVersion: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
unorganizedFolder: "Unorganized",
|
||||||
|
collections: [] as any[],
|
||||||
|
drawings: [
|
||||||
|
{
|
||||||
|
id: "duplicate-drawing-id",
|
||||||
|
name: "Drawing One",
|
||||||
|
filePath: "Unorganized/drawing-1.excalidraw",
|
||||||
|
collectionId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "duplicate-drawing-id",
|
||||||
|
name: "Drawing Two",
|
||||||
|
filePath: "Unorganized/drawing-2.excalidraw",
|
||||||
|
collectionId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
zip.file("excalidash.manifest.json", JSON.stringify(manifest));
|
||||||
|
zip.file(
|
||||||
|
"Unorganized/drawing-1.excalidraw",
|
||||||
|
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
|
||||||
|
);
|
||||||
|
zip.file(
|
||||||
|
"Unorganized/drawing-2.excalidraw",
|
||||||
|
JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
return filePath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLegacySqliteDbWithDuplicateDrawingIds = (): string => {
|
||||||
|
const dir = createTempDir();
|
||||||
|
const filePath = path.join(dir, "legacy-duplicate-ids.db");
|
||||||
|
const db = openWritableDb(filePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE "Drawing" (
|
||||||
|
id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
elements TEXT NOT NULL,
|
||||||
|
appState TEXT NOT NULL,
|
||||||
|
files TEXT,
|
||||||
|
preview TEXT,
|
||||||
|
version INTEGER,
|
||||||
|
collectionId TEXT,
|
||||||
|
collectionName TEXT,
|
||||||
|
createdAt TEXT,
|
||||||
|
updatedAt TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const now = new Date("2024-01-03T00:00:00.000Z").toISOString();
|
||||||
|
const insertDrawing = db.prepare(
|
||||||
|
`INSERT INTO "Drawing"
|
||||||
|
(id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
insertDrawing.run(
|
||||||
|
"legacy-duplicate-id",
|
||||||
|
"Legacy Drawing A",
|
||||||
|
JSON.stringify([]),
|
||||||
|
JSON.stringify({}),
|
||||||
|
JSON.stringify({}),
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
insertDrawing.run(
|
||||||
|
"legacy-duplicate-id",
|
||||||
|
"Legacy Drawing B",
|
||||||
|
JSON.stringify([]),
|
||||||
|
JSON.stringify({}),
|
||||||
|
JSON.stringify({}),
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
};
|
||||||
|
|
||||||
describe("Import compatibility (legacy exports)", () => {
|
describe("Import compatibility (legacy exports)", () => {
|
||||||
const uploadsDir = path.resolve(__dirname, "../../uploads");
|
const uploadsDir = path.resolve(__dirname, "../../uploads");
|
||||||
const userAgent = "vitest-import-compat";
|
const userAgent = "vitest-import-compat";
|
||||||
@@ -287,4 +393,52 @@ describe("Import compatibility (legacy exports)", () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.error).toBe("Invalid legacy DB");
|
expect(res.body.error).toBe("Invalid legacy DB");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
|
||||||
|
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/excalidash/verify")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("archive", archive);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
|
||||||
|
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/excalidash")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("archive", archive);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
|
||||||
|
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/sqlite/legacy/verify")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("db", legacyDb);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
|
||||||
|
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/import/sqlite/legacy")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.attach("db", legacyDb);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(String(res.body.message || "")).toContain("Duplicate drawing id");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ const drawingUpdateSchema = drawingBaseSchema
|
|||||||
elements: elementSchema.array().optional(),
|
elements: elementSchema.array().optional(),
|
||||||
appState: appStateSchema.optional(),
|
appState: appStateSchema.optional(),
|
||||||
files: filesFieldSchema,
|
files: filesFieldSchema,
|
||||||
|
version: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
|||||||
@@ -310,7 +310,12 @@ export const registerDashboardRoutes = (
|
|||||||
appState?: Record<string, unknown>;
|
appState?: Record<string, unknown>;
|
||||||
preview?: string | null;
|
preview?: string | null;
|
||||||
files?: Record<string, unknown>;
|
files?: Record<string, unknown>;
|
||||||
|
version?: number;
|
||||||
};
|
};
|
||||||
|
const isSceneUpdate =
|
||||||
|
payload.elements !== undefined ||
|
||||||
|
payload.appState !== undefined ||
|
||||||
|
payload.files !== undefined;
|
||||||
const data: Prisma.DrawingUpdateInput = { version: { increment: 1 } };
|
const data: Prisma.DrawingUpdateInput = { version: { increment: 1 } };
|
||||||
|
|
||||||
if (payload.name !== undefined) data.name = payload.name;
|
if (payload.name !== undefined) data.name = payload.name;
|
||||||
@@ -334,7 +339,37 @@ export const registerDashboardRoutes = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDrawing = await prisma.drawing.update({ where: { id }, data });
|
const updateWhere: Prisma.DrawingWhereInput = { id, userId: req.user.id };
|
||||||
|
if (isSceneUpdate && payload.version !== undefined) {
|
||||||
|
updateWhere.version = payload.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateResult = await prisma.drawing.updateMany({
|
||||||
|
where: updateWhere,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
if (isSceneUpdate && payload.version !== undefined) {
|
||||||
|
const latestDrawing = await prisma.drawing.findFirst({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
select: { version: true },
|
||||||
|
});
|
||||||
|
return res.status(409).json({
|
||||||
|
error: "Conflict",
|
||||||
|
code: "VERSION_CONFLICT",
|
||||||
|
message: "Drawing has changed since this editor state was loaded.",
|
||||||
|
currentVersion: latestDrawing?.version ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDrawing = await prisma.drawing.findFirst({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!updatedDrawing) {
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
invalidateDrawingsCache();
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -352,7 +387,12 @@ export const registerDashboardRoutes = (
|
|||||||
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
||||||
if (!drawing) return res.status(404).json({ error: "Drawing not found" });
|
if (!drawing) return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
|
||||||
await prisma.drawing.delete({ where: { id } });
|
const deleteResult = await prisma.drawing.deleteMany({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (deleteResult.count === 0) {
|
||||||
|
return res.status(404).json({ error: "Drawing not found" });
|
||||||
|
}
|
||||||
invalidateDrawingsCache();
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
if (config.enableAuditLogging) {
|
if (config.enableAuditLogging) {
|
||||||
@@ -375,6 +415,9 @@ export const registerDashboardRoutes = (
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
||||||
if (!original) return res.status(404).json({ error: "Original drawing not found" });
|
if (!original) return res.status(404).json({ error: "Original drawing not found" });
|
||||||
|
if (original.collectionId === "trash") {
|
||||||
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
const newDrawing = await prisma.drawing.create({
|
const newDrawing = await prisma.drawing.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -443,10 +486,19 @@ export const registerDashboardRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedName = sanitizeText(parsed.data, 100);
|
const sanitizedName = sanitizeText(parsed.data, 100);
|
||||||
const updatedCollection = await prisma.collection.update({
|
const updateResult = await prisma.collection.updateMany({
|
||||||
where: { id },
|
where: { id, userId: req.user.id },
|
||||||
data: { name: sanitizedName },
|
data: { name: sanitizedName },
|
||||||
});
|
});
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
return res.status(404).json({ error: "Collection not found" });
|
||||||
|
}
|
||||||
|
const updatedCollection = await prisma.collection.findFirst({
|
||||||
|
where: { id, userId: req.user.id },
|
||||||
|
});
|
||||||
|
if (!updatedCollection) {
|
||||||
|
return res.status(404).json({ error: "Collection not found" });
|
||||||
|
}
|
||||||
return res.json(updatedCollection);
|
return res.json(updatedCollection);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -464,7 +516,7 @@ export const registerDashboardRoutes = (
|
|||||||
where: { collectionId: id, userId: req.user.id },
|
where: { collectionId: id, userId: req.user.id },
|
||||||
data: { collectionId: null },
|
data: { collectionId: null },
|
||||||
}),
|
}),
|
||||||
prisma.collection.delete({ where: { id } }),
|
prisma.collection.deleteMany({ where: { id, userId: req.user.id } }),
|
||||||
]);
|
]);
|
||||||
invalidateDrawingsCache();
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,21 @@ const makeUniqueName = (base: string, used: Set<string>): string => {
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findFirstDuplicate = (values: string[]): string | null => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
if (seen.has(value)) return value;
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNonEmptyId = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
const findSqliteTable = (tables: string[], candidates: string[]): string | null => {
|
const findSqliteTable = (tables: string[], candidates: string[]): string | null => {
|
||||||
const byLower = new Map(tables.map((t) => [t.toLowerCase(), t]));
|
const byLower = new Map(tables.map((t) => [t.toLowerCase(), t]));
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -439,6 +454,28 @@ Drawings: ${drawings.length}
|
|||||||
message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`,
|
message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id));
|
||||||
|
if (duplicateCollectionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate collection id in manifest: ${duplicateCollectionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id));
|
||||||
|
if (duplicateDrawingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath));
|
||||||
|
if (duplicateDrawingPath) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const drawing of manifest.drawings) {
|
for (const drawing of manifest.drawings) {
|
||||||
if (!getSafeZipEntry(zip, drawing.filePath)) {
|
if (!getSafeZipEntry(zip, drawing.filePath)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -532,6 +569,28 @@ Drawings: ${drawings.length}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id));
|
||||||
|
if (duplicateCollectionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate collection id in manifest: ${duplicateCollectionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id));
|
||||||
|
if (duplicateDrawingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath));
|
||||||
|
if (duplicateDrawingPath) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid backup manifest",
|
||||||
|
message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type PreparedImportDrawing = {
|
type PreparedImportDrawing = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -772,6 +831,31 @@ Drawings: ${drawings.length}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicateDrawingIdRow = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM "${drawingTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (duplicateDrawingIdRow?.id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate drawing id in legacy DB: ${String(duplicateDrawingIdRow.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (collectionTable) {
|
||||||
|
const duplicateCollectionIdRow = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM "${collectionTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (duplicateCollectionIdRow?.id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate collection id in legacy DB: ${String(duplicateCollectionIdRow.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let latestMigration: string | null = null;
|
let latestMigration: string | null = null;
|
||||||
const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]);
|
const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]);
|
||||||
if (migrationsTable) {
|
if (migrationsTable) {
|
||||||
@@ -862,6 +946,28 @@ Drawings: ${drawings.length}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importedCollectionIds = importedCollections
|
||||||
|
.map((c) => normalizeNonEmptyId(c?.id))
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
const duplicateCollectionId = findFirstDuplicate(importedCollectionIds);
|
||||||
|
if (duplicateCollectionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate collection id in legacy DB: ${duplicateCollectionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedDrawingIds = importedDrawings
|
||||||
|
.map((d) => normalizeNonEmptyId(d?.id))
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
const duplicateDrawingId = findFirstDuplicate(importedDrawingIds);
|
||||||
|
if (duplicateDrawingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid legacy DB",
|
||||||
|
message: `Duplicate drawing id in legacy DB: ${duplicateDrawingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type PreparedLegacyDrawing = {
|
type PreparedLegacyDrawing = {
|
||||||
importedId: string | null;
|
importedId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.3",
|
"version": "0.4.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 6767",
|
"dev": "vite --port 6767",
|
||||||
|
|||||||
@@ -340,8 +340,8 @@ export const createDrawing = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateDrawing = async (id: string, data: Partial<Drawing>) => {
|
export const updateDrawing = async (id: string, data: Partial<Drawing>) => {
|
||||||
const response = await api.put<{ success: true }>(`/drawings/${id}`, data);
|
const response = await api.put<Drawing>(`/drawings/${id}`, data);
|
||||||
return response.data;
|
return deserializeDrawing(response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteDrawing = async (id: string) => {
|
export const deleteDrawing = async (id: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./Sidebar", () => ({
|
||||||
|
Sidebar: () => <div data-testid="sidebar">sidebar</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./Logo", () => ({
|
||||||
|
Logo: () => <div data-testid="logo">logo</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./UploadStatus", () => ({
|
||||||
|
UploadStatus: () => <div data-testid="upload-status">upload-status</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Layout } from "./Layout";
|
||||||
|
|
||||||
|
describe("Layout", () => {
|
||||||
|
it("removes active resize listeners on unmount", () => {
|
||||||
|
const addSpy = vi.spyOn(document, "addEventListener");
|
||||||
|
const removeSpy = vi.spyOn(document, "removeEventListener");
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Layout
|
||||||
|
collections={[]}
|
||||||
|
selectedCollectionId={undefined}
|
||||||
|
onSelectCollection={() => {}}
|
||||||
|
onCreateCollection={() => {}}
|
||||||
|
onEditCollection={() => {}}
|
||||||
|
onDeleteCollection={() => {}}
|
||||||
|
>
|
||||||
|
<div>content</div>
|
||||||
|
</Layout>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByTitle("Drag to resize sidebar"));
|
||||||
|
|
||||||
|
const mouseMoveAdd = addSpy.mock.calls.find(([event]) => event === "mousemove");
|
||||||
|
const mouseUpAdd = addSpy.mock.calls.find(([event]) => event === "mouseup");
|
||||||
|
|
||||||
|
expect(mouseMoveAdd?.[1]).toBeTypeOf("function");
|
||||||
|
expect(mouseUpAdd?.[1]).toBeTypeOf("function");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
removeSpy.mock.calls.some(
|
||||||
|
([event, handler]) => event === "mousemove" && handler === mouseMoveAdd?.[1]
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
removeSpy.mock.calls.some(
|
||||||
|
([event, handler]) => event === "mouseup" && handler === mouseUpAdd?.[1]
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,8 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
const startXRef = useRef(0);
|
const startXRef = useRef(0);
|
||||||
const startWidthRef = useRef(0);
|
const startWidthRef = useRef(0);
|
||||||
|
const resizeMouseMoveHandlerRef = useRef<((e: MouseEvent) => void) | null>(null);
|
||||||
|
const resizeMouseUpHandlerRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// Handle mouse down on resize handle
|
// Handle mouse down on resize handle
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
@@ -44,6 +46,13 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
startXRef.current = e.clientX;
|
startXRef.current = e.clientX;
|
||||||
startWidthRef.current = sidebarWidth;
|
startWidthRef.current = sidebarWidth;
|
||||||
|
|
||||||
|
if (resizeMouseMoveHandlerRef.current) {
|
||||||
|
document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current);
|
||||||
|
}
|
||||||
|
if (resizeMouseUpHandlerRef.current) {
|
||||||
|
document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
const diff = e.clientX - startXRef.current;
|
const diff = e.clientX - startXRef.current;
|
||||||
const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff));
|
const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff));
|
||||||
@@ -54,8 +63,12 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
resizeMouseMoveHandlerRef.current = null;
|
||||||
|
resizeMouseUpHandlerRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
resizeMouseMoveHandlerRef.current = handleMouseMove;
|
||||||
|
resizeMouseUpHandlerRef.current = handleMouseUp;
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
@@ -63,8 +76,14 @@ export const Layout: React.FC<LayoutProps> = ({
|
|||||||
// Cleanup event listeners on unmount
|
// Cleanup event listeners on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', () => {});
|
if (resizeMouseMoveHandlerRef.current) {
|
||||||
document.removeEventListener('mouseup', () => {});
|
document.removeEventListener('mousemove', resizeMouseMoveHandlerRef.current);
|
||||||
|
resizeMouseMoveHandlerRef.current = null;
|
||||||
|
}
|
||||||
|
if (resizeMouseUpHandlerRef.current) {
|
||||||
|
document.removeEventListener('mouseup', resizeMouseUpHandlerRef.current);
|
||||||
|
resizeMouseUpHandlerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AuthProvider, useAuth } from "./AuthContext";
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
const { loading, authEnabled } = useAuth();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="loading">{String(loading)}</span>
|
||||||
|
<span data-testid="auth-enabled">{String(authEnabled)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AuthProvider", () => {
|
||||||
|
it("defaults to auth-enabled mode if /auth/status fails", async () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
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("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,12 +60,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If status fails (backend down / schema mismatch), avoid locking the UI
|
// If status fails, default to auth-enabled mode to avoid exposing
|
||||||
// behind login. Backend still enforces auth when enabled.
|
// single-user UI paths accidentally. Backend remains the source of truth.
|
||||||
setAuthEnabled(false);
|
setAuthEnabled(true);
|
||||||
setBootstrapRequired(false);
|
setBootstrapRequired(false);
|
||||||
setUser(null);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedUser = localStorage.getItem(USER_KEY);
|
const storedUser = localStorage.getItem(USER_KEY);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import clsx from 'clsx';
|
|||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
import { useUpload } from '../context/UploadContext';
|
import { useUpload } from '../context/UploadContext';
|
||||||
import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared';
|
import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared';
|
||||||
|
import { isLatestRequest, mergeUniqueDrawings } from './dashboard/pagination';
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
@@ -73,12 +74,14 @@ export const Dashboard: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const listRequestVersionRef = useRef(0);
|
||||||
|
|
||||||
const { uploadFiles } = useUpload();
|
const { uploadFiles } = useUpload();
|
||||||
|
|
||||||
const hasMore = drawings.length < totalCount;
|
const hasMore = drawings.length < totalCount;
|
||||||
|
|
||||||
const refreshData = useCallback(async () => {
|
const refreshData = useCallback(async () => {
|
||||||
|
const requestVersion = ++listRequestVersionRef.current;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [drawingsRes, collectionsData] = await Promise.all([
|
const [drawingsRes, collectionsData] = await Promise.all([
|
||||||
@@ -90,6 +93,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
api.getCollections()
|
api.getCollections()
|
||||||
]);
|
]);
|
||||||
|
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
||||||
setDrawings(drawingsRes.drawings);
|
setDrawings(drawingsRes.drawings);
|
||||||
setTotalCount(drawingsRes.totalCount);
|
setTotalCount(drawingsRes.totalCount);
|
||||||
setCollections(collectionsData);
|
setCollections(collectionsData);
|
||||||
@@ -97,12 +101,15 @@ export const Dashboard: React.FC = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch data:', err);
|
console.error('Failed to fetch data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (isLatestRequest(requestVersion, listRequestVersionRef.current)) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]);
|
}, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]);
|
||||||
|
|
||||||
const fetchMore = useCallback(async () => {
|
const fetchMore = useCallback(async () => {
|
||||||
if (isFetchingMore || !hasMore || isLoading) return;
|
if (isFetchingMore || !hasMore || isLoading) return;
|
||||||
|
const requestVersion = listRequestVersionRef.current;
|
||||||
setIsFetchingMore(true);
|
setIsFetchingMore(true);
|
||||||
try {
|
try {
|
||||||
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
||||||
@@ -111,7 +118,8 @@ export const Dashboard: React.FC = () => {
|
|||||||
sortField: sortConfig.field,
|
sortField: sortConfig.field,
|
||||||
sortDirection: sortConfig.direction,
|
sortDirection: sortConfig.direction,
|
||||||
});
|
});
|
||||||
setDrawings(prev => [...prev, ...drawingsRes.drawings]);
|
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
||||||
|
setDrawings(prev => mergeUniqueDrawings(prev, drawingsRes.drawings));
|
||||||
setTotalCount(drawingsRes.totalCount);
|
setTotalCount(drawingsRes.totalCount);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch more data:', err);
|
console.error('Failed to fetch more data:', err);
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
getColorFromString,
|
getColorFromString,
|
||||||
getFilesDelta,
|
getFilesDelta,
|
||||||
getInitialsFromName,
|
getInitialsFromName,
|
||||||
|
hasRenderableElements,
|
||||||
haveSameElements,
|
haveSameElements,
|
||||||
|
isSuspiciousEmptySnapshot,
|
||||||
} from './editor/shared';
|
} from './editor/shared';
|
||||||
import type { ElementVersionInfo } from './editor/shared';
|
import type { ElementVersionInfo } from './editor/shared';
|
||||||
|
|
||||||
@@ -124,7 +126,9 @@ export const Editor: React.FC = () => {
|
|||||||
const latestFilesRef = useRef<any>(null);
|
const latestFilesRef = useRef<any>(null);
|
||||||
const lastSyncedFilesRef = useRef<Record<string, any>>({});
|
const lastSyncedFilesRef = useRef<Record<string, any>>({});
|
||||||
const latestAppStateRef = useRef<any>(null);
|
const latestAppStateRef = useRef<any>(null);
|
||||||
const debouncedSaveRef = useRef<((elements: readonly any[], appState: any) => void) | null>(null);
|
const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => void) | null>(null);
|
||||||
|
const currentDrawingVersionRef = useRef<number | null>(null);
|
||||||
|
const lastPersistedElementsRef = useRef<readonly any[]>([]);
|
||||||
|
|
||||||
const emitFilesDeltaIfNeeded = useCallback(
|
const emitFilesDeltaIfNeeded = useCallback(
|
||||||
(nextFiles: Record<string, any>) => {
|
(nextFiles: Record<string, any>) => {
|
||||||
@@ -361,13 +365,13 @@ export const Editor: React.FC = () => {
|
|||||||
const didEmit = emitFilesDeltaIfNeeded(nextFiles);
|
const didEmit = emitFilesDeltaIfNeeded(nextFiles);
|
||||||
|
|
||||||
// Persist after file data becomes available so new tabs (tab3) load correctly.
|
// Persist after file data becomes available so new tabs (tab3) load correctly.
|
||||||
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||||
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current);
|
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
}, [emitFilesDeltaIfNeeded]);
|
}, [emitFilesDeltaIfNeeded, id]);
|
||||||
|
|
||||||
// Handle #addLibrary URL hash parameter for importing libraries from links
|
// Handle #addLibrary URL hash parameter for importing libraries from links
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -428,12 +432,12 @@ export const Editor: React.FC = () => {
|
|||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise<void>) | null>(null);
|
const saveDataRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => Promise<void>) | null>(null);
|
||||||
const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
|
const savePreviewRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
|
||||||
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
|
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
|
||||||
|
|
||||||
saveDataRef.current = async (elements: readonly any[], appState: any) => {
|
saveDataRef.current = async (drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => {
|
||||||
if (!id) return;
|
if (!drawingId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const persistableAppState = {
|
const persistableAppState = {
|
||||||
@@ -442,31 +446,45 @@ export const Editor: React.FC = () => {
|
|||||||
gridSize: appState?.gridSize || null,
|
gridSize: appState?.gridSize || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshot = latestElementsRef.current ?? elements ?? [];
|
const persistableElements = Array.isArray(elements) ? elements : [];
|
||||||
const persistableElements = Array.isArray(snapshot) ? snapshot : [];
|
if (isSuspiciousEmptySnapshot(lastPersistedElementsRef.current, persistableElements)) {
|
||||||
|
console.warn("[Editor] Skipping suspicious empty snapshot save", { drawingId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const persistableFiles = files ?? latestFilesRef.current ?? {};
|
||||||
|
|
||||||
console.log("[Editor] Saving drawing", {
|
console.log("[Editor] Saving drawing", {
|
||||||
drawingId: id,
|
drawingId,
|
||||||
elementCount: persistableElements.length,
|
elementCount: persistableElements.length,
|
||||||
hasRenderableElements: persistableElements.some((el: any) => !el?.isDeleted),
|
hasRenderableElements: hasRenderableElements(persistableElements),
|
||||||
appState: persistableAppState,
|
appState: persistableAppState,
|
||||||
});
|
});
|
||||||
|
|
||||||
await api.updateDrawing(id, {
|
const updated = await api.updateDrawing(drawingId, {
|
||||||
elements: persistableElements,
|
elements: persistableElements,
|
||||||
appState: persistableAppState,
|
appState: persistableAppState,
|
||||||
files: latestFilesRef.current || {},
|
files: persistableFiles,
|
||||||
|
version: currentDrawingVersionRef.current ?? undefined,
|
||||||
});
|
});
|
||||||
|
if (typeof updated.version === "number") {
|
||||||
|
currentDrawingVersionRef.current = updated.version;
|
||||||
|
}
|
||||||
|
lastPersistedElementsRef.current = persistableElements;
|
||||||
|
|
||||||
console.log("[Editor] Save complete", { drawingId: id });
|
console.log("[Editor] Save complete", { drawingId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (api.isAxiosError(err) && err.response?.status === 409) {
|
||||||
|
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
|
||||||
|
toast.error("Drawing changed in another tab. Refresh to load latest.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Failed to save drawing', err);
|
console.error('Failed to save drawing', err);
|
||||||
toast.error("Failed to save changes");
|
toast.error("Failed to save changes");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
savePreviewRef.current = async (elements: readonly any[], appState: any, files: any) => {
|
savePreviewRef.current = async (drawingId: string, elements: readonly any[], appState: any, files: any) => {
|
||||||
if (!id) return;
|
if (!drawingId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentSnapshot = latestElementsRef.current ?? elements;
|
const currentSnapshot = latestElementsRef.current ?? elements;
|
||||||
@@ -484,13 +502,13 @@ export const Editor: React.FC = () => {
|
|||||||
const preview = svg.outerHTML;
|
const preview = svg.outerHTML;
|
||||||
|
|
||||||
console.log("[Editor] Saving preview", {
|
console.log("[Editor] Saving preview", {
|
||||||
drawingId: id,
|
drawingId,
|
||||||
elementCount: currentSnapshot.length,
|
elementCount: currentSnapshot.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
await api.updateDrawing(id, { preview });
|
await api.updateDrawing(drawingId, { preview });
|
||||||
|
|
||||||
console.log("[Editor] Preview save complete", { drawingId: id });
|
console.log("[Editor] Preview save complete", { drawingId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save preview', err);
|
console.error('Failed to save preview', err);
|
||||||
}
|
}
|
||||||
@@ -509,9 +527,9 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
const debouncedSave = useCallback(
|
const debouncedSave = useCallback(
|
||||||
debounce((elements, appState) => {
|
debounce((drawingId, elements, appState, files) => {
|
||||||
if (saveDataRef.current) {
|
if (saveDataRef.current) {
|
||||||
saveDataRef.current(elements, appState);
|
saveDataRef.current(drawingId, elements, appState, files);
|
||||||
}
|
}
|
||||||
}, 1000),
|
}, 1000),
|
||||||
[] // Empty dependency array = Stable across renders
|
[] // Empty dependency array = Stable across renders
|
||||||
@@ -519,9 +537,9 @@ export const Editor: React.FC = () => {
|
|||||||
// Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves.
|
// Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves.
|
||||||
debouncedSaveRef.current = debouncedSave;
|
debouncedSaveRef.current = debouncedSave;
|
||||||
const debouncedSavePreview = useCallback(
|
const debouncedSavePreview = useCallback(
|
||||||
debounce((elements, appState, files) => {
|
debounce((drawingId, elements, appState, files) => {
|
||||||
if (savePreviewRef.current) {
|
if (savePreviewRef.current) {
|
||||||
savePreviewRef.current(elements, appState, files);
|
savePreviewRef.current(drawingId, elements, appState, files);
|
||||||
}
|
}
|
||||||
}, 10000),
|
}, 10000),
|
||||||
[]
|
[]
|
||||||
@@ -536,6 +554,13 @@ export const Editor: React.FC = () => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedSave.cancel();
|
||||||
|
debouncedSavePreview.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedSave, debouncedSavePreview]);
|
||||||
|
|
||||||
const broadcastChanges = useCallback(
|
const broadcastChanges = useCallback(
|
||||||
throttle((elements: readonly any[], currentFiles?: Record<string, any>) => {
|
throttle((elements: readonly any[], currentFiles?: Record<string, any>) => {
|
||||||
if (!socketRef.current || !id) return;
|
if (!socketRef.current || !id) return;
|
||||||
@@ -580,6 +605,8 @@ export const Editor: React.FC = () => {
|
|||||||
latestElementsRef.current = [];
|
latestElementsRef.current = [];
|
||||||
latestFilesRef.current = {};
|
latestFilesRef.current = {};
|
||||||
lastSyncedFilesRef.current = {};
|
lastSyncedFilesRef.current = {};
|
||||||
|
currentDrawingVersionRef.current = null;
|
||||||
|
lastPersistedElementsRef.current = [];
|
||||||
excalidrawAPI.current = null;
|
excalidrawAPI.current = null;
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setIsSceneLoading(true);
|
setIsSceneLoading(true);
|
||||||
@@ -607,6 +634,8 @@ export const Editor: React.FC = () => {
|
|||||||
latestElementsRef.current = elements;
|
latestElementsRef.current = elements;
|
||||||
latestFilesRef.current = files;
|
latestFilesRef.current = files;
|
||||||
lastSyncedFilesRef.current = files;
|
lastSyncedFilesRef.current = files;
|
||||||
|
currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null;
|
||||||
|
lastPersistedElementsRef.current = elements;
|
||||||
|
|
||||||
elements.forEach((el: any) => {
|
elements.forEach((el: any) => {
|
||||||
recordElementVersion(el);
|
recordElementVersion(el);
|
||||||
@@ -650,6 +679,8 @@ export const Editor: React.FC = () => {
|
|||||||
latestElementsRef.current = [];
|
latestElementsRef.current = [];
|
||||||
latestFilesRef.current = {};
|
latestFilesRef.current = {};
|
||||||
lastSyncedFilesRef.current = {};
|
lastSyncedFilesRef.current = {};
|
||||||
|
currentDrawingVersionRef.current = null;
|
||||||
|
lastPersistedElementsRef.current = [];
|
||||||
setLoadError(message);
|
setLoadError(message);
|
||||||
setInitialData(null);
|
setInitialData(null);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -670,8 +701,9 @@ export const Editor: React.FC = () => {
|
|||||||
const files = excalidrawAPI.current.getFiles() || {};
|
const files = excalidrawAPI.current.getFiles() || {};
|
||||||
latestElementsRef.current = elements;
|
latestElementsRef.current = elements;
|
||||||
latestFilesRef.current = files;
|
latestFilesRef.current = files;
|
||||||
await saveDataRef.current(elements, appState);
|
if (!id) return;
|
||||||
savePreviewRef.current(elements, appState, files);
|
await saveDataRef.current(id, elements, appState, files);
|
||||||
|
savePreviewRef.current(id, elements, appState, files);
|
||||||
toast.success("Saved changes to server");
|
toast.success("Saved changes to server");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -721,8 +753,8 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
latestElementsRef.current = allElements;
|
latestElementsRef.current = allElements;
|
||||||
|
|
||||||
const hasRenderableElements = allElements.some((el: any) => !el?.isDeleted);
|
const hasRenderable = hasRenderableElements(allElements);
|
||||||
if (isBootstrappingScene.current && !hasRenderableElements) {
|
if (isBootstrappingScene.current && !hasRenderable) {
|
||||||
console.log("[Editor] Bootstrapping guard active", {
|
console.log("[Editor] Bootstrapping guard active", {
|
||||||
drawingId: id,
|
drawingId: id,
|
||||||
elementCount: allElements.length,
|
elementCount: allElements.length,
|
||||||
@@ -733,23 +765,28 @@ export const Editor: React.FC = () => {
|
|||||||
// Trigger Sync (Throttled)
|
// Trigger Sync (Throttled)
|
||||||
broadcastChanges(allElements, currentFiles);
|
broadcastChanges(allElements, currentFiles);
|
||||||
|
|
||||||
|
const filesSnapshot = currentFiles;
|
||||||
|
latestFilesRef.current = filesSnapshot;
|
||||||
|
|
||||||
// Trigger Fast Save
|
// Trigger Fast Save
|
||||||
console.log("[Editor] Queueing save", {
|
console.log("[Editor] Queueing save", {
|
||||||
drawingId: id,
|
drawingId: id,
|
||||||
elementCount: allElements.length,
|
elementCount: allElements.length,
|
||||||
hasRenderableElements,
|
hasRenderableElements: hasRenderable,
|
||||||
});
|
});
|
||||||
debouncedSave(allElements, appState);
|
if (id) {
|
||||||
|
debouncedSave(id, allElements, appState, filesSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger Slow Preview Gen
|
// Trigger Slow Preview Gen
|
||||||
const filesSnapshot = currentFiles;
|
|
||||||
latestFilesRef.current = filesSnapshot;
|
|
||||||
console.log("[Editor] Queueing preview save", {
|
console.log("[Editor] Queueing preview save", {
|
||||||
drawingId: id,
|
drawingId: id,
|
||||||
fileCount: Object.keys(filesSnapshot).length,
|
fileCount: Object.keys(filesSnapshot).length,
|
||||||
});
|
});
|
||||||
debouncedSavePreview(allElements, appState, filesSnapshot);
|
if (id) {
|
||||||
}, [debouncedSave, debouncedSavePreview, broadcastChanges]);
|
debouncedSavePreview(id, allElements, appState, filesSnapshot);
|
||||||
|
}
|
||||||
|
}, [debouncedSave, debouncedSavePreview, broadcastChanges, id]);
|
||||||
|
|
||||||
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
||||||
// are still broadcast to collaborators AND persisted to the server.
|
// are still broadcast to collaborators AND persisted to the server.
|
||||||
@@ -767,7 +804,7 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Persist after file data becomes available (covers the "tab 3" case).
|
// Persist after file data becomes available (covers the "tab 3" case).
|
||||||
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||||
debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current);
|
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
@@ -803,6 +840,7 @@ export const Editor: React.FC = () => {
|
|||||||
// Save drawing and generate preview before navigating
|
// Save drawing and generate preview before navigating
|
||||||
try {
|
try {
|
||||||
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
|
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
|
||||||
|
if (!id) return;
|
||||||
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
||||||
const appState = excalidrawAPI.current.getAppState();
|
const appState = excalidrawAPI.current.getAppState();
|
||||||
const files = excalidrawAPI.current.getFiles() || {};
|
const files = excalidrawAPI.current.getFiles() || {};
|
||||||
@@ -810,8 +848,8 @@ export const Editor: React.FC = () => {
|
|||||||
latestFilesRef.current = files;
|
latestFilesRef.current = files;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
saveDataRef.current(elements, appState),
|
saveDataRef.current(id, elements, appState, files),
|
||||||
savePreviewRef.current(elements, appState, files)
|
savePreviewRef.current(id, elements, appState, files)
|
||||||
]);
|
]);
|
||||||
console.log("[Editor] Saved on back navigation", { drawingId: id });
|
console.log("[Editor] Saved on back navigation", { drawingId: id });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isLatestRequest, mergeUniqueDrawings } from "./pagination";
|
||||||
|
import type { DrawingSummary } from "../../types";
|
||||||
|
|
||||||
|
const drawing = (id: string, name = id): DrawingSummary => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
collectionId: null,
|
||||||
|
preview: null,
|
||||||
|
version: 1,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dashboard pagination helpers", () => {
|
||||||
|
it("accepts only latest request version", () => {
|
||||||
|
expect(isLatestRequest(3, 3)).toBe(true);
|
||||||
|
expect(isLatestRequest(2, 3)).toBe(false);
|
||||||
|
expect(isLatestRequest(4, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges pages without duplicating IDs", () => {
|
||||||
|
const existing = [drawing("a"), drawing("b")];
|
||||||
|
const incoming = [drawing("b", "b-new"), drawing("c")];
|
||||||
|
|
||||||
|
const merged = mergeUniqueDrawings(existing, incoming);
|
||||||
|
|
||||||
|
expect(merged.map((d) => d.id)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(merged[1].name).toBe("b");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { DrawingSummary } from "../../types";
|
||||||
|
|
||||||
|
export const isLatestRequest = (requestVersion: number, currentVersion: number): boolean =>
|
||||||
|
requestVersion === currentVersion;
|
||||||
|
|
||||||
|
export const mergeUniqueDrawings = (
|
||||||
|
existing: DrawingSummary[],
|
||||||
|
incoming: DrawingSummary[]
|
||||||
|
): DrawingSummary[] => {
|
||||||
|
const seen = new Set(existing.map((d) => d.id));
|
||||||
|
const nextPage = incoming.filter((d) => !seen.has(d.id));
|
||||||
|
return [...existing, ...nextPage];
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
hasRenderableElements,
|
||||||
|
isSuspiciousEmptySnapshot,
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
describe("editor/shared scene guards", () => {
|
||||||
|
it("detects renderable elements", () => {
|
||||||
|
expect(hasRenderableElements([{ id: "a", isDeleted: false }])).toBe(true);
|
||||||
|
expect(
|
||||||
|
hasRenderableElements([
|
||||||
|
{ id: "a", isDeleted: true },
|
||||||
|
{ id: "b", isDeleted: true },
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags empty snapshot after a previously non-empty persisted scene", () => {
|
||||||
|
const previous = [{ id: "a", isDeleted: false }];
|
||||||
|
expect(isSuspiciousEmptySnapshot(previous, [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag empty snapshot for already-empty drawings", () => {
|
||||||
|
expect(isSuspiciousEmptySnapshot([], [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag non-empty snapshots", () => {
|
||||||
|
const previous = [{ id: "a", isDeleted: false }];
|
||||||
|
const next = [{ id: "a", isDeleted: true }];
|
||||||
|
expect(isSuspiciousEmptySnapshot(previous, next)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,21 @@ export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = [])
|
|||||||
return true;
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
const buildFileSignature = (file: any): string => {
|
const buildFileSignature = (file: any): string => {
|
||||||
const mimeType = typeof file?.mimeType === "string" ? file.mimeType : "";
|
const mimeType = typeof file?.mimeType === "string" ? file.mimeType : "";
|
||||||
const id = typeof file?.id === "string" ? file.id : "";
|
const id = typeof file?.id === "string" ? file.id : "";
|
||||||
|
|||||||
Reference in New Issue
Block a user