Compare commits

..

2 Commits

Author SHA1 Message Date
Tomas Dvorak 910546230d refactor(editor): improve type safety and optimize build chunks
Docker Images / Build and push (push) Failing after 21s
Refactor the Editor component to replace `any` types with explicit interfaces for Excalidraw props, library items, and elements, improving type safety and developer experience.

Additionally, update the Vite configuration to implement manual chunking for large dependencies like Excalidraw, React, and Zustand to optimize bundle loading and improve build performance.
2026-05-09 19:27:36 +02:00
Tomas Dvorak 190be65e4f feat(ui): implement folder management and enhance editor functionality
Implements full folder CRUD operations in the file browser, including
renaming, deleting, and drag-and-drop reordering. Enhances the editor
with improved autosave logic and new template role toggling.

- Add folder management (create, update, delete, reorder) to API and UI
- Implement drag-and-drop functionality for folders in FileBrowser
- Add folder context menus and improved styling for Editor and FileBrowser
- Optimize editor autosave to only trigger on actual data changes
- Add support for 'correct-incorrect' template roles in the editor
2026-05-09 18:54:57 +02:00
7 changed files with 828 additions and 114 deletions
+78 -6
View File
@@ -38,6 +38,13 @@
gap: var(--space-2);
}
.toolbarDivider {
width: 1px;
height: 24px;
background: var(--default-border-color);
margin: 0 var(--space-2);
}
.title {
font-weight: 500;
color: var(--color-gray-85);
@@ -402,11 +409,19 @@
.presentationOverlay {
position: fixed;
top: 12px;
right: 12px;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
pointer-events: auto;
animation: presentationFadeIn 0.3s var(--ease-out);
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8);
}
@keyframes presentationFadeIn {
@@ -418,11 +433,12 @@
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--island-bg-color);
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-xl);
padding: var(--space-3) var(--space-5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
margin-bottom: var(--space-4);
}
.presentationLabel {
@@ -630,3 +646,59 @@
z-index: 80;
}
}
// Excalidraw context menu styling
:global(.context-menu),
:global(.excalidraw-context-menu) {
background: var(--island-bg-color) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-xl) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
padding: var(--space-1) !important;
.context-menu-item,
.menu-item {
border-radius: var(--border-radius-lg) !important;
padding: var(--space-2) var(--space-3) !important;
margin: 2px 0 !important;
font-size: var(--text-sm) !important;
&:hover {
background: var(--color-surface-low) !important;
}
&:first-child {
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0 !important;
}
&:last-child {
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg) !important;
}
}
.context-menu-separator,
.menu-item-separator {
background: var(--default-border-color) !important;
margin: var(--space-1) var(--space-2) !important;
}
}
// Excalidraw dropdown menus
:global(.dropdown-menu),
:global(.excalidraw-dropdown) {
background: var(--island-bg-color) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-xl) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
padding: var(--space-1) !important;
.dropdown-menu-item,
.menu-item {
border-radius: var(--border-radius-lg) !important;
padding: var(--space-2) var(--space-3) !important;
&:hover {
background: var(--color-surface-low) !important;
}
}
}
+462 -95
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, ChevronLeft, StickyNote, LayoutTemplate, MonitorPlay, X, Plus, Frame } from 'lucide-react';
import { ArrowLeft, Check, Loader2, History, ChevronRight, ChevronLeft, StickyNote, LayoutTemplate, MonitorPlay, X, Plus, Frame } from 'lucide-react';
import { Button } from '@/components';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
@@ -13,7 +13,7 @@ import styles from './Editor.module.scss';
const ExcalidrawWithLibrary = React.lazy(() =>
import('@excalidraw/excalidraw').then((mod) => {
const { Excalidraw } = mod;
const ExcalidrawWrapper: React.FC<any> = (props) => {
const ExcalidrawWrapper: React.FC<ExcalidrawProps> = (props) => {
return <Excalidraw {...props} />;
};
return { default: ExcalidrawWrapper };
@@ -25,6 +25,38 @@ import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excal
type LooseElement = Record<string, unknown>;
// Type definitions for Excalidraw components
interface ExcalidrawProps {
[key: string]: unknown;
}
interface LibraryItem {
id: string;
elements: ExcalidrawElement[];
status: string;
}
interface ArrowElement {
type: 'arrow';
points: number[][];
}
interface Slide {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
name: string;
isDeleted: boolean;
}
interface AppStateWithScroll {
scrollX?: number;
scrollY?: number;
}
interface EditorState {
elements: ExcalidrawElement[];
appState: Record<string, unknown>;
@@ -112,6 +144,7 @@ export const Editor: React.FC = () => {
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
const [slideIndex, setSlideIndex] = useState(0);
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
const [notEndingArrow, setNotEndingArrow] = useState(false);
// Load drawing data
useEffect(() => {
@@ -200,11 +233,15 @@ export const Editor: React.FC = () => {
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
saveDrawingRef.current();
}, 2000);
// Only mark as unsaved if the data actually differs from last saved
const currentJson = JSON.stringify(currentStateRef.current);
if (currentJson !== lastSavedDataRef.current) {
setSaveStatus('unsaved');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
saveDrawingRef.current();
}, 2000);
}
return;
}
@@ -255,21 +292,133 @@ export const Editor: React.FC = () => {
lastToggledCheckboxRef.current = null;
}
// Handle "+" add button click
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
// Handle correct/incorrect toggle (cycles: empty → correct → incorrect → empty)
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'correct-incorrect') {
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
lastToggledCheckboxRef.current = selectedEl.id;
const currentStatus = ((selectedEl.customData as Record<string, unknown> | undefined)?.status as string) || 'empty';
let nextStatus: string;
let nextColor: string;
let nextFill: 'solid' | 'hachure';
if (currentStatus === 'empty') {
nextStatus = 'correct';
nextColor = '#22c55e';
nextFill = 'solid';
} else if (currentStatus === 'correct') {
nextStatus = 'incorrect';
nextColor = '#ef4444';
nextFill = 'solid';
} else {
nextStatus = 'empty';
nextColor = '#1e1e1e';
nextFill = 'hachure';
}
const nextElements = elements.map((el) =>
el.id === selectedEl.id
? {
...el,
backgroundColor: nextStatus === 'empty' ? 'transparent' : nextColor,
fillStyle: nextFill,
customData: {
...((el.customData as Record<string, unknown> | undefined) || {}),
status: nextStatus,
},
version: el.version + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}
: el
);
const nextEls = nextElements;
const nextAppState = appStateWithoutGrid(appState);
const nextFiles = files;
isMutatingSceneRef.current = true;
setTimeout(() => {
excalidrawAPI.updateScene({ elements: nextEls as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
currentStateRef.current = {
elements: nextEls,
appState: nextAppState,
files: nextFiles,
};
setSaveStatus('unsaved');
return;
}
}
// Handle star rating toggle
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'star-rating') {
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
lastToggledCheckboxRef.current = selectedEl.id;
const currentRating = ((selectedEl.customData as Record<string, unknown> | undefined)?.rating as number) || 0;
const nextRating = currentRating >= 5 ? 1 : currentRating + 1;
const nextElements = elements.map((el) =>
el.id === selectedEl.id
? {
...el,
customData: {
...((el.customData as Record<string, unknown> | undefined) || {}),
rating: nextRating,
},
version: el.version + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}
: el
);
const nextEls = nextElements;
const nextAppState = appStateWithoutGrid(appState);
const nextFiles = files;
isMutatingSceneRef.current = true;
setTimeout(() => {
excalidrawAPI.updateScene({ elements: nextEls as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
currentStateRef.current = {
elements: nextEls,
appState: nextAppState,
files: nextFiles,
};
setSaveStatus('unsaved');
return;
}
}
// Handle "+" add button click or "Add task..." text click
const customData = (selectedEl?.customData as Record<string, unknown> | undefined);
const isAddButton = customData?.action === 'add';
const isAddText = customData?.templateRole && typeof customData.templateRole === 'string' &&
(customData.templateRole.startsWith('todo-add') ||
customData.templateRole.startsWith('checklist-add') ||
customData.templateRole.startsWith('list-add') ||
customData.templateRole.startsWith('meeting-add-action'));
if (selectedEl && (isAddButton || isAddText) && excalidrawAPI) {
if (lastProcessedAddRef.current === selectedEl.id) {
return;
}
lastProcessedAddRef.current = selectedEl.id;
const customData = (selectedEl.customData as Record<string, unknown>) || {};
const role = customData.templateRole as string;
const role = customData?.templateRole as string;
const btnX = (selectedEl.x as number) || 0;
const btnY = (selectedEl.y as number) || 0;
const newElements: LooseElement[] = [];
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
const tid = () => `txt-${Math.random().toString(36).slice(2)}`;
if (role.startsWith('todo-add') || role.startsWith('checklist-add')) {
if (role?.startsWith('todo-add') || role?.startsWith('checklist-add')) {
// Find the associated add button and "Add task..." text to move together
const addButtonEl = elements.find(el =>
el.type === 'rectangle' &&
(el.customData as Record<string, unknown> | undefined)?.templateRole === role
);
const addTextEl = elements.find(el =>
el.type === 'text' &&
((el.text as string)?.toLowerCase().includes('add task') ||
(el.text as string)?.toLowerCase().includes('add item') ||
(el.text as string)?.toLowerCase().includes('add bullet'))
);
// Add a new checkbox + text row below the button
const newY = btnY + 30;
newElements.push({
@@ -291,12 +440,28 @@ export const Editor: React.FC = () => {
text: 'New task', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 16, containerId: null, originalText: 'New task', lineHeight: 1.25,
});
// Move the add button down
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
// Move the add button and "Add task..." text down, plus any notes line for todo
const moveDown = newY + 40;
const updated = elements.map((el) => {
// Move the add button
if (addButtonEl && el.id === addButtonEl.id) {
return { ...el, y: moveDown, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() };
}
// Move the "Add task..." text
if (addTextEl && el.id === addTextEl.id) {
return { ...el, y: moveDown + 2, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() };
}
// Move notes line for todo template (the line after the add button)
if (role?.startsWith('todo-add') && el.type === 'rectangle' && (el.width as number) > 400 && (el.height as number) < 5) {
return { ...el, y: (el.y as number) + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() };
}
// Move notes text for todo template
if (role?.startsWith('todo-add') && el.type === 'text' && (el.text as string)?.toLowerCase() === 'notes:') {
return { ...el, y: (el.y as number) + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() };
}
return el;
});
const merged = [...updated, ...newElements];
isMutatingSceneRef.current = true;
setTimeout(() => {
@@ -402,10 +567,23 @@ export const Editor: React.FC = () => {
}
// Generic add: add a text line below
if (role.startsWith('list-add') || role.startsWith('meeting-add') || role.startsWith('flow-add') ||
role.startsWith('brainstorm-add') || role.startsWith('retro-add') || role.startsWith('swot-add') ||
role.startsWith('storymap-add') || role.startsWith('wireframe-add') || role.startsWith('timeline-add') ||
role.startsWith('architecture-add')) {
if (role?.startsWith('list-add') || role?.startsWith('meeting-add') || role?.startsWith('flow-add') ||
role?.startsWith('brainstorm-add') || role?.startsWith('retro-add') || role?.startsWith('swot-add') ||
role?.startsWith('storymap-add') || role?.startsWith('wireframe-add') || role?.startsWith('timeline-add') ||
role?.startsWith('architecture-add')) {
// Find the associated add text to move together
const addButtonEl = elements.find(el =>
el.type === 'rectangle' &&
(el.customData as Record<string, unknown> | undefined)?.templateRole === role
);
const addTextEl = elements.find(el =>
el.type === 'text' &&
((el.text as string)?.toLowerCase().includes('add') ||
(el.text as string)?.toLowerCase().includes('step') ||
(el.text as string)?.toLowerCase().includes('bullet') ||
(el.text as string)?.toLowerCase().includes('action'))
);
const newY = btnY + 30;
newElements.push({
id: tid(), type: 'text', x: btnX + 30, y: newY, width: 150, height: 22,
@@ -414,15 +592,22 @@ export const Editor: React.FC = () => {
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
text: role.startsWith('list-add') ? '• New item' : '- New item',
text: role?.startsWith('list-add') ? '• New item' : '- New item',
fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 14, containerId: null, originalText: role.startsWith('list-add') ? '• New item' : '- New item', lineHeight: 1.25,
baseline: 14, containerId: null, originalText: role?.startsWith('list-add') ? '• New item' : '- New item', lineHeight: 1.25,
});
// Move both the add button and the add text
const moveDown = newY + 30;
const updated = elements.map((el) => {
if (addButtonEl && el.id === addButtonEl.id) {
return { ...el, y: moveDown, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() };
}
if (addTextEl && el.id === addTextEl.id) {
return { ...el, y: moveDown + 2, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() };
}
return el;
});
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
const genericMerged = [...updated, ...newElements];
isMutatingSceneRef.current = true;
setTimeout(() => {
@@ -441,13 +626,37 @@ export const Editor: React.FC = () => {
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
// Auto-recognize links in text elements
const urlRegex = /(https?:\/\/[^\s<>"']+)/gi;
const elementsWithLinks = elements.map((el: ExcalidrawElement) => {
if (el.type === 'text' && el.text && !el.link) {
const match = el.text.match(urlRegex);
if (match && match[0]) {
return { ...el, link: match[0], version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000) };
}
}
return el;
});
if (elementsWithLinks.some((el: ExcalidrawElement, i: number) => el.link !== (elements as ExcalidrawElement[])[i]?.link) && excalidrawAPI) {
isMutatingSceneRef.current = true;
setTimeout(() => {
excalidrawAPI.updateScene({ elements: elementsWithLinks as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
}
// Only mark as unsaved if the data actually differs from last saved
const currentJson = JSON.stringify(currentStateRef.current);
if (currentJson !== lastSavedDataRef.current) {
setSaveStatus('unsaved');
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveDrawingRef.current();
}, 2000);
}
saveTimeoutRef.current = setTimeout(() => {
saveDrawingRef.current();
}, 2000);
}, [excalidrawAPI]);
// Auto-save: updates drawing snapshot directly without creating a revision
@@ -507,42 +716,6 @@ export const Editor: React.FC = () => {
}
};
// Manual save: creates a named revision
const handleManualSave = async () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
if (!id || !currentStateRef.current) return;
const snapshot = {
type: 'excalidraw',
version: 2,
source: window.location.hostname,
elements: currentStateRef.current.elements,
appState: currentStateRef.current.appState,
files: currentStateRef.current.files,
};
const snapshotJson = JSON.stringify(snapshot);
try {
setIsSaving(true);
setSaveStatus('saving');
// Create a named revision for manual save
await api.revisions.create(id, snapshot, 'Manual save');
lastSavedDataRef.current = snapshotJson;
setSaveStatus('saved');
// Refresh revisions list
try {
const revData = await api.revisions.list(id);
setRevisions(revData);
} catch (_) { /* ignore */ }
} catch (err) {
console.error('Failed to save:', err);
setSaveStatus('unsaved');
} finally {
setIsSaving(false);
}
};
// Ctrl+S keyboard shortcut
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -633,24 +806,35 @@ export const Editor: React.FC = () => {
if (match) {
const libraryUrl = decodeURIComponent(match[1]);
fetch(libraryUrl)
.then((r) => r.json())
.then((r) => {
if (!r.ok) {
throw new Error(`HTTP error! status: ${r.status}`);
}
return r.json();
})
.then((data) => {
// Excalidraw library items come in various formats
let libraryItems = data.libraryItems || data.library || data;
// Normalize to Excalidraw's expected library item format: { id, elements, status }
if (Array.isArray(libraryItems)) {
libraryItems = libraryItems.map((item: any) => {
libraryItems = libraryItems.map((item: Record<string, unknown>): LibraryItem => {
if (item.libraryItem) {
return { id: item.id || item.libraryItem.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.libraryItem.elements || [], status: 'published' };
return { id: (item.id as string) || (item.libraryItem as Record<string, unknown>).id as string || `item-${Math.random().toString(36).slice(2, 9)}`, elements: (item.libraryItem as Record<string, unknown>).elements as ExcalidrawElement[] || [], status: 'published' };
}
if (item.data) {
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.data.elements || item.elements || [], status: 'published' };
return { id: (item.id as string) || `item-${Math.random().toString(36).slice(2, 9)}`, elements: ((item.data as Record<string, unknown>).elements as ExcalidrawElement[]) || (item.elements as ExcalidrawElement[]) || [], status: 'published' };
}
if (item.elements) {
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements, status: 'published' };
return { id: (item.id as string) || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements as ExcalidrawElement[], status: 'published' };
}
return item;
});
return item as unknown as LibraryItem;
}).filter((item: LibraryItem) => item.elements && Array.isArray(item.elements) && item.elements.length > 0);
}
// Validate libraryItems is a valid array before proceeding
if (!Array.isArray(libraryItems) || libraryItems.length === 0) {
console.warn('Library import failed: No valid library items found');
window.history.replaceState(null, '', window.location.pathname + window.location.search);
return;
}
// Use the Excalidraw imperative API to add library items
try {
@@ -660,7 +844,7 @@ export const Editor: React.FC = () => {
} else if (api.updateScene) {
// Fallback: add elements directly to the canvas at center
const currentElements = api.getSceneElements?.() || [];
const newElements = libraryItems.flatMap((item: any) => item.elements || []);
const newElements = libraryItems.flatMap((item: LibraryItem) => item.elements || []);
if (newElements.length > 0) {
api.updateScene({
elements: [...currentElements, ...newElements] as ExcalidrawElement[],
@@ -672,25 +856,108 @@ export const Editor: React.FC = () => {
}
window.history.replaceState(null, '', window.location.pathname + window.location.search);
})
.catch((err) => console.error('Failed to load library:', err));
.catch((err) => {
console.error('Failed to load library:', err);
// Clear the hash even on error to prevent repeated failed attempts
window.history.replaceState(null, '', window.location.pathname + window.location.search);
});
}
}, [excalidrawAPI]);
// Not-ending arrow mode: auto-continue drawing arrows
useEffect(() => {
if (!excalidrawAPI || !notEndingArrow) return;
let lastArrowId: string | null = null;
let isDrawing = false;
const handlePointerDown = () => {
isDrawing = true;
};
const handlePointerUp = (activeTool: { type?: string }) => {
if (!notEndingArrow) return;
// After an arrow is drawn, wait a moment then start a new arrow from the end
if (isDrawing && activeTool.type === 'arrow') {
isDrawing = false;
const elements = (excalidrawAPI.getSceneElements?.() || []) as ExcalidrawElement[];
const lastArrow = elements.find((el: ExcalidrawElement): el is ExcalidrawElement & ArrowElement => el.type === 'arrow' && el.id !== lastArrowId);
if (lastArrow) {
lastArrowId = lastArrow.id;
// Get the end point of the last arrow
const points = (lastArrow as ArrowElement).points || [];
if (points.length >= 2) {
// Switch back to arrow tool to continue drawing
window.setTimeout(() => {
if (notEndingArrow && excalidrawAPI) {
(excalidrawAPI as any).setActiveTool?.({
type: 'arrow',
nativePenSDK: undefined,
});
}
}, 50);
}
}
}
};
const unsubPointerDown = excalidrawAPI.onPointerDown?.(handlePointerDown);
const unsubPointerUp = excalidrawAPI.onPointerUp?.(handlePointerUp);
return () => {
unsubPointerDown?.();
unsubPointerUp?.();
};
}, [excalidrawAPI, notEndingArrow]);
// Handle escape and right-click to stop not-ending arrow mode
useEffect(() => {
if (!notEndingArrow) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setNotEndingArrow(false);
if (excalidrawAPI) {
(excalidrawAPI as any).setActiveTool?.({ type: 'selection' });
}
}
};
const handleContextMenu = () => {
if (notEndingArrow) {
setNotEndingArrow(false);
if (excalidrawAPI) {
(excalidrawAPI as any).setActiveTool?.({ type: 'selection' });
}
}
};
window.addEventListener('keydown', handleKeyDown);
document.addEventListener('contextmenu', handleContextMenu);
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('contextmenu', handleContextMenu);
};
}, [notEndingArrow, excalidrawAPI]);
// Build slides: first slide is whole canvas, then each frame is a slide
useEffect(() => {
if (!presentationMode || !excalidrawAPI) return;
const currentElements = (excalidrawAPI.getSceneElements?.() || []) as ExcalidrawElement[];
const frameElements = currentElements
.filter((el: any) => el.type === 'frame')
.sort((a: any, b: any) => (a.y - b.y) || (a.x - b.x));
const allSlides: ExcalidrawElement[] = [];
.filter((el: ExcalidrawElement): el is ExcalidrawElement & Slide => el.type === 'frame')
.sort((a: ExcalidrawElement & Slide, b: ExcalidrawElement & Slide) => (a.y - b.y) || (a.x - b.x));
const allSlides: (ExcalidrawElement & Slide)[] = [];
// Slide 0: whole canvas (represented by a virtual placeholder)
if (currentElements.length > 0) {
allSlides.push({ id: '__whole_canvas__', type: 'frame', x: 0, y: 0, width: 1, height: 1, name: 'Canvas', isDeleted: false } as any);
allSlides.push({ id: '__whole_canvas__', type: 'frame', x: 0, y: 0, width: 1, height: 1, name: 'Canvas', isDeleted: false } as ExcalidrawElement & Slide);
}
// Subsequent slides: frames
frameElements.forEach((f: any) => allSlides.push(f));
setSlides(allSlides);
frameElements.forEach((f: ExcalidrawElement & Slide) => allSlides.push(f));
setSlides(allSlides as any);
setSlideIndex(0);
window.setTimeout(() => {
const api = excalidrawAPI as any;
@@ -763,6 +1030,59 @@ export const Editor: React.FC = () => {
);
}
const insertCustomElement = (type: 'checkbox' | 'correct-incorrect' | 'star-rating') => {
if (!excalidrawAPI) return;
const currentElements = (excalidrawAPI.getSceneElements?.() || []) as ExcalidrawElement[];
const appState = excalidrawAPI.getAppState?.() || {};
const centerX = ((appState as AppStateWithScroll).scrollX || 200) + 200;
const centerY = ((appState as AppStateWithScroll).scrollY || 200) + 200;
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
let newEl: LooseElement;
if (type === 'checkbox') {
newEl = {
id: uid(), type: 'rectangle', x: centerX, y: centerY, width: 24, height: 24,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: { type: 3, value: 32 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
customData: { templateRole: 'checkbox', checked: false },
};
} else if (type === 'correct-incorrect') {
newEl = {
id: uid(), type: 'ellipse', x: centerX, y: centerY, width: 24, height: 24,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 2, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: { type: 2 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
customData: { templateRole: 'correct-incorrect', status: 'empty' },
};
} else {
// star-rating
newEl = {
id: uid(), type: 'text', x: centerX, y: centerY, width: 120, height: 24,
angle: 0, strokeColor: '#fbbf24', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
text: '☆☆☆☆☆', fontSize: 24, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 18, containerId: null, originalText: '☆☆☆☆☆', lineHeight: 1.25,
customData: { templateRole: 'star-rating', rating: 0 },
};
}
const mergedElements = [...currentElements, newEl] as ExcalidrawElement[];
excalidrawAPI.updateScene({ elements: mergedElements });
const elId = newEl.id as string;
const selectedIds: Record<string, boolean> = { [elId]: true };
(excalidrawAPI as any).setAppState?.({ selectedElementIds: selectedIds });
setSaveStatus('unsaved');
};
return (
<div className={styles.container}>
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
@@ -805,15 +1125,6 @@ export const Editor: React.FC = () => {
<History size={16} />
{revisionCount > 0 && <span className={styles.revisionBadge}>{revisionCount}</span>}
</Button>
<Button
size="sm"
onClick={handleManualSave}
loading={isSaving}
disabled={saveStatus === 'saved'}
>
<Save size={16} />
{t('editor.saveNow')}
</Button>
<Button
variant="ghost"
size="sm"
@@ -904,6 +1215,60 @@ export const Editor: React.FC = () => {
>
<Plus size={16} />
</Button>
<div className={styles.toolbarDivider} />
<Button
variant="ghost"
size="sm"
onClick={() => insertCustomElement('checkbox')}
title="Insert checkbox"
aria-label="Insert a toggleable checkbox"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M9 12l2 2 4-4" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => insertCustomElement('correct-incorrect')}
title="Insert correct/incorrect"
aria-label="Insert a correct/incorrect toggle"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<path d="M9 12l2 2 4-4" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => insertCustomElement('star-rating')}
title="Insert star rating"
aria-label="Insert a star rating element"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1">
<polygon points="12,2 15,9 22,9 17,14 19,21 12,17 5,21 7,14 2,9 9,9" />
</svg>
</Button>
<Button
variant={notEndingArrow ? 'primary' : 'ghost'}
size="sm"
onClick={() => {
setNotEndingArrow(!notEndingArrow);
if (excalidrawAPI) {
const newTool = !notEndingArrow ? 'arrow' : 'selection';
(excalidrawAPI as any).setActiveTool?.({ type: newTool });
}
}}
title="Not-ending arrow (draws curved arrow that continues until you click)"
aria-label="Toggle not-ending arrow mode"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
<path d="M19 3c-2 2-4 4-4 7 0 2-2 4-4 5" strokeDasharray="2 2" />
</svg>
</Button>
</div>
</div>
<div className={styles.canvasWrapper}>
@@ -925,8 +1290,10 @@ export const Editor: React.FC = () => {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
importFiles: true,
},
}}
isMobile={false}
/>
</React.Suspense>
)}
@@ -148,6 +148,7 @@
width: 100%;
text-align: left;
font-size: var(--text-sm);
position: relative;
&:hover {
background: var(--color-surface-low);
@@ -162,12 +163,91 @@
border-color: var(--color-primary);
}
&.dragging {
opacity: 0.5;
}
svg {
color: var(--color-primary);
flex-shrink: 0;
}
}
.dragHandle {
cursor: grab;
color: var(--color-muted);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out);
.folderItem:hover & {
opacity: 1;
}
}
.dragOver {
background: var(--color-surface-primary-container);
border-color: var(--color-primary);
border-radius: var(--border-radius-lg);
}
.folderMenuBtn {
margin-left: auto;
background: none;
border: none;
padding: var(--space-1);
cursor: pointer;
color: var(--color-muted);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.folderMenu {
position: absolute;
right: var(--space-2);
top: 100%;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
min-width: 140px;
z-index: 20;
padding: var(--space-1);
}
.folderMenuItem {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
background: none;
border: none;
padding: var(--space-2) var(--space-2);
cursor: pointer;
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
text-align: left;
&:hover {
background: var(--color-surface-low);
}
}
.folderMenuDanger {
color: var(--color-danger-text);
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.grid {
flex: 1;
display: grid;
+189 -12
View File
@@ -1,11 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle } from 'lucide-react';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle, Pencil, Trash2, GripVertical } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import type { Drawing, Folder as FolderType } from '@/types';
import styles from './FileBrowser.module.scss';
export const FileBrowser: React.FC = () => {
@@ -37,6 +37,14 @@ export const FileBrowser: React.FC = () => {
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
// Folder menu state
const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
const folderMenuRef = useRef<HTMLDivElement | null>(null);
// Drag-drop state for folders
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
// New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
@@ -227,6 +235,110 @@ export const FileBrowser: React.FC = () => {
}
};
const handleRenameFolder = async (folder: FolderType) => {
const name = renameValue.trim();
if (!name || name === folder.name) {
setRenamingId(null);
return;
}
try {
const updated = await api.folders.update(folder.id, { name });
setFolders(folders.map(f => f.id === folder.id ? updated : f));
setRenamingId(null);
setFolderMenuId(null);
} catch (err) {
console.error('Failed to rename folder:', err);
showModal('alert', 'Error', 'Failed to rename folder. Please try again.');
}
};
const handleDeleteFolder = (folder: FolderType) => {
const drawingsInFolder = drawings.filter(d => d.folder_id === folder.id);
const message = drawingsInFolder.length > 0
? `Delete "${folder.name}" and move its ${drawingsInFolder.length} drawing(s) to root? This cannot be undone.`
: `Delete "${folder.name}"? This cannot be undone.`;
showModal('confirm', 'Delete Folder', message, async () => {
try {
// Move drawings to root first
for (const drawing of drawingsInFolder) {
await api.drawings.update(drawing.id, { folder_id: null });
}
setDrawings(drawings.map(d =>
d.folder_id === folder.id ? { ...d, folder_id: null } : d
));
await api.folders.delete(folder.id);
setFolders(folders.filter(f => f.id !== folder.id));
setFolderMenuId(null);
setModal(m => ({ ...m, open: false }));
if (activeFolderId === folder.id) {
navigate('/files');
}
} catch (err) {
console.error('Failed to delete folder:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete folder.'), 100);
}
});
};
// Drag and drop handlers for folders
const handleDragStart = (e: React.DragEvent, folderId: string) => {
setDraggedFolderId(folderId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, folderId: string) => {
e.preventDefault();
if (draggedFolderId && draggedFolderId !== folderId) {
setDragOverFolderId(folderId);
}
};
const handleDragLeave = () => {
setDragOverFolderId(null);
};
const handleDrop = async (e: React.DragEvent, targetFolderId: string) => {
e.preventDefault();
if (!draggedFolderId || draggedFolderId === targetFolderId) {
setDraggedFolderId(null);
setDragOverFolderId(null);
return;
}
// Reorder: move dragged folder to target position
const currentFolders = [...folders];
const draggedIndex = currentFolders.findIndex(f => f.id === draggedFolderId);
const targetIndex = currentFolders.findIndex(f => f.id === targetFolderId);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedFolderId(null);
setDragOverFolderId(null);
return;
}
const [draggedFolder] = currentFolders.splice(draggedIndex, 1);
currentFolders.splice(targetIndex, 0, draggedFolder);
const newOrder = currentFolders.map(f => f.id);
try {
const reordered = await api.folders.reorder(newOrder);
setFolders(reordered);
} catch (err) {
console.error('Failed to reorder folders:', err);
}
setDraggedFolderId(null);
setDragOverFolderId(null);
};
const handleDragEnd = () => {
setDraggedFolderId(null);
setDragOverFolderId(null);
};
// Close menu on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
@@ -386,16 +498,81 @@ export const FileBrowser: React.FC = () => {
</button>
</li>
{folders.map((folder) => (
<li key={folder.id}>
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(folder.id)}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
</button>
<li
key={folder.id}
draggable
onDragStart={(e) => handleDragStart(e, folder.id)}
onDragOver={(e) => handleDragOver(e, folder.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, folder.id)}
onDragEnd={handleDragEnd}
className={dragOverFolderId === folder.id ? styles.dragOver : ''}
>
{renamingId === folder.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameFolder(folder);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameFolder(folder)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''} ${draggedFolderId === folder.id ? styles.dragging : ''}`}
onClick={() => handleFolderClick(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setFolderMenuId(folder.id);
}}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<GripVertical size={14} className={styles.dragHandle} aria-hidden="true" />
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
<button
className={styles.folderMenuBtn}
onClick={(e) => {
e.stopPropagation();
setFolderMenuId(folderMenuId === folder.id ? null : folder.id);
}}
aria-label="Folder options"
>
<MoreVertical size={14} />
</button>
</button>
)}
{folderMenuId === folder.id && (
<div className={styles.folderMenu} ref={folderMenuRef}>
<button
className={styles.folderMenuItem}
onClick={(e) => {
e.stopPropagation();
setRenamingId(folder.id);
setRenameValue(folder.name);
setFolderMenuId(null);
}}
>
<Pencil size={14} />
Rename
</button>
<button
className={`${styles.folderMenuItem} ${styles.folderMenuDanger}`}
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder);
}}
>
<Trash2 size={14} />
Delete
</button>
</div>
)}
</li>
))}
</ul>
+6
View File
@@ -61,6 +61,12 @@ export const api = {
list: (): Promise<Folder[]> => fetchApi('/folders'),
create: (data: object): Promise<Folder> =>
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Folder> =>
fetchApi(`/folders/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> =>
fetchApi(`/folders/${id}`, { method: 'DELETE' }),
reorder: (folderIds: string[]): Promise<Folder[]> =>
fetchApi('/folders/reorder', { method: 'POST', body: JSON.stringify({ folder_ids: folderIds }) }),
},
teams: {
list: (): Promise<Team[]> => fetchApi('/teams'),
+12
View File
@@ -27,5 +27,17 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'excalidraw': ['@excalidraw/excalidraw'],
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['lucide-react', 'clsx'],
'i18n': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
'state': ['zustand'],
},
},
},
chunkSizeWarningLimit: 1000,
},
})