mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
Compare commits
4 Commits
910546230d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c8088404 | |||
| cd22ee1ee8 | |||
| 19e7ed6ea1 | |||
| 8336c76705 |
@@ -28,6 +28,15 @@ jobs:
|
||||
id: image
|
||||
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
rm get-docker.sh
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Use GitHub token for GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
+1
-1
Submodule excalidraw updated: b2b2815954...f6d85bc80f
@@ -48,8 +48,8 @@ function makeHandDrawnRect(x: number, y: number, w: number, h: number, groupId?:
|
||||
};
|
||||
}
|
||||
|
||||
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string) {
|
||||
return {
|
||||
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string, customData?: Record<string, unknown>) {
|
||||
const el: RawElement = {
|
||||
id: `txt-${Math.random().toString(36).slice(2)}`,
|
||||
type: 'text',
|
||||
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
|
||||
@@ -82,6 +82,10 @@ function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: s
|
||||
originalText: text,
|
||||
lineHeight: 1.25,
|
||||
};
|
||||
if (customData) {
|
||||
el.customData = customData;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeCheckbox(x: number, y: number, checked = false) {
|
||||
@@ -158,7 +162,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeCheckbox(60, 210, false),
|
||||
makeText(90, 210, 'Third task'),
|
||||
makeAddButton(60, 250, '+', 'todo-add'),
|
||||
makeText(92, 250, 'Add task...', 16),
|
||||
makeText(92, 250, 'Add task...', 16, undefined, { templateRole: 'todo-add', action: 'add' }),
|
||||
makeHandDrawnRect(50, 290, 500, 2),
|
||||
makeText(60, 310, 'Notes:', 18),
|
||||
],
|
||||
@@ -172,7 +176,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeCheckbox(60, 210, false),
|
||||
makeText(90, 210, 'Another task', 18),
|
||||
makeAddButton(60, 250, '+', 'checklist-add'),
|
||||
makeText(92, 250, 'Add item...', 16),
|
||||
makeText(92, 250, 'Add item...', 16, undefined, { templateRole: 'checklist-add', action: 'add' }),
|
||||
],
|
||||
list: [
|
||||
makeHandDrawnRect(50, 50, 500, 50),
|
||||
@@ -182,7 +186,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeText(60, 210, '• Third bullet point'),
|
||||
makeText(60, 250, '• Fourth item with details'),
|
||||
makeAddButton(60, 290, '+', 'list-add'),
|
||||
makeText(92, 290, 'Add bullet...', 16),
|
||||
makeText(92, 290, 'Add bullet...', 16, undefined, { templateRole: 'list-add', action: 'add' }),
|
||||
],
|
||||
flow: [
|
||||
makeHandDrawnRect(200, 50, 200, 60),
|
||||
@@ -197,7 +201,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(200, 350, 200, 60),
|
||||
makeText(230, 370, 'End', 20),
|
||||
makeAddButton(420, 180, '+', 'flow-add'),
|
||||
makeText(452, 180, 'Add step', 14),
|
||||
makeText(452, 180, 'Add step', 14, undefined, { templateRole: 'flow-add', action: 'add' }),
|
||||
],
|
||||
kanban: [
|
||||
makeText(50, 40, 'Kanban Board', 30),
|
||||
@@ -234,7 +238,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeCheckbox(70, 390, false),
|
||||
makeText(105, 390, 'Owner and next step', 18),
|
||||
makeAddButton(70, 430, '+', 'meeting-add-action'),
|
||||
makeText(102, 430, 'Add action...', 14),
|
||||
makeText(102, 430, 'Add action...', 14, undefined, { templateRole: 'meeting-add-action', action: 'add' }),
|
||||
],
|
||||
wireframe: [
|
||||
makeText(50, 35, 'Page Wireframe', 30),
|
||||
@@ -248,7 +252,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(265, 380, 190, 110),
|
||||
makeHandDrawnRect(480, 380, 190, 110),
|
||||
makeAddButton(480, 500, '+', 'wireframe-add-section'),
|
||||
makeText(512, 500, 'Add section', 14),
|
||||
makeText(512, 500, 'Add section', 14, undefined, { templateRole: 'wireframe-add-section', action: 'add' }),
|
||||
],
|
||||
mindmap: [
|
||||
makeHandDrawnRect(240, 200, 200, 70),
|
||||
@@ -285,7 +289,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeText(520, 196, 'Idea 3', 18),
|
||||
makeArrow(460, 110, 580, 180),
|
||||
makeAddButton(50, 240, '+', 'brainstorm-add'),
|
||||
makeText(82, 240, 'Add idea...', 16),
|
||||
makeText(82, 240, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
// Notes area
|
||||
makeHandDrawnRect(50, 280, 610, 100),
|
||||
makeText(70, 300, 'Notes & connections:', 18),
|
||||
@@ -316,7 +320,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeText(80, 156, 'Branch 6', 18),
|
||||
makeArrow(260, 220, 200, 165),
|
||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
||||
makeText(82, 400, 'Add branch...', 16),
|
||||
makeText(82, 400, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-matrix': [
|
||||
makeText(50, 30, 'Brainstorm — Matrix', 30),
|
||||
@@ -338,7 +342,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeText(400, 350, '- Idea 1', 16),
|
||||
makeText(400, 380, '- Idea 2', 16),
|
||||
makeAddButton(50, 450, '+', 'brainstorm-add'),
|
||||
makeText(82, 450, 'Add idea...', 16),
|
||||
makeText(82, 450, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-freeform': [
|
||||
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
||||
@@ -355,7 +359,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(340, 250, 160, 80),
|
||||
makeText(360, 280, '✨ Idea 5', 18),
|
||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||
makeText(82, 360, 'Add note...', 16),
|
||||
makeText(82, 360, 'Add note...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-fishbone': [
|
||||
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
||||
@@ -382,7 +386,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(360, 340, 160, 50),
|
||||
makeText(380, 358, 'Product', 16),
|
||||
makeAddButton(50, 420, '+', 'brainstorm-add'),
|
||||
makeText(82, 420, 'Add cause...', 16),
|
||||
makeText(82, 420, 'Add cause...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-venn': [
|
||||
makeText(50, 30, 'Brainstorm — Venn', 30),
|
||||
@@ -399,7 +403,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
// Center overlap note
|
||||
makeText(245, 190, 'Overlap', 12),
|
||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
||||
makeText(82, 400, 'Add set...', 16),
|
||||
makeText(82, 400, 'Add set...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-tree': [
|
||||
makeText(50, 30, 'Brainstorm — Tree', 30),
|
||||
@@ -427,7 +431,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(440, 290, 140, 40),
|
||||
makeText(465, 305, 'Leaf 3a', 14),
|
||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||
makeText(82, 360, 'Add branch...', 16),
|
||||
makeText(82, 360, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-converge': [
|
||||
makeText(50, 30, 'Brainstorm — Converge', 30),
|
||||
@@ -450,7 +454,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(240, 350, 220, 50),
|
||||
makeText(265, 370, 'Action Plan', 16),
|
||||
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
||||
makeText(82, 430, 'Add idea...', 16),
|
||||
makeText(82, 430, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
retrospective: [
|
||||
makeText(50, 30, 'Retrospective', 30),
|
||||
@@ -529,7 +533,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(50, 300, 600, 2),
|
||||
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
||||
makeAddButton(50, 350, '+', 'storymap-add-row'),
|
||||
makeText(82, 350, 'Add row...', 14),
|
||||
makeText(82, 350, 'Add row...', 14, undefined, { templateRole: 'storymap-add-row', action: 'add' }),
|
||||
],
|
||||
timeline: [
|
||||
makeText(50, 30, 'Project Timeline', 30),
|
||||
@@ -553,7 +557,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(500, 170, 130, 50),
|
||||
makeText(515, 185, 'Deploy', 14),
|
||||
makeAddButton(80, 240, '+', 'timeline-add'),
|
||||
makeText(112, 240, 'Add phase...', 14),
|
||||
makeText(112, 240, 'Add phase...', 14, undefined, { templateRole: 'timeline-add', action: 'add' }),
|
||||
],
|
||||
architecture: [
|
||||
makeText(50, 30, 'System Architecture', 30),
|
||||
@@ -580,7 +584,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(50, 200, 160, 70),
|
||||
makeText(90, 220, 'CDN', 18),
|
||||
makeAddButton(300, 290, '+', 'architecture-add'),
|
||||
makeText(332, 290, 'Add component...', 14),
|
||||
makeText(332, 290, 'Add component...', 14, undefined, { templateRole: 'architecture-add', action: 'add' }),
|
||||
],
|
||||
'er-diagram': [
|
||||
makeText(50, 30, 'ER Diagram', 30),
|
||||
@@ -613,7 +617,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(50, 330, 600, 50),
|
||||
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
|
||||
makeAddButton(50, 400, '+', 'api-add'),
|
||||
makeText(82, 400, 'Add endpoint...', 14),
|
||||
makeText(82, 400, 'Add endpoint...', 14, undefined, { templateRole: 'api-add', action: 'add' }),
|
||||
],
|
||||
'sitemap': [
|
||||
makeText(50, 30, 'Site Map', 30),
|
||||
@@ -635,7 +639,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeArrow(350, 140, 460, 180),
|
||||
makeArrow(350, 140, 630, 180),
|
||||
makeAddButton(50, 260, '+', 'sitemap-add'),
|
||||
makeText(82, 260, 'Add page...', 14),
|
||||
makeText(82, 260, 'Add page...', 14, undefined, { templateRole: 'sitemap-add', action: 'add' }),
|
||||
],
|
||||
'user-persona': [
|
||||
makeText(50, 30, 'User Persona', 30),
|
||||
@@ -658,7 +662,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(50, 330, 620, 70),
|
||||
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
|
||||
makeAddButton(50, 420, '+', 'persona-add'),
|
||||
makeText(82, 420, 'Add trait...', 14),
|
||||
makeText(82, 420, 'Add trait...', 14, undefined, { templateRole: 'persona-add', action: 'add' }),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -99,6 +99,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.canvasTools {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
button {
|
||||
padding: 4px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingCanvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -414,14 +437,13 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
pointer-events: auto;
|
||||
pointer-events: none;
|
||||
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);
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@keyframes presentationFadeIn {
|
||||
@@ -439,6 +461,7 @@
|
||||
padding: var(--space-3) var(--space-5);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
margin-bottom: var(--space-4);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.presentationLabel {
|
||||
@@ -459,6 +482,7 @@
|
||||
box-shadow: var(--shadow-island);
|
||||
max-width: 400px;
|
||||
overflow-x: auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.presentationSlideThumb {
|
||||
|
||||
@@ -22,6 +22,7 @@ const ExcalidrawWithLibrary = React.lazy(() =>
|
||||
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
|
||||
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
|
||||
import { useHandleLibrary } from '@excalidraw/excalidraw';
|
||||
|
||||
type LooseElement = Record<string, unknown>;
|
||||
|
||||
@@ -30,17 +31,6 @@ interface ExcalidrawProps {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface LibraryItem {
|
||||
id: string;
|
||||
elements: ExcalidrawElement[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ArrowElement {
|
||||
type: 'arrow';
|
||||
points: number[][];
|
||||
}
|
||||
|
||||
interface Slide {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -111,6 +101,41 @@ function appStateWithoutGrid(appState: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function ensureArray<T>(val: unknown): T[] {
|
||||
if (Array.isArray(val)) return val as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureObject(val: unknown): Record<string, unknown> {
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) return val as Record<string, unknown>;
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeElements(elements: unknown): ExcalidrawElement[] {
|
||||
const arr = ensureArray<Record<string, unknown>>(elements);
|
||||
return arr.map((el) => {
|
||||
if (!el || typeof el !== 'object') return null;
|
||||
const normalized: Record<string, unknown> = { ...el };
|
||||
// Ensure array fields are actually arrays
|
||||
normalized.boundElements = ensureArray<unknown>(el.boundElements);
|
||||
normalized.groupIds = ensureArray<unknown>(el.groupIds);
|
||||
if (Array.isArray(el.points)) {
|
||||
normalized.points = el.points;
|
||||
} else if (el.points) {
|
||||
normalized.points = [];
|
||||
}
|
||||
return normalized as unknown as ExcalidrawElement;
|
||||
}).filter((el): el is ExcalidrawElement => el !== null);
|
||||
}
|
||||
|
||||
function normalizeSnapshot(snapshot: Record<string, unknown>): ExcalidrawInitialDataState {
|
||||
return {
|
||||
elements: normalizeElements(snapshot.elements),
|
||||
appState: appStateWithoutGrid(ensureObject(snapshot.appState)),
|
||||
files: ensureObject(snapshot.files) as ExcalidrawInitialDataState['files'],
|
||||
};
|
||||
}
|
||||
|
||||
export const Editor: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -130,12 +155,17 @@ export const Editor: React.FC = () => {
|
||||
const currentStateRef = useRef<EditorState | null>(null);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSavedDataRef = useRef<string>('');
|
||||
const lastToggledCheckboxRef = useRef<string | null>(null);
|
||||
const recentlyToggledRef = useRef<Set<string>>(new Set());
|
||||
const skipNextUnsavedRef = useRef(false);
|
||||
const lastProcessedAddRef = useRef<string | null>(null);
|
||||
const saveDrawingRef = useRef<() => Promise<void>>(async () => {});
|
||||
const isMutatingSceneRef = useRef(false);
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
});
|
||||
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
const [presentationMode, setPresentationMode] = useState(false);
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
|
||||
@@ -144,7 +174,6 @@ 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(() => {
|
||||
@@ -169,12 +198,9 @@ export const Editor: React.FC = () => {
|
||||
try {
|
||||
const rawSnapshot = revisionsData[0].snapshot;
|
||||
const snapshot = typeof rawSnapshot === 'string' ? JSON.parse(rawSnapshot) : rawSnapshot;
|
||||
setInitialData({
|
||||
elements: snapshot.elements || [],
|
||||
appState: appStateWithoutGrid(snapshot.appState || {}),
|
||||
files: snapshot.files || {},
|
||||
});
|
||||
setInitialData(normalizeSnapshot(snapshot));
|
||||
lastSavedDataRef.current = JSON.stringify(snapshot);
|
||||
skipNextUnsavedRef.current = true;
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse revision snapshot:', parseErr);
|
||||
setInitialData({
|
||||
@@ -183,18 +209,16 @@ export const Editor: React.FC = () => {
|
||||
files: {},
|
||||
});
|
||||
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
||||
skipNextUnsavedRef.current = true;
|
||||
}
|
||||
} else {
|
||||
// Check for pending template from dashboard
|
||||
const pendingTemplate = localStorage.getItem(`template_${id}`);
|
||||
if (pendingTemplate) {
|
||||
const tpl = JSON.parse(pendingTemplate);
|
||||
setInitialData({
|
||||
elements: tpl.elements || [],
|
||||
appState: appStateWithoutGrid(tpl.appState || {}),
|
||||
files: tpl.files || {},
|
||||
});
|
||||
setInitialData(normalizeSnapshot(tpl));
|
||||
lastSavedDataRef.current = JSON.stringify(tpl);
|
||||
skipNextUnsavedRef.current = true;
|
||||
localStorage.removeItem(`template_${id}`);
|
||||
} else {
|
||||
// Start with empty canvas
|
||||
@@ -204,6 +228,7 @@ export const Editor: React.FC = () => {
|
||||
files: {},
|
||||
});
|
||||
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
||||
skipNextUnsavedRef.current = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -235,13 +260,28 @@ export const Editor: React.FC = () => {
|
||||
};
|
||||
// Only mark as unsaved if the data actually differs from last saved
|
||||
const currentJson = JSON.stringify(currentStateRef.current);
|
||||
if (currentJson !== lastSavedDataRef.current) {
|
||||
if (currentJson !== lastSavedDataRef.current && !skipNextUnsavedRef.current) {
|
||||
setSaveStatus('unsaved');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveDrawingRef.current();
|
||||
}, 2000);
|
||||
}
|
||||
if (skipNextUnsavedRef.current) {
|
||||
skipNextUnsavedRef.current = false;
|
||||
lastSavedDataRef.current = currentJson;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipNextUnsavedRef.current) {
|
||||
currentStateRef.current = {
|
||||
elements: elements as unknown as ExcalidrawElement[],
|
||||
appState: appStateWithoutGrid(appState),
|
||||
files,
|
||||
};
|
||||
lastSavedDataRef.current = JSON.stringify(currentStateRef.current);
|
||||
skipNextUnsavedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,8 +292,9 @@ export const Editor: React.FC = () => {
|
||||
|
||||
// Handle checkbox toggle
|
||||
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox') {
|
||||
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
|
||||
lastToggledCheckboxRef.current = selectedEl.id;
|
||||
if (excalidrawAPI && !recentlyToggledRef.current.has(selectedEl.id)) {
|
||||
recentlyToggledRef.current.add(selectedEl.id);
|
||||
setTimeout(() => recentlyToggledRef.current.delete(selectedEl.id), 150);
|
||||
const nextChecked = !((selectedEl.customData as Record<string, unknown> | undefined)?.checked as boolean);
|
||||
const nextElements = elements.map((el) => (
|
||||
el.id === selectedEl.id
|
||||
@@ -288,37 +329,37 @@ export const Editor: React.FC = () => {
|
||||
setSaveStatus('unsaved');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
lastToggledCheckboxRef.current = null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (excalidrawAPI && !recentlyToggledRef.current.has(selectedEl.id)) {
|
||||
recentlyToggledRef.current.add(selectedEl.id);
|
||||
setTimeout(() => recentlyToggledRef.current.delete(selectedEl.id), 150);
|
||||
const currentStatus = ((selectedEl.customData as Record<string, unknown> | undefined)?.status as string) || 'empty';
|
||||
let nextStatus: string;
|
||||
let nextText: string;
|
||||
let nextColor: string;
|
||||
let nextFill: 'solid' | 'hachure';
|
||||
if (currentStatus === 'empty') {
|
||||
nextStatus = 'correct';
|
||||
nextText = '☑';
|
||||
nextColor = '#22c55e';
|
||||
nextFill = 'solid';
|
||||
} else if (currentStatus === 'correct') {
|
||||
nextStatus = 'incorrect';
|
||||
nextText = '☒';
|
||||
nextColor = '#ef4444';
|
||||
nextFill = 'solid';
|
||||
} else {
|
||||
nextStatus = 'empty';
|
||||
nextText = '☐';
|
||||
nextColor = '#1e1e1e';
|
||||
nextFill = 'hachure';
|
||||
}
|
||||
const nextElements = elements.map((el) =>
|
||||
el.id === selectedEl.id
|
||||
? {
|
||||
...el,
|
||||
backgroundColor: nextStatus === 'empty' ? 'transparent' : nextColor,
|
||||
fillStyle: nextFill,
|
||||
text: nextText,
|
||||
originalText: nextText,
|
||||
strokeColor: nextColor,
|
||||
customData: {
|
||||
...((el.customData as Record<string, unknown> | undefined) || {}),
|
||||
status: nextStatus,
|
||||
@@ -349,14 +390,18 @@ export const Editor: React.FC = () => {
|
||||
|
||||
// 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;
|
||||
if (excalidrawAPI && !recentlyToggledRef.current.has(selectedEl.id)) {
|
||||
recentlyToggledRef.current.add(selectedEl.id);
|
||||
setTimeout(() => recentlyToggledRef.current.delete(selectedEl.id), 150);
|
||||
const currentRating = ((selectedEl.customData as Record<string, unknown> | undefined)?.rating as number) || 0;
|
||||
const nextRating = currentRating >= 5 ? 1 : currentRating + 1;
|
||||
const stars = '★'.repeat(nextRating) + '☆'.repeat(5 - nextRating);
|
||||
const nextElements = elements.map((el) =>
|
||||
el.id === selectedEl.id
|
||||
? {
|
||||
...el,
|
||||
text: stars,
|
||||
originalText: stars,
|
||||
customData: {
|
||||
...((el.customData as Record<string, unknown> | undefined) || {}),
|
||||
rating: nextRating,
|
||||
@@ -388,20 +433,48 @@ export const Editor: React.FC = () => {
|
||||
// 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'));
|
||||
const isAddText = selectedEl?.type === 'text' && typeof selectedEl.text === 'string' &&
|
||||
((selectedEl.text.toLowerCase().includes('add task') ||
|
||||
selectedEl.text.toLowerCase().includes('add item') ||
|
||||
selectedEl.text.toLowerCase().includes('add bullet') ||
|
||||
selectedEl.text.toLowerCase().includes('add action') ||
|
||||
selectedEl.text.toLowerCase().includes('add step') ||
|
||||
selectedEl.text.toLowerCase().includes('add section') ||
|
||||
selectedEl.text.toLowerCase().includes('add idea') ||
|
||||
selectedEl.text.toLowerCase().includes('add branch') ||
|
||||
selectedEl.text.toLowerCase().includes('add note') ||
|
||||
selectedEl.text.toLowerCase().includes('add cause') ||
|
||||
selectedEl.text.toLowerCase().includes('add set') ||
|
||||
selectedEl.text.toLowerCase().includes('add row') ||
|
||||
selectedEl.text.toLowerCase().includes('add phase') ||
|
||||
selectedEl.text.toLowerCase().includes('add component') ||
|
||||
selectedEl.text.toLowerCase().includes('add endpoint') ||
|
||||
selectedEl.text.toLowerCase().includes('add page') ||
|
||||
selectedEl.text.toLowerCase().includes('add trait')));
|
||||
|
||||
if (selectedEl && (isAddButton || isAddText) && excalidrawAPI) {
|
||||
if (lastProcessedAddRef.current === selectedEl.id) {
|
||||
return;
|
||||
}
|
||||
lastProcessedAddRef.current = selectedEl.id;
|
||||
const role = customData?.templateRole as string;
|
||||
const btnX = (selectedEl.x as number) || 0;
|
||||
const btnY = (selectedEl.y as number) || 0;
|
||||
let role = customData?.templateRole as string;
|
||||
if (!role && isAddText) {
|
||||
const addButton = elements.find(el =>
|
||||
el.type === 'rectangle' &&
|
||||
(el.customData as Record<string, unknown> | undefined)?.action === 'add' &&
|
||||
Math.abs((el.y as number) - (selectedEl.y as number)) < 60
|
||||
);
|
||||
role = (addButton?.customData as Record<string, unknown> | undefined)?.templateRole as string;
|
||||
}
|
||||
const resolvedBtn = isAddText
|
||||
? elements.find(el =>
|
||||
el.type === 'rectangle' &&
|
||||
(el.customData as Record<string, unknown> | undefined)?.action === 'add' &&
|
||||
Math.abs((el.y as number) - (selectedEl.y as number)) < 60
|
||||
)
|
||||
: selectedEl;
|
||||
const btnX = (resolvedBtn?.x as number) || 0;
|
||||
const btnY = (resolvedBtn?.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)}`;
|
||||
@@ -419,8 +492,8 @@ export const Editor: React.FC = () => {
|
||||
(el.text as string)?.toLowerCase().includes('add bullet'))
|
||||
);
|
||||
|
||||
// Add a new checkbox + text row below the button
|
||||
const newY = btnY + 30;
|
||||
// Add a new checkbox + text row where the button currently is
|
||||
const newY = btnY;
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 20, height: 20,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||
@@ -440,9 +513,9 @@ 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 and "Add task..." text down, plus any notes line for todo
|
||||
const moveDown = newY + 40;
|
||||
|
||||
// Move the add button and "Add task..." text down by one slot
|
||||
const moveDown = btnY + 40;
|
||||
const updated = elements.map((el) => {
|
||||
// Move the add button
|
||||
if (addButtonEl && el.id === addButtonEl.id) {
|
||||
@@ -566,39 +639,261 @@ export const Editor: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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')) {
|
||||
// Find the associated add text to move together
|
||||
const addButtonEl = elements.find(el =>
|
||||
el.type === 'rectangle' &&
|
||||
// Generic add handler for all templates
|
||||
const genericRoles = ['list-add', 'meeting-add', 'flow-add', 'brainstorm-add', 'retro-add',
|
||||
'swot-add', 'storymap-add', 'wireframe-add', 'timeline-add', 'architecture-add',
|
||||
'api-add', 'sitemap-add', 'persona-add'];
|
||||
if (genericRoles.some((r) => role?.startsWith(r))) {
|
||||
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 addTextEl = elements.find(el =>
|
||||
el.type === 'text' &&
|
||||
(el.customData as Record<string, unknown> | undefined)?.templateRole === role
|
||||
);
|
||||
|
||||
const newY = btnY + 30;
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX + 30, y: newY, width: 150, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 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,
|
||||
});
|
||||
|
||||
|
||||
const newY = btnY;
|
||||
let slotHeight = 30;
|
||||
|
||||
// Role-specific element creation
|
||||
if (role?.startsWith('brainstorm-add')) {
|
||||
// Sticky note style: rectangle + text
|
||||
const gid = `note-${uid()}`;
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 160, height: 50,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: '#fef3c7', fillStyle: 'solid',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
|
||||
frameId: null, roundness: { type: 3, value: 12 }, seed: Math.floor(Math.random() * 10000),
|
||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX + 15, y: newY + 14, width: 130, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
|
||||
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: 'New idea', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: 'New idea', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 60;
|
||||
} else if (role?.startsWith('retro-add-well') || role?.startsWith('retro-add-improve')) {
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX, y: newY, width: 150, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: '- New item', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: '- New item', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 30;
|
||||
} else if (role?.startsWith('retro-add-action')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 20, height: 20,
|
||||
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 },
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX + 30, y: newY + 2, width: 120, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'New action', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: 'New action', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 30;
|
||||
} else if (role?.startsWith('swot-add')) {
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX, y: newY, width: 200, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: '- New item', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: '- New item', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 30;
|
||||
} else if (role?.startsWith('storymap-add-step')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 120, height: 40,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||
frameId: null, roundness: { type: 3, value: 12 }, seed: Math.floor(Math.random() * 10000),
|
||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX + 10, y: newY + 10, width: 100, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'New step', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: 'New step', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 50;
|
||||
} else if (role?.startsWith('storymap-add-story')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 120, height: 35,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: '#e0f2fe', fillStyle: 'solid',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||
frameId: null, roundness: { type: 3, value: 12 }, seed: Math.floor(Math.random() * 10000),
|
||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX + 8, y: newY + 8, width: 100, height: 20,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'New story', fontSize: 14, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 12, containerId: null, originalText: 'New story', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 45;
|
||||
} else if (role?.startsWith('storymap-add-row')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX, y: newY + 10, width: 600, height: 2,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: '#1e1e1e', fillStyle: 'solid',
|
||||
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,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX, y: newY + 20, width: 200, height: 20,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'New epic row', fontSize: 14, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 12, containerId: null, originalText: 'New epic row', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 40;
|
||||
} else if (role?.startsWith('wireframe-add')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX - 430, y: newY, width: 190, height: 110,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||
frameId: null, roundness: { type: 3, value: 12 }, seed: Math.floor(Math.random() * 10000),
|
||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX - 410, y: newY + 45, width: 150, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'New section', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: 'New section', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 120;
|
||||
} else if (role?.startsWith('timeline-add')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX - 30, y: newY + 30, width: 130, height: 50,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||
frameId: null, roundness: { type: 3, value: 12 }, seed: Math.floor(Math.random() * 10000),
|
||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX - 15, y: newY + 45, width: 100, height: 20,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'New task', fontSize: 14, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 12, containerId: null, originalText: 'New task', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 80;
|
||||
} else if (role?.startsWith('api-add')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX - 30, y: newY, width: 600, height: 50,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||
frameId: null, roundness: { type: 3, value: 12 }, seed: Math.floor(Math.random() * 10000),
|
||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX, y: newY + 14, width: 400, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'GET /new-endpoint → Description', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: 'GET /new-endpoint → Description', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 60;
|
||||
} else if (role?.startsWith('sitemap-add')) {
|
||||
newElements.push({
|
||||
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 140, height: 50,
|
||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||
frameId: null, roundness: { type: 3, value: 12 }, seed: Math.floor(Math.random() * 10000),
|
||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||
});
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX + 20, y: newY + 14, width: 100, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 'New page', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 14, containerId: null, originalText: 'New page', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 60;
|
||||
} else if (role?.startsWith('persona-add')) {
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX, y: newY, width: 300, height: 20,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: '- New trait', fontSize: 14, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||
baseline: 12, containerId: null, originalText: '- New trait', lineHeight: 1.25,
|
||||
});
|
||||
slotHeight = 24;
|
||||
} else {
|
||||
// Default: simple text line
|
||||
newElements.push({
|
||||
id: tid(), type: 'text', x: btnX + 30, y: newY, width: 150, height: 22,
|
||||
angle: 0, strokeColor: '#1e1e1e', 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: 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,
|
||||
});
|
||||
slotHeight = 30;
|
||||
}
|
||||
|
||||
// Move both the add button and the add text
|
||||
const moveDown = newY + 30;
|
||||
const moveDown = btnY + slotHeight;
|
||||
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() };
|
||||
@@ -703,11 +998,7 @@ export const Editor: React.FC = () => {
|
||||
if (!revision.snapshot) return;
|
||||
try {
|
||||
const snapshot = typeof revision.snapshot === 'string' ? JSON.parse(revision.snapshot) : revision.snapshot;
|
||||
setInitialData({
|
||||
elements: snapshot.elements || [],
|
||||
appState: appStateWithoutGrid(snapshot.appState || {}),
|
||||
files: snapshot.files || {},
|
||||
});
|
||||
setInitialData(normalizeSnapshot(snapshot));
|
||||
lastSavedDataRef.current = JSON.stringify(snapshot);
|
||||
setSelectedRevision(revision.id);
|
||||
setSaveStatus('saved');
|
||||
@@ -786,163 +1077,39 @@ export const Editor: React.FC = () => {
|
||||
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: null },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI?.onPointerUp) return undefined;
|
||||
|
||||
return excalidrawAPI.onPointerUp((activeTool: { type?: string; locked?: boolean }) => {
|
||||
if ((activeTool.type === 'line' || activeTool.type === 'arrow') && !activeTool.locked) {
|
||||
window.setTimeout(() => {
|
||||
excalidrawAPI.setActiveTool?.({ type: 'selection' });
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
// Library import from URL hash (#addLibrary=...)
|
||||
// Handle postMessage library imports from libraries.excalidraw.com
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI) return;
|
||||
const hash = window.location.hash;
|
||||
const match = hash.match(/addLibrary=([^&]+)/);
|
||||
if (match) {
|
||||
const libraryUrl = decodeURIComponent(match[1]);
|
||||
fetch(libraryUrl)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(`HTTP error! status: ${r.status}`);
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.origin !== 'https://libraries.excalidraw.com') return;
|
||||
let data = event.data;
|
||||
if (typeof data === 'string') {
|
||||
try { data = JSON.parse(data); } catch { return; }
|
||||
}
|
||||
if (data?.type === 'EXCALIDRAW_LIBRARY') {
|
||||
try {
|
||||
const items = data.data?.libraryItems || data.data?.items || data.data;
|
||||
if (items && Array.isArray(items)) {
|
||||
await excalidrawAPI.updateLibrary({
|
||||
libraryItems: items,
|
||||
merge: true,
|
||||
openLibraryMenu: true,
|
||||
defaultStatus: 'published',
|
||||
});
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
// Excalidraw library items come in various formats
|
||||
let libraryItems = data.libraryItems || data.library || data;
|
||||
// Normalize to Excalidraw's expected library item format: { id, elements, status }
|
||||
if (Array.isArray(libraryItems)) {
|
||||
libraryItems = libraryItems.map((item: Record<string, unknown>): LibraryItem => {
|
||||
if (item.libraryItem) {
|
||||
return { id: (item.id as string) || (item.libraryItem as Record<string, unknown>).id as string || `item-${Math.random().toString(36).slice(2, 9)}`, elements: (item.libraryItem as Record<string, unknown>).elements as ExcalidrawElement[] || [], status: 'published' };
|
||||
}
|
||||
if (item.data) {
|
||||
return { id: (item.id as string) || `item-${Math.random().toString(36).slice(2, 9)}`, elements: ((item.data as Record<string, unknown>).elements as ExcalidrawElement[]) || (item.elements as ExcalidrawElement[]) || [], status: 'published' };
|
||||
}
|
||||
if (item.elements) {
|
||||
return { id: (item.id as string) || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements as ExcalidrawElement[], status: 'published' };
|
||||
}
|
||||
return item as unknown as LibraryItem;
|
||||
}).filter((item: LibraryItem) => item.elements && Array.isArray(item.elements) && item.elements.length > 0);
|
||||
}
|
||||
// Validate libraryItems is a valid array before proceeding
|
||||
if (!Array.isArray(libraryItems) || libraryItems.length === 0) {
|
||||
console.warn('Library import failed: No valid library items found');
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
return;
|
||||
}
|
||||
// Use the Excalidraw imperative API to add library items
|
||||
try {
|
||||
const api = excalidrawAPI as any;
|
||||
if (api.updateLibraryItems) {
|
||||
api.updateLibraryItems(libraryItems, 'merge');
|
||||
} else if (api.updateScene) {
|
||||
// Fallback: add elements directly to the canvas at center
|
||||
const currentElements = api.getSceneElements?.() || [];
|
||||
const newElements = libraryItems.flatMap((item: LibraryItem) => item.elements || []);
|
||||
if (newElements.length > 0) {
|
||||
api.updateScene({
|
||||
elements: [...currentElements, ...newElements] as ExcalidrawElement[],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Library import failed:', e);
|
||||
}
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
})
|
||||
.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);
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Library import from postMessage failed:', err);
|
||||
excalidrawAPI.updateScene({
|
||||
appState: { errorMessage: err?.message || 'Library import failed' },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
// Not-ending arrow mode: auto-continue drawing arrows
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || !notEndingArrow) return;
|
||||
|
||||
let lastArrowId: string | null = null;
|
||||
let isDrawing = false;
|
||||
|
||||
const handlePointerDown = () => {
|
||||
isDrawing = true;
|
||||
};
|
||||
|
||||
const handlePointerUp = (activeTool: { type?: string }) => {
|
||||
if (!notEndingArrow) return;
|
||||
|
||||
// After an arrow is drawn, wait a moment then start a new arrow from the end
|
||||
if (isDrawing && activeTool.type === 'arrow') {
|
||||
isDrawing = false;
|
||||
const elements = (excalidrawAPI.getSceneElements?.() || []) as ExcalidrawElement[];
|
||||
const lastArrow = elements.find((el: ExcalidrawElement): el is ExcalidrawElement & ArrowElement => el.type === 'arrow' && el.id !== lastArrowId);
|
||||
|
||||
if (lastArrow) {
|
||||
lastArrowId = lastArrow.id;
|
||||
// Get the end point of the last arrow
|
||||
const points = (lastArrow as ArrowElement).points || [];
|
||||
if (points.length >= 2) {
|
||||
// Switch back to arrow tool to continue drawing
|
||||
window.setTimeout(() => {
|
||||
if (notEndingArrow && excalidrawAPI) {
|
||||
(excalidrawAPI as any).setActiveTool?.({
|
||||
type: 'arrow',
|
||||
nativePenSDK: undefined,
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unsubPointerDown = excalidrawAPI.onPointerDown?.(handlePointerDown);
|
||||
const unsubPointerUp = excalidrawAPI.onPointerUp?.(handlePointerUp);
|
||||
|
||||
return () => {
|
||||
unsubPointerDown?.();
|
||||
unsubPointerUp?.();
|
||||
};
|
||||
}, [excalidrawAPI, notEndingArrow]);
|
||||
|
||||
// Handle escape and right-click to stop not-ending arrow mode
|
||||
useEffect(() => {
|
||||
if (!notEndingArrow) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setNotEndingArrow(false);
|
||||
if (excalidrawAPI) {
|
||||
(excalidrawAPI as any).setActiveTool?.({ type: 'selection' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = () => {
|
||||
if (notEndingArrow) {
|
||||
setNotEndingArrow(false);
|
||||
if (excalidrawAPI) {
|
||||
(excalidrawAPI as any).setActiveTool?.({ type: 'selection' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('contextmenu', handleContextMenu);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
};
|
||||
}, [notEndingArrow, excalidrawAPI]);
|
||||
|
||||
// Build slides: first slide is whole canvas, then each frame is a slide
|
||||
useEffect(() => {
|
||||
if (!presentationMode || !excalidrawAPI) return;
|
||||
@@ -1052,12 +1219,14 @@ export const Editor: React.FC = () => {
|
||||
};
|
||||
} else if (type === 'correct-incorrect') {
|
||||
newEl = {
|
||||
id: uid(), type: 'ellipse', x: centerX, y: centerY, width: 24, height: 24,
|
||||
id: uid(), type: 'text', 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),
|
||||
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: 'correct-incorrect', status: 'empty' },
|
||||
};
|
||||
} else {
|
||||
@@ -1215,64 +1384,49 @@ 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}>
|
||||
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates) ? styles.canvasNarrow : ''}`}>
|
||||
{!presentationMode && (
|
||||
<div className={styles.canvasTools}>
|
||||
<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">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M8 12l3 3 5-5" />
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
{initialData && (
|
||||
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
||||
<ExcalidrawWithLibrary
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.folderItem {
|
||||
@@ -173,12 +177,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
.dragHandleWrapper {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
color: var(--color-muted);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
.folderItem:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -186,7 +206,7 @@
|
||||
|
||||
.dragOver {
|
||||
background: var(--color-surface-primary-container);
|
||||
border-color: var(--color-primary);
|
||||
border: 2px dashed var(--color-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
|
||||
@@ -425,17 +445,88 @@
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.dropdownSubmenu {
|
||||
.batchBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--color-surface-primary-container);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dropdownSubheader {
|
||||
.batchCount {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
.batchActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.batchBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-on-surface);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
}
|
||||
}
|
||||
|
||||
.batchDanger {
|
||||
color: var(--color-danger-text);
|
||||
|
||||
&:hover {
|
||||
background: rgba(224, 49, 49, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.selectBox {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
z-index: 5;
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-1);
|
||||
cursor: pointer;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
|
||||
.drawingCard:hover &,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.drawingSelected {
|
||||
border: 2px solid var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
|
||||
.selectBox {
|
||||
opacity: 1;
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.newProjectForm {
|
||||
@@ -630,3 +721,58 @@
|
||||
&:hover { background: var(--color-primary-darker); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.moveHint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-muted);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.moveList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.moveItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-on-surface);
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
|
||||
svg {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
border-color: var(--default-border-color);
|
||||
}
|
||||
|
||||
&.moveItemActive {
|
||||
background: var(--color-surface-primary-container);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-darkest);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.moveCurrent {
|
||||
margin-left: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-low);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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, Pencil, Trash2, GripVertical } from 'lucide-react';
|
||||
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle, Pencil, Trash2, GripVertical, Square, SquareCheck, Move } from 'lucide-react';
|
||||
import { Card, Button, Modal } from '@/components';
|
||||
import { useDrawingStore } from '@/stores';
|
||||
import { api } from '@/services';
|
||||
@@ -35,7 +35,7 @@ export const FileBrowser: React.FC = () => {
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
// Move state
|
||||
const [movingId, setMovingId] = useState<string | null>(null);
|
||||
const [moveModalDrawing, setMoveModalDrawing] = useState<Drawing | null>(null);
|
||||
|
||||
// Folder menu state
|
||||
const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
|
||||
@@ -45,6 +45,13 @@ export const FileBrowser: React.FC = () => {
|
||||
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
|
||||
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
||||
|
||||
// Drag-drop state for drawings
|
||||
const [draggedDrawingId, setDraggedDrawingId] = useState<string | null>(null);
|
||||
|
||||
// Multi-select state
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
|
||||
|
||||
// New drawing name modal state
|
||||
const [showNameModal, setShowNameModal] = useState(false);
|
||||
const [newDrawingName, setNewDrawingName] = useState('');
|
||||
@@ -181,6 +188,11 @@ export const FileBrowser: React.FC = () => {
|
||||
try {
|
||||
await api.drawings.delete(drawing.id);
|
||||
removeDrawing(drawing.id);
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(drawing.id);
|
||||
return next;
|
||||
});
|
||||
setActiveMenu(null);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
} catch (err) {
|
||||
@@ -191,6 +203,55 @@ export const FileBrowser: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const count = selectedIds.size;
|
||||
if (count === 0) return;
|
||||
const selectedDrawings = visibleDrawings.filter(d => selectedIds.has(d.id));
|
||||
showModal('confirm', 'Delete Drawings', `Delete ${count} drawing(s)? This cannot be undone.`, async () => {
|
||||
try {
|
||||
await Promise.all(selectedDrawings.map(d => api.drawings.delete(d.id)));
|
||||
selectedDrawings.forEach(d => removeDrawing(d.id));
|
||||
setSelectedIds(new Set());
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete drawings:', err);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawings.'), 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(new Set(visibleDrawings.map(d => d.id)));
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchMove = async (folderId: string | null) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await Promise.all(ids.map(id => api.drawings.update(id, { folder_id: folderId })));
|
||||
setDrawings(drawings.map(d => selectedIds.has(d.id) ? { ...d, folder_id: folderId } : d));
|
||||
setSelectedIds(new Set());
|
||||
setBatchMoveOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to move drawings:', err);
|
||||
showModal('alert', 'Error', 'Failed to move drawings. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateDrawing = async (drawing: Drawing) => {
|
||||
try {
|
||||
const newDrawing = await api.drawings.create({
|
||||
@@ -227,8 +288,7 @@ export const FileBrowser: React.FC = () => {
|
||||
try {
|
||||
await api.drawings.update(drawing.id, { folder_id: folderId });
|
||||
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
||||
setMovingId(null);
|
||||
setActiveMenu(null);
|
||||
setMoveModalDrawing(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to move drawing:', err);
|
||||
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
|
||||
@@ -284,23 +344,46 @@ export const FileBrowser: React.FC = () => {
|
||||
|
||||
// Drag and drop handlers for folders
|
||||
const handleDragStart = (e: React.DragEvent, folderId: string) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isHandle = target.closest(`.${styles.dragHandleWrapper}`) !== null;
|
||||
if (!isHandle) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setDraggedFolderId(folderId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, folderId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedFolderId && draggedFolderId !== folderId) {
|
||||
setDragOverFolderId(folderId);
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
const related = e.relatedTarget as HTMLElement;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
if (related && current.contains(related)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverFolderId(null);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetFolderId: string) => {
|
||||
const handleDrop = async (e: React.DragEvent, targetFolderId: string | null) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle drawing drop onto folder
|
||||
if (draggedDrawingId) {
|
||||
const drawing = drawings.find(d => d.id === draggedDrawingId);
|
||||
if (drawing && drawing.folder_id !== targetFolderId) {
|
||||
try {
|
||||
await api.drawings.update(drawing.id, { folder_id: targetFolderId });
|
||||
setDrawings(drawings.map(d => d.id === draggedDrawingId ? { ...d, folder_id: targetFolderId } : d));
|
||||
} catch (err) {
|
||||
console.error('Failed to move drawing to folder:', err);
|
||||
}
|
||||
}
|
||||
setDraggedDrawingId(null);
|
||||
setDragOverFolderId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle folder reorder drop
|
||||
if (!draggedFolderId || draggedFolderId === targetFolderId) {
|
||||
setDraggedFolderId(null);
|
||||
setDragOverFolderId(null);
|
||||
@@ -311,7 +394,7 @@ export const FileBrowser: React.FC = () => {
|
||||
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);
|
||||
@@ -320,16 +403,16 @@ export const FileBrowser: React.FC = () => {
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -337,14 +420,33 @@ export const FileBrowser: React.FC = () => {
|
||||
const handleDragEnd = () => {
|
||||
setDraggedFolderId(null);
|
||||
setDragOverFolderId(null);
|
||||
setDraggedDrawingId(null);
|
||||
};
|
||||
|
||||
// Drawing drag handlers
|
||||
const handleDrawingDragStart = (e: React.DragEvent, drawingId: string) => {
|
||||
setDraggedDrawingId(drawingId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDrawingDragEnd = () => {
|
||||
setDraggedDrawingId(null);
|
||||
setDragOverFolderId(null);
|
||||
};
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (menuRef.current && !menuRef.current.contains(target)) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
|
||||
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
|
||||
if (!isMenuBtn) {
|
||||
setFolderMenuId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
@@ -459,6 +561,28 @@ export const FileBrowser: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className={styles.batchBar}>
|
||||
<span className={styles.batchCount}>{selectedIds.size} selected</span>
|
||||
<div className={styles.batchActions}>
|
||||
<button className={styles.batchBtn} onClick={selectAll}>
|
||||
Select All
|
||||
</button>
|
||||
<button className={styles.batchBtn} onClick={() => setBatchMoveOpen(true)}>
|
||||
<Move size={14} />
|
||||
Move to...
|
||||
</button>
|
||||
<button className={`${styles.batchBtn} ${styles.batchDanger}`} onClick={handleDeleteSelected}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
<button className={styles.batchBtn} onClick={clearSelection}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.content}>
|
||||
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
|
||||
{showNewProject && (
|
||||
@@ -486,7 +610,20 @@ export const FileBrowser: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.folderTree} role="tree">
|
||||
<li>
|
||||
<li
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggedDrawingId) setDragOverFolderId('__root__');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const related = e.relatedTarget as HTMLElement;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
if (related && current.contains(related)) return;
|
||||
setDragOverFolderId(null);
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, null)}
|
||||
className={dragOverFolderId === '__root__' ? styles.dragOver : ''}
|
||||
>
|
||||
<button
|
||||
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
|
||||
onClick={() => handleFolderClick(null)}
|
||||
@@ -498,12 +635,18 @@ export const FileBrowser: React.FC = () => {
|
||||
</button>
|
||||
</li>
|
||||
{folders.map((folder) => (
|
||||
<li
|
||||
<li
|
||||
key={folder.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, folder.id)}
|
||||
onDragOver={(e) => handleDragOver(e, folder.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if ((draggedFolderId && draggedFolderId !== folder.id) || (draggedDrawingId && draggedDrawingId !== folder.id)) {
|
||||
setDragOverFolderId(folder.id);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => handleDragLeave(e)}
|
||||
onDrop={(e) => handleDrop(e, folder.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={dragOverFolderId === folder.id ? styles.dragOver : ''}
|
||||
@@ -532,7 +675,13 @@ export const FileBrowser: React.FC = () => {
|
||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
||||
role="treeitem"
|
||||
>
|
||||
<GripVertical size={14} className={styles.dragHandle} aria-hidden="true" />
|
||||
<span
|
||||
className={styles.dragHandleWrapper}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<GripVertical size={14} className={styles.dragHandle} />
|
||||
</span>
|
||||
<Folder size={18} aria-hidden="true" />
|
||||
<span>{folder.name}</span>
|
||||
<button
|
||||
@@ -587,93 +736,99 @@ export const FileBrowser: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleDrawings.map((drawing) => (
|
||||
<Card
|
||||
key={drawing.id}
|
||||
className={styles.drawingCard}
|
||||
hover
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={() => handleDrawingClick(drawing)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleDrawingClick(drawing);
|
||||
}
|
||||
}}
|
||||
aria-label={`Open drawing ${drawing.title}`}
|
||||
>
|
||||
<div className={styles.thumbnail}>
|
||||
{drawing.thumbnail_url ? (
|
||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<img
|
||||
src={`/api/drawings/${drawing.id}/thumbnail`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
{renamingId === drawing.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className={styles.renameInput}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameDrawing(drawing);
|
||||
if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
onBlur={() => handleRenameDrawing(drawing)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className={styles.title}>{drawing.title}</h4>
|
||||
<p className={styles.meta}>
|
||||
Edited {new Date(drawing.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
|
||||
visibleDrawings.map((drawing) => {
|
||||
const isSelected = selectedIds.has(drawing.id);
|
||||
return (
|
||||
<Card
|
||||
key={drawing.id}
|
||||
className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
|
||||
hover
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
|
||||
onDragEnd={handleDrawingDragEnd}
|
||||
onClick={() => handleDrawingClick(drawing)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleDrawingClick(drawing);
|
||||
}
|
||||
}}
|
||||
aria-label={`Open drawing ${drawing.title}`}
|
||||
>
|
||||
<button
|
||||
className={styles.more}
|
||||
className={styles.selectBox}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
|
||||
setRenamingId(null);
|
||||
toggleSelect(drawing.id);
|
||||
}}
|
||||
aria-label={`More options for ${drawing.title}`}
|
||||
aria-expanded={activeMenu === drawing.id}
|
||||
aria-label={isSelected ? 'Deselect' : 'Select'}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
{activeMenu === drawing.id && (
|
||||
<div className={styles.dropdown}>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
|
||||
{movingId === drawing.id ? (
|
||||
<div className={styles.dropdownSubmenu}>
|
||||
<button className={styles.dropdownSubheader}>Move to:</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
|
||||
{folders.map(f => (
|
||||
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
|
||||
))}
|
||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
|
||||
)}
|
||||
<div className={styles.dropdownDivider} />
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
<div className={styles.thumbnail}>
|
||||
{drawing.thumbnail_url ? (
|
||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<img
|
||||
src={`/api/drawings/${drawing.id}/thumbnail`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
{renamingId === drawing.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className={styles.renameInput}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameDrawing(drawing);
|
||||
if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
onBlur={() => handleRenameDrawing(drawing)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className={styles.title}>{drawing.title}</h4>
|
||||
<p className={styles.meta}>
|
||||
Edited {new Date(drawing.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
|
||||
<button
|
||||
className={styles.more}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
aria-label={`More options for ${drawing.title}`}
|
||||
aria-expanded={activeMenu === drawing.id}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{activeMenu === drawing.id && (
|
||||
<div className={styles.dropdown}>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
|
||||
<div className={styles.dropdownDivider} />
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -707,6 +862,80 @@ export const FileBrowser: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moveModalDrawing && (
|
||||
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="move-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setMoveModalDrawing(null); }}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 id="move-drawing-title">Move "{moveModalDrawing.title}"</h3>
|
||||
<button className={styles.modalClose} onClick={() => setMoveModalDrawing(null)} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
<p className={styles.moveHint}>Select a destination:</p>
|
||||
<div className={styles.moveList}>
|
||||
<button
|
||||
className={`${styles.moveItem} ${moveModalDrawing.folder_id === null ? styles.moveItemActive : ''}`}
|
||||
onClick={() => handleMoveDrawing(moveModalDrawing, null)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>All Projects</span>
|
||||
{moveModalDrawing.folder_id === null && <span className={styles.moveCurrent}>Current</span>}
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`${styles.moveItem} ${moveModalDrawing.folder_id === f.id ? styles.moveItemActive : ''}`}
|
||||
onClick={() => handleMoveDrawing(moveModalDrawing, f.id)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>{f.name}</span>
|
||||
{moveModalDrawing.folder_id === f.id && <span className={styles.moveCurrent}>Current</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button className={styles.modalBtnSecondary} onClick={() => setMoveModalDrawing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchMoveOpen && (
|
||||
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="batch-move-title" onClick={(e) => { if (e.target === e.currentTarget) setBatchMoveOpen(false); }}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 id="batch-move-title">Move {selectedIds.size} drawing(s)</h3>
|
||||
<button className={styles.modalClose} onClick={() => setBatchMoveOpen(false)} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
<p className={styles.moveHint}>Select a destination:</p>
|
||||
<div className={styles.moveList}>
|
||||
<button
|
||||
className={styles.moveItem}
|
||||
onClick={() => handleBatchMove(null)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={styles.moveItem}
|
||||
onClick={() => handleBatchMove(f.id)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>{f.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button className={styles.modalBtnSecondary} onClick={() => setBatchMoveOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE workspace_folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX idx_workspace_folders_sort_order ON workspace_folders(team_id, sort_order);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_workspace_folders_sort_order;
|
||||
ALTER TABLE workspace_folders DROP COLUMN IF EXISTS sort_order;
|
||||
@@ -79,6 +79,9 @@ func (a *API) Routes() chi.Router {
|
||||
r.Get("/stats", a.handleStats)
|
||||
r.Get("/folders", a.handleListFolders)
|
||||
r.Post("/folders", a.handleCreateFolder)
|
||||
r.Patch("/folders/{folderID}", a.handleUpdateFolder)
|
||||
r.Delete("/folders/{folderID}", a.handleDeleteFolder)
|
||||
r.Post("/folders/reorder", a.handleReorderFolders)
|
||||
r.Get("/projects", a.handleListProjects)
|
||||
r.Post("/projects", a.handleCreateProject)
|
||||
r.Get("/notifications", a.handleListNotifications)
|
||||
@@ -625,6 +628,51 @@ func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, folder)
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
folderID := chi.URLParam(r, "folderID")
|
||||
var req UpdateFolderRequest
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
folder, err := a.store.UpdateFolder(r.Context(), user.ID, folderID, req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folder)
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
folderID := chi.URLParam(r, "folderID")
|
||||
if err := a.store.DeleteFolder(r.Context(), user.ID, folderID); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleReorderFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req struct {
|
||||
FolderIDs []string `json:"folder_ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
if err := a.store.ReorderFolders(r.Context(), user.ID, req.FolderIDs); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
folders, err := a.store.ListFolders(r.Context(), user.ID, "")
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folders)
|
||||
}
|
||||
|
||||
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
|
||||
+143
-13
@@ -40,12 +40,33 @@ type CreateDrawingRequest struct {
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
}
|
||||
|
||||
// NullString distinguishes JSON null (Valid=true, Value=nil) from absent field (Valid=false).
|
||||
type NullString struct {
|
||||
Value *string
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func (n *NullString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
n.Value = nil
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Value = &s
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateDrawingRequest struct {
|
||||
FolderID *string `json:"folder_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *string `json:"visibility"`
|
||||
FolderID NullString `json:"folder_id"`
|
||||
ProjectID NullString `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *string `json:"visibility"`
|
||||
}
|
||||
|
||||
type CreateRevisionRequest struct {
|
||||
@@ -61,6 +82,11 @@ type CreateFolderRequest struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type UpdateFolderRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Visibility *string `json:"visibility"`
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
TeamID string `json:"team_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
|
||||
}
|
||||
current.Visibility = *req.Visibility
|
||||
}
|
||||
if req.FolderID != nil {
|
||||
current.FolderID = req.FolderID
|
||||
if req.FolderID.Valid {
|
||||
current.FolderID = req.FolderID.Value
|
||||
}
|
||||
if req.ProjectID != nil {
|
||||
current.ProjectID = req.ProjectID
|
||||
if req.ProjectID.Valid {
|
||||
current.ProjectID = req.ProjectID.Value
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings
|
||||
@@ -922,7 +948,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at
|
||||
FROM workspace_folders WHERE team_id = ? ORDER BY path_cache ASC`, teamID)
|
||||
FROM workspace_folders WHERE team_id = ? ORDER BY sort_order ASC, created_at ASC`, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -972,10 +998,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
var maxOrder int
|
||||
s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspace_folders WHERE team_id = ?`, teamID).Scan(&maxOrder)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_folders
|
||||
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
|
||||
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, maxOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -983,6 +1011,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFolder(ctx context.Context, userID, folderID string, req UpdateFolderRequest) (*Folder, error) {
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
var updates []string
|
||||
var args []any
|
||||
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" || len(name) > 120 {
|
||||
return nil, fmt.Errorf("folder name must be between 1 and 120 characters")
|
||||
}
|
||||
updates = append(updates, "name = ?")
|
||||
args = append(args, name)
|
||||
updates = append(updates, "slug = ?")
|
||||
args = append(args, slugify(name))
|
||||
updates = append(updates, "path_cache = ?")
|
||||
args = append(args, slugify(name))
|
||||
}
|
||||
if req.Visibility != nil {
|
||||
updates = append(updates, "visibility = ?")
|
||||
args = append(args, *req.Visibility)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return s.GetFolder(ctx, folderID)
|
||||
}
|
||||
|
||||
updates = append(updates, "updated_at = ?")
|
||||
args = append(args, time.Now().UTC())
|
||||
args = append(args, folderID)
|
||||
|
||||
query := "UPDATE workspace_folders SET " + strings.Join(updates, ", ") + " WHERE id = ?"
|
||||
_, err = s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFolder(ctx, folderID)
|
||||
}
|
||||
|
||||
func (s *Store) GetFolder(ctx context.Context, folderID string) (*Folder, error) {
|
||||
var folder Folder
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE id = ?`, folderID).Scan(
|
||||
&folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &folder, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFolder(ctx context.Context, userID, folderID string) error {
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_folders WHERE id = ?`, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ReorderFolders(ctx context.Context, userID string, folderIDs []string) error {
|
||||
if len(folderIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderIDs[0]).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
for i, id := range folderIDs {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE workspace_folders SET sort_order = ? WHERE id = ? AND team_id = ?`, i, id, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
|
||||
if teamID == "" {
|
||||
teamID, _ = s.defaultTeamID(ctx, userID)
|
||||
|
||||
Reference in New Issue
Block a user