mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +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);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--default-border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
@@ -402,11 +409,19 @@
|
|||||||
|
|
||||||
.presentationOverlay {
|
.presentationOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 0;
|
||||||
right: 12px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
animation: presentationFadeIn 0.3s var(--ease-out);
|
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 {
|
@keyframes presentationFadeIn {
|
||||||
@@ -418,11 +433,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
background: var(--island-bg-color);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border: 1px solid var(--default-border-color);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-xl);
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-3) var(--space-5);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentationLabel {
|
.presentationLabel {
|
||||||
@@ -630,3 +646,59 @@
|
|||||||
z-index: 80;
|
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 React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Button } from '@/components';
|
||||||
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
@@ -112,6 +112,7 @@ export const Editor: React.FC = () => {
|
|||||||
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
||||||
const [slideIndex, setSlideIndex] = useState(0);
|
const [slideIndex, setSlideIndex] = useState(0);
|
||||||
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
|
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
|
||||||
|
const [notEndingArrow, setNotEndingArrow] = useState(false);
|
||||||
|
|
||||||
// Load drawing data
|
// Load drawing data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -200,11 +201,15 @@ export const Editor: React.FC = () => {
|
|||||||
appState: appStateWithoutGrid(appState),
|
appState: appStateWithoutGrid(appState),
|
||||||
files,
|
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');
|
setSaveStatus('unsaved');
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
saveDrawingRef.current();
|
saveDrawingRef.current();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,21 +260,133 @@ export const Editor: React.FC = () => {
|
|||||||
lastToggledCheckboxRef.current = null;
|
lastToggledCheckboxRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle "+" add button click
|
// Handle correct/incorrect toggle (cycles: empty → correct → incorrect → empty)
|
||||||
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
|
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) {
|
if (lastProcessedAddRef.current === selectedEl.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastProcessedAddRef.current = selectedEl.id;
|
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 btnX = (selectedEl.x as number) || 0;
|
||||||
const btnY = (selectedEl.y as number) || 0;
|
const btnY = (selectedEl.y as number) || 0;
|
||||||
const newElements: LooseElement[] = [];
|
const newElements: LooseElement[] = [];
|
||||||
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
|
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
|
||||||
const tid = () => `txt-${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
|
// Add a new checkbox + text row below the button
|
||||||
const newY = btnY + 30;
|
const newY = btnY + 30;
|
||||||
newElements.push({
|
newElements.push({
|
||||||
@@ -291,12 +408,28 @@ export const Editor: React.FC = () => {
|
|||||||
text: 'New task', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
text: 'New task', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||||
baseline: 16, containerId: null, originalText: 'New task', lineHeight: 1.25,
|
baseline: 16, containerId: null, originalText: 'New task', lineHeight: 1.25,
|
||||||
});
|
});
|
||||||
// Move the add button down
|
|
||||||
const updated = elements.map((el) =>
|
// Move the add button and "Add task..." text down, plus any notes line for todo
|
||||||
el.id === selectedEl.id
|
const moveDown = newY + 40;
|
||||||
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
const updated = elements.map((el) => {
|
||||||
: 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];
|
const merged = [...updated, ...newElements];
|
||||||
isMutatingSceneRef.current = true;
|
isMutatingSceneRef.current = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -402,10 +535,23 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generic add: add a text line below
|
// Generic add: add a text line below
|
||||||
if (role.startsWith('list-add') || role.startsWith('meeting-add') || role.startsWith('flow-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('brainstorm-add') || role?.startsWith('retro-add') || role?.startsWith('swot-add') ||
|
||||||
role.startsWith('storymap-add') || role.startsWith('wireframe-add') || role.startsWith('timeline-add') ||
|
role?.startsWith('storymap-add') || role?.startsWith('wireframe-add') || role?.startsWith('timeline-add') ||
|
||||||
role.startsWith('architecture-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;
|
const newY = btnY + 30;
|
||||||
newElements.push({
|
newElements.push({
|
||||||
id: tid(), type: 'text', x: btnX + 30, y: newY, width: 150, height: 22,
|
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),
|
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
|
||||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||||
boundElements: [], updated: Date.now(), link: null, locked: 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',
|
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];
|
const genericMerged = [...updated, ...newElements];
|
||||||
isMutatingSceneRef.current = true;
|
isMutatingSceneRef.current = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -441,6 +594,29 @@ export const Editor: React.FC = () => {
|
|||||||
appState: appStateWithoutGrid(appState),
|
appState: appStateWithoutGrid(appState),
|
||||||
files,
|
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');
|
setSaveStatus('unsaved');
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -448,6 +624,7 @@ export const Editor: React.FC = () => {
|
|||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
saveDrawingRef.current();
|
saveDrawingRef.current();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
// Auto-save: updates drawing snapshot directly without creating a revision
|
// Auto-save: updates drawing snapshot directly without creating a revision
|
||||||
@@ -633,7 +810,12 @@ export const Editor: React.FC = () => {
|
|||||||
if (match) {
|
if (match) {
|
||||||
const libraryUrl = decodeURIComponent(match[1]);
|
const libraryUrl = decodeURIComponent(match[1]);
|
||||||
fetch(libraryUrl)
|
fetch(libraryUrl)
|
||||||
.then((r) => r.json())
|
.then((r) => {
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${r.status}`);
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
// Excalidraw library items come in various formats
|
// Excalidraw library items come in various formats
|
||||||
let libraryItems = data.libraryItems || data.library || data;
|
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 { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements, status: 'published' };
|
||||||
}
|
}
|
||||||
return item;
|
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
|
// Use the Excalidraw imperative API to add library items
|
||||||
try {
|
try {
|
||||||
@@ -672,10 +860,93 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
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]);
|
}, [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
|
// Build slides: first slide is whole canvas, then each frame is a slide
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!presentationMode || !excalidrawAPI) return;
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
|
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
|
||||||
@@ -805,15 +1129,6 @@ export const Editor: React.FC = () => {
|
|||||||
<History size={16} />
|
<History size={16} />
|
||||||
{revisionCount > 0 && <span className={styles.revisionBadge}>{revisionCount}</span>}
|
{revisionCount > 0 && <span className={styles.revisionBadge}>{revisionCount}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleManualSave}
|
|
||||||
loading={isSaving}
|
|
||||||
disabled={saveStatus === 'saved'}
|
|
||||||
>
|
|
||||||
<Save size={16} />
|
|
||||||
{t('editor.saveNow')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -904,6 +1219,60 @@ export const Editor: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<div className={styles.canvasWrapper}>
|
<div className={styles.canvasWrapper}>
|
||||||
@@ -925,8 +1294,10 @@ export const Editor: React.FC = () => {
|
|||||||
saveToActiveFile: false,
|
saveToActiveFile: false,
|
||||||
loadScene: false,
|
loadScene: false,
|
||||||
export: { saveFileToDisk: false },
|
export: { saveFileToDisk: false },
|
||||||
|
importFiles: true,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
@@ -162,12 +163,91 @@
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
flex-shrink: 0;
|
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 {
|
.grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Card, Button, Modal } from '@/components';
|
||||||
import { useDrawingStore } from '@/stores';
|
import { useDrawingStore } from '@/stores';
|
||||||
import { api } from '@/services';
|
import { api } from '@/services';
|
||||||
import type { Drawing } from '@/types';
|
import type { Drawing, Folder as FolderType } from '@/types';
|
||||||
import styles from './FileBrowser.module.scss';
|
import styles from './FileBrowser.module.scss';
|
||||||
|
|
||||||
export const FileBrowser: React.FC = () => {
|
export const FileBrowser: React.FC = () => {
|
||||||
@@ -37,6 +37,14 @@ export const FileBrowser: React.FC = () => {
|
|||||||
// Move state
|
// Move state
|
||||||
const [movingId, setMovingId] = useState<string | null>(null);
|
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
|
// New drawing name modal state
|
||||||
const [showNameModal, setShowNameModal] = useState(false);
|
const [showNameModal, setShowNameModal] = useState(false);
|
||||||
const [newDrawingName, setNewDrawingName] = useState('');
|
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
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
@@ -386,16 +498,81 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{folders.map((folder) => (
|
{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
|
<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)}
|
onClick={() => handleFolderClick(folder.id)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFolderMenuId(folder.id);
|
||||||
|
}}
|
||||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
>
|
>
|
||||||
|
<GripVertical size={14} className={styles.dragHandle} aria-hidden="true" />
|
||||||
<Folder size={18} aria-hidden="true" />
|
<Folder size={18} aria-hidden="true" />
|
||||||
<span>{folder.name}</span>
|
<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>
|
||||||
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export const api = {
|
|||||||
list: (): Promise<Folder[]> => fetchApi('/folders'),
|
list: (): Promise<Folder[]> => fetchApi('/folders'),
|
||||||
create: (data: object): Promise<Folder> =>
|
create: (data: object): Promise<Folder> =>
|
||||||
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
|
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: {
|
teams: {
|
||||||
list: (): Promise<Team[]> => fetchApi('/teams'),
|
list: (): Promise<Team[]> => fetchApi('/teams'),
|
||||||
|
|||||||
Reference in New Issue
Block a user