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
This commit is contained in:
Tomas Dvorak
2026-05-09 18:54:57 +02:00
parent 71dda9d45d
commit 190be65e4f
6 changed files with 771 additions and 65 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;
}
}
}
+406 -35
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';
@@ -112,6 +112,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 +201,15 @@ export const Editor: React.FC = () => {
appState: appStateWithoutGrid(appState),
files,
};
// 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 +260,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 +408,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 +535,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 +560,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,6 +594,29 @@ export const Editor: React.FC = () => {
appState: appStateWithoutGrid(appState),
files,
};
// Auto-recognize links in text elements
const urlRegex = /(https?:\/\/[^\s<>"']+)/gi;
const elementsWithLinks = elements.map((el: any) => {
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: any, i: number) => el.link !== (elements as any)[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);
@@ -448,6 +624,7 @@ export const Editor: React.FC = () => {
saveTimeoutRef.current = setTimeout(() => {
saveDrawingRef.current();
}, 2000);
}
}, [excalidrawAPI]);
// Auto-save: updates drawing snapshot directly without creating a revision
@@ -633,7 +810,12 @@ 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;
@@ -650,7 +832,13 @@ export const Editor: React.FC = () => {
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements, status: 'published' };
}
return item;
});
}).filter((item: any) => 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 {
@@ -672,10 +860,93 @@ 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: any) => el.type === 'arrow' && el.id !== lastArrowId);
if (lastArrow) {
lastArrowId = lastArrow.id;
// Get the end point of the last arrow
const points = (lastArrow as any).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;
@@ -763,6 +1034,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 any).scrollX || 200) + 200;
const centerY = ((appState as any).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 +1129,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 +1219,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 +1294,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;
+181 -4
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}>
<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 : ''}`}
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'),