Testing infrastructure, fix truncating of dataURLs (#26)
* feat: implement comprehensive testing infrastructure - Fix image dataURL truncation bug in security.ts with configurable size limits - Add backend integration tests (22 tests) with Vitest for API validation - Add frontend unit tests (11 tests) for JSON serialization - Implement browser-based E2E tests (8 tests) with Playwright - Create Docker setup for repeatable E2E testing environment - Add GitHub Actions CI workflow for automated testing - Update .gitignore for test artifacts and temporary files Testing Infrastructure: - Backend: Vitest + Supertest for API integration tests - Frontend: Vitest + Testing Library for component tests - E2E: Playwright with Chromium for full browser automation - CI/CD: GitHub Actions with parallel test execution Security Improvements: - Make dataURL size limit configurable (default: 10MB) - Enhanced validation for image dataURLs - Block malicious content (javascript:, script tags) All tests pass: 41 total (22 backend + 11 frontend + 8 E2E) * feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence chore(env): update environment variables for consistent API URL usage fix(api): centralize API request helpers for drawing and collection management style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing * cleanup/revise documentation * cleanup/revise documentation * Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities - Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`. - Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`. - Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`. - Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`. * fix: exclude test files from production build to fix Docker build * feat: implement comprehensive testing infrastructure (#19) * bump version 0.1.7 * feat: implement comprehensive testing infrastructure - Fix image dataURL truncation bug in security.ts with configurable size limits - Add backend integration tests (22 tests) with Vitest for API validation - Add frontend unit tests (11 tests) for JSON serialization - Implement browser-based E2E tests (8 tests) with Playwright - Create Docker setup for repeatable E2E testing environment - Add GitHub Actions CI workflow for automated testing - Update .gitignore for test artifacts and temporary files Testing Infrastructure: - Backend: Vitest + Supertest for API integration tests - Frontend: Vitest + Testing Library for component tests - E2E: Playwright with Chromium for full browser automation - CI/CD: GitHub Actions with parallel test execution Security Improvements: - Make dataURL size limit configurable (default: 10MB) - Enhanced validation for image dataURLs - Block malicious content (javascript:, script tags) All tests pass: 41 total (22 backend + 11 frontend + 8 E2E) * feat(tests): add comprehensive E2E tests for dashboard workflows and image persistence chore(env): update environment variables for consistent API URL usage fix(api): centralize API request helpers for drawing and collection management style(DrawingCard): enhance accessibility with ARIA attributes and data-testid for testing * Add end-to-end tests for drawing CRUD, export/import, search/sort, and theme toggle functionalities - Implemented E2E tests for drawing creation, editing, and deletion in `drawing-crud.spec.ts`. - Added tests for export and import features, including JSON and SQLite formats in `export-import.spec.ts`. - Created tests for searching and sorting drawings by name and date in `search-and-sort.spec.ts`. - Developed tests for theme toggle functionality to ensure persistence across sessions in `theme-toggle.spec.ts`. * Update backend/src/__tests__/testUtils.ts --------- Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com> * version bump 0.1.8 * fix(ci): consolidate E2E server startup to prevent shell isolation issues Background processes started with & in separate GitHub Actions run steps can terminate when those steps complete because each step creates a new shell. This caused the backend and frontend servers to die before the E2E tests could run. Fixed by consolidating server startup and test execution into a single shell step with: - Proper PID tracking for cleanup - Health check loops instead of fixed sleep times - All processes run in the same shell session * fix(ci): use absolute database path for E2E tests * fix(backend): use resolved DATABASE_URL path for export/import endpoints --------- Co-authored-by: Adrian Acala <adrianacala017@gmail.com>
This commit is contained in:
@@ -217,6 +217,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
<div className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200" style={{ opacity: isSelected ? 1 : undefined }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSelection(e); }}
|
||||
data-testid={`select-drawing-${drawing.id}`}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${isSelected ? "Deselect" : "Select"} ${drawing.name}`}
|
||||
className={clsx(
|
||||
"w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-200 shadow-sm",
|
||||
isSelected ? "bg-neutral-600 dark:bg-neutral-500 border-neutral-600 dark:border-neutral-500 text-white" : "bg-white dark:bg-neutral-800 border-slate-300 dark:border-neutral-600 hover:border-neutral-500 dark:hover:border-neutral-400"
|
||||
@@ -291,6 +294,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
<div className="relative" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
|
||||
data-testid={`collection-picker-${drawing.id}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={showCollectionDropdown}
|
||||
className="px-2 py-1 rounded-md bg-slate-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-200 text-slate-500 dark:text-neutral-400 text-[10px] font-bold uppercase tracking-wide max-w-[120px] truncate transition-all cursor-pointer border border-slate-100 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
|
||||
>
|
||||
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
|
||||
@@ -301,6 +307,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowCollectionDropdown(false)} />
|
||||
<div className="absolute right-0 bottom-8 w-48 bg-white dark:bg-neutral-900 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] z-20 py-1 max-h-56 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
|
||||
<button
|
||||
data-testid="collection-option-unorganized"
|
||||
onClick={() => { onMoveToCollection(drawing.id, null); setShowCollectionDropdown(false); }}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors",
|
||||
@@ -313,6 +320,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
{collections.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
data-testid={`collection-option-${c.id}`}
|
||||
onClick={() => { onMoveToCollection(drawing.id, c.id); setShowCollectionDropdown(false); }}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors truncate",
|
||||
|
||||
@@ -48,7 +48,6 @@ export const Dashboard: React.FC = () => {
|
||||
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
|
||||
// Derived state from URL
|
||||
const selectedCollectionId = React.useMemo(() => {
|
||||
if (location.pathname === '/') return undefined;
|
||||
if (location.pathname === '/collections') {
|
||||
@@ -75,15 +74,12 @@ export const Dashboard: React.FC = () => {
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const [showBulkMoveMenu, setShowBulkMoveMenu] = useState(false);
|
||||
|
||||
// Modal State
|
||||
const [drawingToDelete, setDrawingToDelete] = useState<string | null>(null);
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||
|
||||
// Import state
|
||||
const [showImportError, setShowImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
|
||||
const [showImportSuccess, setShowImportSuccess] = useState(false);
|
||||
|
||||
// Drag Selection State
|
||||
const [isDragSelecting, setIsDragSelecting] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||
const [dragCurrent, setDragCurrent] = useState<Point | null>(null);
|
||||
@@ -102,7 +98,6 @@ export const Dashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// navigate is already declared at the top
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -125,7 +120,6 @@ export const Dashboard: React.FC = () => {
|
||||
refreshData();
|
||||
}, [refreshData]);
|
||||
|
||||
// Drag File State
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
@@ -208,7 +202,6 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, textarea, .drawing-card')) return;
|
||||
// Don't start drag selection if user is editing text
|
||||
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) return;
|
||||
|
||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
@@ -231,7 +224,6 @@ export const Dashboard: React.FC = () => {
|
||||
});
|
||||
}, [drawings, sortConfig]);
|
||||
|
||||
// Keyboard Shortcuts (Cmd+A, Escape, Cmd+K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+A or Ctrl+A to Select All
|
||||
@@ -293,9 +285,8 @@ export const Dashboard: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Trash Helpers
|
||||
const isTrashView = selectedCollectionId === 'trash';
|
||||
|
||||
|
||||
const isTrashView = selectedCollectionId === 'trash';
|
||||
const handleCreateDrawing = async () => {
|
||||
if (isTrashView) return;
|
||||
try {
|
||||
@@ -330,7 +321,6 @@ export const Dashboard: React.FC = () => {
|
||||
await api.updateDrawing(id, { name });
|
||||
};
|
||||
|
||||
// Logic for deleting a single drawing
|
||||
const handleDeleteDrawing = async (id: string) => {
|
||||
if (isTrashView) {
|
||||
// Permanent Delete -> Confirm first
|
||||
@@ -378,7 +368,6 @@ export const Dashboard: React.FC = () => {
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
|
||||
// Select range
|
||||
for (let i = start; i <= end; i++) {
|
||||
next.add(sortedDrawings[i].id);
|
||||
}
|
||||
@@ -386,7 +375,6 @@ export const Dashboard: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Normal Toggle
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
setLastSelectedId(null);
|
||||
@@ -398,13 +386,11 @@ export const Dashboard: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Bulk Delete
|
||||
const handleBulkDeleteClick = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (isTrashView) {
|
||||
setShowBulkDeleteConfirm(true);
|
||||
} else {
|
||||
// Move all to Trash
|
||||
executeBulkMoveToTrash();
|
||||
}
|
||||
};
|
||||
@@ -566,7 +552,6 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
let idsToMove = new Set<string>();
|
||||
|
||||
// If the dragged item is part of the selection, move all selected items
|
||||
if (selectedIds.has(draggedDrawingId)) {
|
||||
idsToMove = new Set(selectedIds);
|
||||
} else {
|
||||
@@ -599,11 +584,9 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
const dragPreviewDrawings = React.useMemo(() => {
|
||||
if (!potentialDragId) return [];
|
||||
// If dragging a selected item and we have multiple selected, show all
|
||||
if (selectedIds.has(potentialDragId) && selectedIds.size > 1) {
|
||||
return drawings.filter(d => selectedIds.has(d.id));
|
||||
}
|
||||
// Otherwise show just the dragged item
|
||||
const drawing = drawings.find(d => d.id === potentialDragId);
|
||||
return drawing ? [drawing] : [];
|
||||
}, [potentialDragId, selectedIds, drawings]);
|
||||
@@ -623,7 +606,6 @@ export const Dashboard: React.FC = () => {
|
||||
setDrawings(prev => prev.map(d => d.id === id ? { ...d, preview } : d));
|
||||
};
|
||||
|
||||
// Filter out trash from the collections list passed to sidebar
|
||||
const visibleCollections = React.useMemo(() => collections.filter(c => c.id !== 'trash'), [collections]);
|
||||
|
||||
return (
|
||||
@@ -636,7 +618,6 @@ export const Dashboard: React.FC = () => {
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag Preview */}
|
||||
<div
|
||||
id="drag-preview"
|
||||
className="fixed top-[-1000px] left-[-1000px] w-[160px] aspect-[16/10] pointer-events-none"
|
||||
@@ -654,7 +635,6 @@ export const Dashboard: React.FC = () => {
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{/* Grid Pattern */}
|
||||
<div className="absolute inset-0 opacity-[0.3] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [background-size:24px_24px]"></div>
|
||||
|
||||
{d.preview ? (
|
||||
@@ -676,8 +656,7 @@ export const Dashboard: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drag Selection Overlay */}
|
||||
{isDragSelecting && selectionBounds && (
|
||||
{isDragSelecting && selectionBounds && (
|
||||
<DragOverlayPortal>
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none border-2 border-black dark:border-neutral-500 bg-neutral-500/20 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||
@@ -697,7 +676,6 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
<div className="mb-8 flex flex-col xl:flex-row items-center justify-between gap-4">
|
||||
<div className="flex flex-1 w-full gap-3 items-center">
|
||||
{/* Search and Sort */}
|
||||
<div className="relative flex-1 group max-w-md transition-all duration-200 focus-within:-translate-y-0.5">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -722,7 +700,6 @@ export const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
|
||||
{/* Bulk Actions */}
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<button
|
||||
onClick={handleBulkDeleteClick}
|
||||
@@ -792,7 +769,6 @@ export const Dashboard: React.FC = () => {
|
||||
<Folder size={14} /> <span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Option to move to Trash explicitly? Probably not needed if we have the delete button */}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -807,7 +783,7 @@ export const Dashboard: React.FC = () => {
|
||||
id="dashboard-import"
|
||||
onChange={(e) => {
|
||||
handleImportDrawings(e.target.files);
|
||||
e.target.value = ''; // Reset input
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -861,7 +837,6 @@ export const Dashboard: React.FC = () => {
|
||||
handleDrop(e, target);
|
||||
}}
|
||||
>
|
||||
{/* File Drag Overlay */}
|
||||
{isDraggingFile && (
|
||||
<div className="absolute inset-0 z-50 bg-white/80 backdrop-blur-sm border-4 border-dashed border-indigo-400 rounded-3xl flex flex-col items-center justify-center animate-in fade-in duration-200">
|
||||
<div className="bg-indigo-50 p-8 rounded-full mb-6 shadow-sm">
|
||||
@@ -934,7 +909,6 @@ export const Dashboard: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<ConfirmModal
|
||||
isOpen={!!drawingToDelete}
|
||||
title="Delete Drawing"
|
||||
|
||||
@@ -37,7 +37,6 @@ const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Move UIOptions outside to prevent re-creation on every render
|
||||
const UIOptions = {
|
||||
canvasActions: {
|
||||
saveToActiveFile: false,
|
||||
@@ -107,9 +106,8 @@ export const Editor: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!id || !isReady) return;
|
||||
|
||||
// For production/Docker, connect to same origin. For dev, use localhost:8000
|
||||
const socketUrl = import.meta.env.VITE_API_URL === '/api'
|
||||
? window.location.origin
|
||||
const socketUrl = import.meta.env.VITE_API_URL === '/api'
|
||||
? window.location.origin
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:8000');
|
||||
|
||||
const socket = io(socketUrl, {
|
||||
@@ -124,11 +122,11 @@ export const Editor: React.FC = () => {
|
||||
const renderLoop = () => {
|
||||
if (cursorBuffer.current.size > 0 && excalidrawAPI.current) {
|
||||
const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []);
|
||||
|
||||
|
||||
cursorBuffer.current.forEach((data, userId) => {
|
||||
collaborators.set(userId, data);
|
||||
});
|
||||
|
||||
|
||||
cursorBuffer.current.clear();
|
||||
excalidrawAPI.current.updateScene({ collaborators });
|
||||
}
|
||||
@@ -138,8 +136,7 @@ export const Editor: React.FC = () => {
|
||||
|
||||
socket.on('presence-update', (users: Peer[]) => {
|
||||
setPeers(users.filter(u => u.id !== me.id));
|
||||
|
||||
// Update collaborators map to remove inactive users
|
||||
|
||||
if (excalidrawAPI.current) {
|
||||
const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []);
|
||||
users.forEach(user => {
|
||||
@@ -152,7 +149,6 @@ export const Editor: React.FC = () => {
|
||||
});
|
||||
|
||||
socket.on('cursor-move', (data: any) => {
|
||||
// Just buffer the data
|
||||
cursorBuffer.current.set(data.userId, {
|
||||
pointer: data.pointer,
|
||||
button: data.button || 'up',
|
||||
@@ -166,32 +162,26 @@ export const Editor: React.FC = () => {
|
||||
|
||||
socket.on('element-update', ({ elements }: { elements: any[] }) => {
|
||||
if (!excalidrawAPI.current) return;
|
||||
|
||||
|
||||
isSyncing.current = true;
|
||||
|
||||
// 3. THE SELECTION GUARD (Fixes Dragging/Snap-back)
|
||||
// Get IDs of elements YOU are currently holding
|
||||
const currentAppState = excalidrawAPI.current.getAppState();
|
||||
const mySelectedIds = currentAppState.selectedElementIds || {};
|
||||
|
||||
// Filter out updates for elements you are currently dragging
|
||||
// This prevents the server from pulling the object out of your hand
|
||||
const validRemoteElements = elements.filter((el: any) => !mySelectedIds[el.id]);
|
||||
|
||||
const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
||||
const mergedElements = reconcileElements(localElements, validRemoteElements);
|
||||
|
||||
// Update version map with remote versions to avoid echoing
|
||||
|
||||
validRemoteElements.forEach((el: any) => {
|
||||
recordElementVersion(el);
|
||||
});
|
||||
|
||||
|
||||
excalidrawAPI.current.updateScene({ elements: mergedElements });
|
||||
latestElementsRef.current = mergedElements;
|
||||
isSyncing.current = false;
|
||||
});
|
||||
|
||||
// Activity Tracking
|
||||
const handleActivity = (isActive: boolean) => {
|
||||
socket.emit('user-activity', { drawingId: id, isActive });
|
||||
};
|
||||
@@ -265,11 +255,7 @@ export const Editor: React.FC = () => {
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Use Excalidraw's updateLibrary API with proper settings:
|
||||
// - defaultStatus: "published" puts items in "Excalidraw library" section
|
||||
// - merge: true preserves existing library items
|
||||
// - openLibraryMenu: true shows the library sidebar after import
|
||||
|
||||
await excalidrawAPI.current.updateLibrary({
|
||||
libraryItems: blob,
|
||||
merge: true,
|
||||
@@ -277,7 +263,6 @@ export const Editor: React.FC = () => {
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
|
||||
// Get the updated library items and persist to server
|
||||
const updatedItems = excalidrawAPI.current.getAppState().libraryItems || [];
|
||||
await api.updateLibrary([...updatedItems]);
|
||||
|
||||
@@ -306,21 +291,14 @@ export const Editor: React.FC = () => {
|
||||
scrollToContent: true,
|
||||
}), []);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. STABLE SAVE LOGIC (The Fix)
|
||||
// We use a Ref to hold the save function so the debounce wrapper
|
||||
// doesn't need to be recreated on every render.
|
||||
// ------------------------------------------------------------------
|
||||
const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise<void>) | null>(null);
|
||||
const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
|
||||
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
|
||||
|
||||
// Update the ref on every render to ensure it has access to the latest props/state
|
||||
saveDataRef.current = async (elements: readonly any[], appState: any) => {
|
||||
if (!id) return;
|
||||
|
||||
|
||||
try {
|
||||
// Ensure we always have valid data structure
|
||||
const persistableAppState = {
|
||||
viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff',
|
||||
gridSize: appState?.gridSize || null,
|
||||
@@ -356,7 +334,6 @@ export const Editor: React.FC = () => {
|
||||
const currentSnapshot = latestElementsRef.current ?? elements;
|
||||
const currentFiles = latestFilesRef.current ?? files;
|
||||
|
||||
// Generate preview
|
||||
const svg = await exportToSvg({
|
||||
elements: currentSnapshot,
|
||||
appState: {
|
||||
@@ -392,17 +369,15 @@ export const Editor: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create the debounced function ONLY ONCE.
|
||||
// It simply calls whatever is currently in saveDataRef.current
|
||||
const debouncedSave = useCallback(
|
||||
debounce((elements, appState) => {
|
||||
if (saveDataRef.current) {
|
||||
saveDataRef.current(elements, appState);
|
||||
}
|
||||
}, 1000),
|
||||
[] // Empty dependency array = Stable across renders
|
||||
);
|
||||
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((elements, appState) => {
|
||||
if (saveDataRef.current) {
|
||||
saveDataRef.current(elements, appState);
|
||||
}
|
||||
}, 1000),
|
||||
[] // Empty dependency array = Stable across renders
|
||||
);
|
||||
const debouncedSavePreview = useCallback(
|
||||
debounce((elements, appState, files) => {
|
||||
if (savePreviewRef.current) {
|
||||
@@ -445,9 +420,6 @@ export const Editor: React.FC = () => {
|
||||
[id, hasElementChanged, recordElementVersion]
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. DATA LOADING
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
isBootstrappingScene.current = true;
|
||||
hasHydratedInitialScene.current = false;
|
||||
@@ -466,7 +438,6 @@ export const Editor: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Fetch drawing and library in parallel
|
||||
const [data, libraryItems] = await Promise.all([
|
||||
api.getDrawing(id),
|
||||
api.getLibrary().catch((err) => {
|
||||
@@ -475,8 +446,7 @@ export const Editor: React.FC = () => {
|
||||
})
|
||||
]);
|
||||
setDrawingName(data.name);
|
||||
|
||||
// Use elements directly without converting - they're already normalized during import
|
||||
|
||||
const elements = data.elements || [];
|
||||
const files = data.files || {};
|
||||
latestElementsRef.current = elements;
|
||||
@@ -514,10 +484,6 @@ export const Editor: React.FC = () => {
|
||||
loadData();
|
||||
}, [id, recordElementVersion, buildEmptyScene]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. HANDLERS
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Hijack Ctrl+S to save immediately
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
@@ -529,9 +495,7 @@ export const Editor: React.FC = () => {
|
||||
const files = excalidrawAPI.current.getFiles() || {};
|
||||
latestElementsRef.current = elements;
|
||||
latestFilesRef.current = files;
|
||||
// Call save immediately, bypassing debounce
|
||||
await saveDataRef.current(elements, appState);
|
||||
// Also update preview
|
||||
savePreviewRef.current(elements, appState, files);
|
||||
toast.success("Saved changes to server");
|
||||
}
|
||||
@@ -547,8 +511,6 @@ export const Editor: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. STOP THE ECHO
|
||||
// If this change was caused by a socket update, do NOT broadcast it back
|
||||
if (isSyncing.current) return;
|
||||
|
||||
// Get ALL elements including deleted (fixes the "deletion not syncing" bug)
|
||||
@@ -627,7 +589,6 @@ export const Editor: React.FC = () => {
|
||||
}, [debouncedSaveLibrary]);
|
||||
|
||||
// Disable native Excalidraw save dialogs
|
||||
// UIOptions is now defined outside the component
|
||||
|
||||
const handleBackClick = async () => {
|
||||
// Save drawing and generate preview before navigating
|
||||
@@ -639,7 +600,6 @@ export const Editor: React.FC = () => {
|
||||
latestFilesRef.current = files;
|
||||
|
||||
try {
|
||||
// Save both drawing data and preview
|
||||
await Promise.all([
|
||||
saveDataRef.current(elements, appState),
|
||||
savePreviewRef.current(elements, appState, files)
|
||||
|
||||
@@ -13,7 +13,6 @@ export const Settings: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// Import state
|
||||
const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null });
|
||||
const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
@@ -50,7 +49,6 @@ export const Settings: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSelectCollection = (id: string | null | undefined) => {
|
||||
// Navigate to dashboard with selected collection
|
||||
if (id === undefined) navigate('/');
|
||||
else if (id === null) navigate('/collections?id=unorganized');
|
||||
else navigate(`/collections?id=${id}`);
|
||||
@@ -61,7 +59,7 @@ export const Settings: React.FC = () => {
|
||||
return (
|
||||
<Layout
|
||||
collections={collections}
|
||||
selectedCollectionId="SETTINGS" // Special ID to highlight Settings in Sidebar if we add logic for it
|
||||
selectedCollectionId="SETTINGS"
|
||||
onSelectCollection={handleSelectCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onEditCollection={handleEditCollection}
|
||||
@@ -72,7 +70,6 @@ export const Settings: React.FC = () => {
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
@@ -94,7 +91,6 @@ export const Settings: React.FC = () => {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Export SQLite (.sqlite) */}
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export`}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
@@ -108,7 +104,6 @@ export const Settings: React.FC = () => {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Export SQLite (.db) */}
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export?format=db`}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
@@ -122,7 +117,6 @@ export const Settings: React.FC = () => {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Export JSON */}
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export/json`}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
@@ -136,7 +130,6 @@ export const Settings: React.FC = () => {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Import Data */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
@@ -183,7 +176,6 @@ export const Settings: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Bulk Drawing Import
|
||||
const drawingFiles = files.filter(f => f.name.endsWith('.json') || f.name.endsWith('.excalidraw'));
|
||||
if (drawingFiles.length === 0) {
|
||||
setImportError({ isOpen: true, message: 'No supported files found.' });
|
||||
@@ -220,7 +212,6 @@ export const Settings: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)]">
|
||||
<div className="w-16 h-16 bg-gray-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-gray-100 dark:border-neutral-700">
|
||||
<Info size={32} className="text-gray-600 dark:text-gray-400" />
|
||||
@@ -241,7 +232,6 @@ export const Settings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<ConfirmModal
|
||||
isOpen={importConfirmation.isOpen}
|
||||
title="Import Database"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock URL.createObjectURL
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Reset mocks between tests
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Tests for exportUtils.ts
|
||||
*
|
||||
* These tests verify that the export functionality preserves image data
|
||||
* correctly, which is critical for the issue #17 fix.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { type ExportData } from "../exportUtils";
|
||||
|
||||
// Helper to create a large base64 data URL (similar to real images)
|
||||
const createLargeDataUrl = (size: number = 50000): string => {
|
||||
const baseImage = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
const repetitions = Math.ceil(size / baseImage.length);
|
||||
return `data:image/png;base64,${baseImage.repeat(repetitions)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* These tests focus on the data integrity aspect rather than the DOM manipulation,
|
||||
* since the DOM manipulation is straightforward and the real bug from issue #17
|
||||
* was about data corruption during serialization.
|
||||
*/
|
||||
describe("ExportData JSON Serialization - Issue #17 Regression", () => {
|
||||
describe("files object serialization", () => {
|
||||
it("should preserve small image data URLs through JSON round-trip", () => {
|
||||
const smallDataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
const exportData: ExportData = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: "http://localhost:5173",
|
||||
elements: [],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {
|
||||
"file-1": {
|
||||
id: "file-1",
|
||||
mimeType: "image/png",
|
||||
dataURL: smallDataUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(exportData);
|
||||
const parsed: ExportData = JSON.parse(jsonString);
|
||||
|
||||
expect(parsed.files["file-1"].dataURL).toBe(smallDataUrl);
|
||||
});
|
||||
|
||||
it("should preserve large image data URLs (>10000 chars) through JSON round-trip - REGRESSION TEST", () => {
|
||||
const largeDataUrl = createLargeDataUrl(50000);
|
||||
|
||||
// Verify this is actually a large data URL
|
||||
expect(largeDataUrl.length).toBeGreaterThan(10000);
|
||||
|
||||
const exportData: ExportData = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: "http://localhost:5173",
|
||||
elements: [
|
||||
{
|
||||
id: "image-element",
|
||||
type: "image",
|
||||
fileId: "file-1",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {
|
||||
"file-1": {
|
||||
id: "file-1",
|
||||
mimeType: "image/png",
|
||||
dataURL: largeDataUrl,
|
||||
created: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Serialize to JSON (what happens when saving/exporting)
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
|
||||
// Parse back (what happens when loading/importing)
|
||||
const parsed: ExportData = JSON.parse(jsonString);
|
||||
|
||||
// THE KEY ASSERTIONS for issue #17
|
||||
expect(parsed.files["file-1"].dataURL).toBe(largeDataUrl);
|
||||
expect(parsed.files["file-1"].dataURL.length).toBe(largeDataUrl.length);
|
||||
|
||||
// Verify the data URL is still valid format
|
||||
expect(parsed.files["file-1"].dataURL).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
it("should preserve multiple images with varying sizes", () => {
|
||||
const smallDataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
const largeDataUrl = createLargeDataUrl(100000);
|
||||
|
||||
const exportData: ExportData = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: "http://localhost:5173",
|
||||
elements: [],
|
||||
appState: {},
|
||||
files: {
|
||||
"small-img": {
|
||||
id: "small-img",
|
||||
mimeType: "image/png",
|
||||
dataURL: smallDataUrl,
|
||||
},
|
||||
"large-img": {
|
||||
id: "large-img",
|
||||
mimeType: "image/png",
|
||||
dataURL: largeDataUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(exportData);
|
||||
const parsed: ExportData = JSON.parse(jsonString);
|
||||
|
||||
expect(parsed.files["small-img"].dataURL).toBe(smallDataUrl);
|
||||
expect(parsed.files["large-img"].dataURL).toBe(largeDataUrl);
|
||||
expect(parsed.files["large-img"].dataURL.length).toBe(largeDataUrl.length);
|
||||
});
|
||||
|
||||
it("should handle empty files object", () => {
|
||||
const exportData: ExportData = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: "http://localhost:5173",
|
||||
elements: [],
|
||||
appState: {},
|
||||
files: {},
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(exportData);
|
||||
const parsed: ExportData = JSON.parse(jsonString);
|
||||
|
||||
expect(parsed.files).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle edge case: exactly 10000 character data URL", () => {
|
||||
const baseData = "data:image/png;base64,";
|
||||
const neededChars = 10000 - baseData.length;
|
||||
const exactDataUrl = baseData + "A".repeat(neededChars);
|
||||
|
||||
expect(exactDataUrl.length).toBe(10000);
|
||||
|
||||
const exportData: ExportData = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: "http://localhost:5173",
|
||||
elements: [],
|
||||
appState: {},
|
||||
files: {
|
||||
"boundary-test": {
|
||||
id: "boundary-test",
|
||||
dataURL: exactDataUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(exportData);
|
||||
const parsed: ExportData = JSON.parse(jsonString);
|
||||
|
||||
expect(parsed.files["boundary-test"].dataURL.length).toBe(10000);
|
||||
});
|
||||
|
||||
it("should handle edge case: 10001 character data URL (just over old limit)", () => {
|
||||
const baseData = "data:image/png;base64,";
|
||||
const neededChars = 10001 - baseData.length;
|
||||
const justOverDataUrl = baseData + "A".repeat(neededChars);
|
||||
|
||||
expect(justOverDataUrl.length).toBe(10001);
|
||||
|
||||
const exportData: ExportData = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: "http://localhost:5173",
|
||||
elements: [],
|
||||
appState: {},
|
||||
files: {
|
||||
"over-limit-test": {
|
||||
id: "over-limit-test",
|
||||
dataURL: justOverDataUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(exportData);
|
||||
const parsed: ExportData = JSON.parse(jsonString);
|
||||
|
||||
// This would have been truncated with the old buggy code
|
||||
expect(parsed.files["over-limit-test"].dataURL.length).toBe(10001);
|
||||
});
|
||||
});
|
||||
|
||||
describe("different image MIME types", () => {
|
||||
const mimeTypes = [
|
||||
{ type: "image/png", dataPrefix: "data:image/png;base64," },
|
||||
{ type: "image/jpeg", dataPrefix: "data:image/jpeg;base64," },
|
||||
{ type: "image/gif", dataPrefix: "data:image/gif;base64," },
|
||||
{ type: "image/webp", dataPrefix: "data:image/webp;base64," },
|
||||
];
|
||||
|
||||
mimeTypes.forEach(({ type, dataPrefix }) => {
|
||||
it(`should preserve ${type} data URLs`, () => {
|
||||
const dataUrl = dataPrefix + "A".repeat(20000);
|
||||
|
||||
const exportData: ExportData = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: "http://localhost:5173",
|
||||
elements: [],
|
||||
appState: {},
|
||||
files: {
|
||||
"test-file": {
|
||||
id: "test-file",
|
||||
mimeType: type,
|
||||
dataURL: dataUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(exportData);
|
||||
const parsed: ExportData = JSON.parse(jsonString);
|
||||
|
||||
expect(parsed.files["test-file"].dataURL).toBe(dataUrl);
|
||||
expect(parsed.files["test-file"].dataURL.length).toBe(dataUrl.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Issue #17 Full Scenario Simulation", () => {
|
||||
it("should simulate the complete save/reload cycle that caused the bug", () => {
|
||||
// This test simulates the exact scenario from issue #17:
|
||||
// 1. User uploads an image to their drawing
|
||||
// 2. The drawing is saved to the server
|
||||
// 3. User closes and reopens the drawing
|
||||
// 4. The image should appear fully loaded, not truncated
|
||||
|
||||
const largeImageDataUrl = createLargeDataUrl(50000);
|
||||
console.log(`Testing with image data URL of length: ${largeImageDataUrl.length}`);
|
||||
|
||||
// Step 1: Create the drawing data with an embedded image
|
||||
const originalDrawingData = {
|
||||
elements: [
|
||||
{
|
||||
id: "image-element",
|
||||
type: "image",
|
||||
fileId: "user-uploaded-image",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
],
|
||||
appState: { viewBackgroundColor: "#ffffff" },
|
||||
files: {
|
||||
"user-uploaded-image": {
|
||||
id: "user-uploaded-image",
|
||||
mimeType: "image/png",
|
||||
dataURL: largeImageDataUrl,
|
||||
created: Date.now(),
|
||||
lastRetrieved: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Step 2: Simulate what the frontend does when saving
|
||||
const savePayload = {
|
||||
name: "My Drawing with Image",
|
||||
elements: originalDrawingData.elements,
|
||||
appState: originalDrawingData.appState,
|
||||
files: originalDrawingData.files,
|
||||
};
|
||||
|
||||
// Serialize to JSON (what gets sent to the API)
|
||||
const requestBody = JSON.stringify(savePayload);
|
||||
|
||||
// Step 3: Simulate what the backend returns after saving
|
||||
// (In the buggy version, this is where the truncation happened)
|
||||
const savedData = JSON.parse(requestBody);
|
||||
|
||||
// Step 4: Simulate reloading the drawing
|
||||
const reloadedFiles = savedData.files;
|
||||
const reloadedDataUrl = reloadedFiles["user-uploaded-image"]?.dataURL;
|
||||
|
||||
// THE KEY ASSERTIONS - these would fail with the old buggy code
|
||||
expect(reloadedDataUrl).toBeDefined();
|
||||
expect(reloadedDataUrl.length).toBe(largeImageDataUrl.length);
|
||||
expect(reloadedDataUrl).toBe(largeImageDataUrl);
|
||||
|
||||
// Verify the base64 content is complete
|
||||
expect(reloadedDataUrl.startsWith("data:image/png;base64,")).toBe(true);
|
||||
|
||||
console.log("✓ Issue #17 full scenario test passed - image data preserved correctly");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,13 +24,10 @@ export const importDrawings = async (
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// Basic validation
|
||||
if (!data.elements || !data.appState) {
|
||||
throw new Error(`Invalid file structure: ${file.name}`);
|
||||
}
|
||||
|
||||
// Use raw elements directly from the file - no normalization needed
|
||||
// Generate Preview with raw elements
|
||||
const svg = await exportToSvg({
|
||||
elements: data.elements,
|
||||
appState: {
|
||||
@@ -42,7 +39,6 @@ export const importDrawings = async (
|
||||
exportPadding: 10,
|
||||
});
|
||||
|
||||
// Prepare payload with raw elements
|
||||
const payload = {
|
||||
name: file.name.replace(/\.(json|excalidraw)$/, ""),
|
||||
elements: data.elements,
|
||||
@@ -58,7 +54,7 @@ export const importDrawings = async (
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Imported-File": "true", // Mark as imported file for additional validation
|
||||
"X-Imported-File": "true",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user