From 8336c7670513ee50ec7db36760c71c7f71972286 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sun, 10 May 2026 10:01:06 +0200 Subject: [PATCH] fix(editor): correct templates, library import, and custom tools - Move custom tools (checkbox, correct/incorrect, star) to floating canvas toolbar above native Excalidraw tools - Fix correct/incorrect toggle: square text element cycling empty/ check/cross with debounce to prevent double-firing - Fix star rating: updates displayed stars on click - Remove broken infinity-arrow feature entirely - Fix autosave false "unsaved" on load by skipping first change after initial data normalization - Fix presentation mode: remove opaque overlay blocking canvas, add pointer-events to only toolbar/thumbnails - Fix to-do list add: full text area clickable via customData, correct spacing when inserting new tasks - Add customData to all template "Add..." text elements so every template add button + text pair is fully clickable - Expand generic add handler with role-specific creation for brainstorm, retro, swot, storymap, wireframe, timeline, api, sitemap, and persona templates - Fix library import from libraries.excalidraw.com via #addLibrary hash: extract into reusable callback, listen to hashchange events, and offset imported elements to viewport center Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../TemplatePicker/TemplatePicker.tsx | 48 +- frontend/src/pages/Editor/Editor.module.scss | 32 +- frontend/src/pages/Editor/Editor.tsx | 783 +++++++++++------- 3 files changed, 554 insertions(+), 309 deletions(-) diff --git a/frontend/src/components/TemplatePicker/TemplatePicker.tsx b/frontend/src/components/TemplatePicker/TemplatePicker.tsx index 29b81d9..5e5cdd3 100644 --- a/frontend/src/components/TemplatePicker/TemplatePicker.tsx +++ b/frontend/src/components/TemplatePicker/TemplatePicker.tsx @@ -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) { + 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { // 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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' }), ], }; diff --git a/frontend/src/pages/Editor/Editor.module.scss b/frontend/src/pages/Editor/Editor.module.scss index 37be635..3d58ad6 100644 --- a/frontend/src/pages/Editor/Editor.module.scss +++ b/frontend/src/pages/Editor/Editor.module.scss @@ -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 { diff --git a/frontend/src/pages/Editor/Editor.tsx b/frontend/src/pages/Editor/Editor.tsx index 5358d06..94410ac 100644 --- a/frontend/src/pages/Editor/Editor.tsx +++ b/frontend/src/pages/Editor/Editor.tsx @@ -36,10 +36,6 @@ interface LibraryItem { status: string; } -interface ArrowElement { - type: 'arrow'; - points: number[][]; -} interface Slide { id: string; @@ -111,6 +107,41 @@ function appStateWithoutGrid(appState: Record = {}) { }; } +function ensureArray(val: unknown): T[] { + if (Array.isArray(val)) return val as T[]; + return []; +} + +function ensureObject(val: unknown): Record { + if (val && typeof val === 'object' && !Array.isArray(val)) return val as Record; + return {}; +} + +function normalizeElements(elements: unknown): ExcalidrawElement[] { + const arr = ensureArray>(elements); + return arr.map((el) => { + if (!el || typeof el !== 'object') return null; + const normalized: Record = { ...el }; + // Ensure array fields are actually arrays + normalized.boundElements = ensureArray(el.boundElements); + normalized.groupIds = ensureArray(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): 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,7 +161,8 @@ export const Editor: React.FC = () => { const currentStateRef = useRef(null); const saveTimeoutRef = useRef | null>(null); const lastSavedDataRef = useRef(''); - const lastToggledCheckboxRef = useRef(null); + const recentlyToggledRef = useRef>(new Set()); + const skipNextUnsavedRef = useRef(false); const lastProcessedAddRef = useRef(null); const saveDrawingRef = useRef<() => Promise>(async () => {}); const isMutatingSceneRef = useRef(false); @@ -144,7 +176,6 @@ export const Editor: React.FC = () => { const [isSavingTemplate, setIsSavingTemplate] = useState(false); const [slideIndex, setSlideIndex] = useState(0); const [slides, setSlides] = useState([]); - const [notEndingArrow, setNotEndingArrow] = useState(false); // Load drawing data useEffect(() => { @@ -169,12 +200,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 +211,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 +230,7 @@ export const Editor: React.FC = () => { files: {}, }); lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} }); + skipNextUnsavedRef.current = true; } } } catch (err) { @@ -235,13 +262,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 +294,9 @@ export const Editor: React.FC = () => { // Handle checkbox toggle if (selectedEl && (selectedEl.customData as Record | 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 | undefined)?.checked as boolean); const nextElements = elements.map((el) => ( el.id === selectedEl.id @@ -288,37 +331,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 | 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 | 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 | undefined) || {}), status: nextStatus, @@ -349,14 +392,18 @@ export const Editor: React.FC = () => { // Handle star rating toggle if (selectedEl && (selectedEl.customData as Record | 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 | 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 | undefined) || {}), rating: nextRating, @@ -388,20 +435,48 @@ export const Editor: React.FC = () => { // Handle "+" add button click or "Add task..." text click const customData = (selectedEl?.customData as Record | 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 | undefined)?.action === 'add' && + Math.abs((el.y as number) - (selectedEl.y as number)) < 60 + ); + role = (addButton?.customData as Record | undefined)?.templateRole as string; + } + const resolvedBtn = isAddText + ? elements.find(el => + el.type === 'rectangle' && + (el.customData as Record | 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 +494,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 +515,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 +641,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 | 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 | 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 +1000,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 +1079,100 @@ 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=...) - useEffect(() => { + const processLibraryImport = React.useCallback(() => { 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}`); - } - 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): LibraryItem => { - if (item.libraryItem) { - return { id: (item.id as string) || (item.libraryItem as Record).id as string || `item-${Math.random().toString(36).slice(2, 9)}`, elements: (item.libraryItem as Record).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).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[], - }); - } + if (!match) return; + const libraryUrl = decodeURIComponent(match[1]); + fetch(libraryUrl) + .then((r) => { + if (!r.ok) { + throw new Error(`HTTP error! status: ${r.status}`); + } + return r.json(); + }) + .then((data) => { + // Excalidraw library items come in various formats + let libraryItems = data.libraryItems || data.library || data; + // Normalize to Excalidraw's expected library item format: { id, elements, status } + if (Array.isArray(libraryItems)) { + libraryItems = libraryItems.map((item: Record): LibraryItem => { + if (item.libraryItem) { + return { id: (item.id as string) || (item.libraryItem as Record).id as string || `item-${Math.random().toString(36).slice(2, 9)}`, elements: (item.libraryItem as Record).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).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 + const currentElements = api.getSceneElements?.() || []; + const importedElements = libraryItems.flatMap((item: LibraryItem) => item.elements || []); + if (importedElements.length > 0) { + // Offset imported elements to current viewport center + const appState = api.getAppState?.() || {}; + const offsetX = ((appState.scrollX as number) || 0) + 200; + const offsetY = ((appState.scrollY as number) || 0) + 200; + const minX = Math.min(...importedElements.map((el: ExcalidrawElement) => el.x)); + const minY = Math.min(...importedElements.map((el: ExcalidrawElement) => el.y)); + const shiftedElements = importedElements.map((el: ExcalidrawElement) => ({ + ...el, + id: `lib-${Math.random().toString(36).slice(2, 9)}-${el.id}`, + x: el.x - minX + offsetX, + y: el.y - minY + offsetY, + version: (el.version || 1) + 1, + versionNonce: Math.floor(Math.random() * 1000000), + updated: Date.now(), + })); + api.updateScene({ + elements: [...currentElements, ...shiftedElements] 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 (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); + window.history.replaceState(null, '', window.location.pathname + window.location.search); + }); }, [excalidrawAPI]); + useEffect(() => { + processLibraryImport(); + }, [processLibraryImport]); + + useEffect(() => { + const handleHashChange = () => { + if (window.location.hash.includes('addLibrary=')) { + processLibraryImport(); + } + }; + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, [processLibraryImport]); + // 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 +1282,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 +1447,49 @@ export const Editor: React.FC = () => { > -
- - - -
+ {!presentationMode && ( +
+ + + +
+ )} {initialData && ( {t('editor.loadingCanvas')}
}>