mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
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:
+1
-1
Submodule excalidraw updated: 278cd35772...b2b2815954
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user