mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
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>
This commit is contained in:
@@ -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) {
|
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string, customData?: Record<string, unknown>) {
|
||||||
return {
|
const el: RawElement = {
|
||||||
id: `txt-${Math.random().toString(36).slice(2)}`,
|
id: `txt-${Math.random().toString(36).slice(2)}`,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
|
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,
|
originalText: text,
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.25,
|
||||||
};
|
};
|
||||||
|
if (customData) {
|
||||||
|
el.customData = customData;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCheckbox(x: number, y: number, checked = false) {
|
function makeCheckbox(x: number, y: number, checked = false) {
|
||||||
@@ -158,7 +162,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(60, 210, false),
|
makeCheckbox(60, 210, false),
|
||||||
makeText(90, 210, 'Third task'),
|
makeText(90, 210, 'Third task'),
|
||||||
makeAddButton(60, 250, '+', 'todo-add'),
|
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),
|
makeHandDrawnRect(50, 290, 500, 2),
|
||||||
makeText(60, 310, 'Notes:', 18),
|
makeText(60, 310, 'Notes:', 18),
|
||||||
],
|
],
|
||||||
@@ -172,7 +176,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(60, 210, false),
|
makeCheckbox(60, 210, false),
|
||||||
makeText(90, 210, 'Another task', 18),
|
makeText(90, 210, 'Another task', 18),
|
||||||
makeAddButton(60, 250, '+', 'checklist-add'),
|
makeAddButton(60, 250, '+', 'checklist-add'),
|
||||||
makeText(92, 250, 'Add item...', 16),
|
makeText(92, 250, 'Add item...', 16, undefined, { templateRole: 'checklist-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
list: [
|
list: [
|
||||||
makeHandDrawnRect(50, 50, 500, 50),
|
makeHandDrawnRect(50, 50, 500, 50),
|
||||||
@@ -182,7 +186,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(60, 210, '• Third bullet point'),
|
makeText(60, 210, '• Third bullet point'),
|
||||||
makeText(60, 250, '• Fourth item with details'),
|
makeText(60, 250, '• Fourth item with details'),
|
||||||
makeAddButton(60, 290, '+', 'list-add'),
|
makeAddButton(60, 290, '+', 'list-add'),
|
||||||
makeText(92, 290, 'Add bullet...', 16),
|
makeText(92, 290, 'Add bullet...', 16, undefined, { templateRole: 'list-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
flow: [
|
flow: [
|
||||||
makeHandDrawnRect(200, 50, 200, 60),
|
makeHandDrawnRect(200, 50, 200, 60),
|
||||||
@@ -197,7 +201,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(200, 350, 200, 60),
|
makeHandDrawnRect(200, 350, 200, 60),
|
||||||
makeText(230, 370, 'End', 20),
|
makeText(230, 370, 'End', 20),
|
||||||
makeAddButton(420, 180, '+', 'flow-add'),
|
makeAddButton(420, 180, '+', 'flow-add'),
|
||||||
makeText(452, 180, 'Add step', 14),
|
makeText(452, 180, 'Add step', 14, undefined, { templateRole: 'flow-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
kanban: [
|
kanban: [
|
||||||
makeText(50, 40, 'Kanban Board', 30),
|
makeText(50, 40, 'Kanban Board', 30),
|
||||||
@@ -234,7 +238,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(70, 390, false),
|
makeCheckbox(70, 390, false),
|
||||||
makeText(105, 390, 'Owner and next step', 18),
|
makeText(105, 390, 'Owner and next step', 18),
|
||||||
makeAddButton(70, 430, '+', 'meeting-add-action'),
|
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: [
|
wireframe: [
|
||||||
makeText(50, 35, 'Page Wireframe', 30),
|
makeText(50, 35, 'Page Wireframe', 30),
|
||||||
@@ -248,7 +252,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(265, 380, 190, 110),
|
makeHandDrawnRect(265, 380, 190, 110),
|
||||||
makeHandDrawnRect(480, 380, 190, 110),
|
makeHandDrawnRect(480, 380, 190, 110),
|
||||||
makeAddButton(480, 500, '+', 'wireframe-add-section'),
|
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: [
|
mindmap: [
|
||||||
makeHandDrawnRect(240, 200, 200, 70),
|
makeHandDrawnRect(240, 200, 200, 70),
|
||||||
@@ -285,7 +289,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(520, 196, 'Idea 3', 18),
|
makeText(520, 196, 'Idea 3', 18),
|
||||||
makeArrow(460, 110, 580, 180),
|
makeArrow(460, 110, 580, 180),
|
||||||
makeAddButton(50, 240, '+', 'brainstorm-add'),
|
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
|
// Notes area
|
||||||
makeHandDrawnRect(50, 280, 610, 100),
|
makeHandDrawnRect(50, 280, 610, 100),
|
||||||
makeText(70, 300, 'Notes & connections:', 18),
|
makeText(70, 300, 'Notes & connections:', 18),
|
||||||
@@ -316,7 +320,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(80, 156, 'Branch 6', 18),
|
makeText(80, 156, 'Branch 6', 18),
|
||||||
makeArrow(260, 220, 200, 165),
|
makeArrow(260, 220, 200, 165),
|
||||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-matrix': [
|
||||||
makeText(50, 30, 'Brainstorm — Matrix', 30),
|
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, 350, '- Idea 1', 16),
|
||||||
makeText(400, 380, '- Idea 2', 16),
|
makeText(400, 380, '- Idea 2', 16),
|
||||||
makeAddButton(50, 450, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-freeform': [
|
||||||
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
||||||
@@ -355,7 +359,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(340, 250, 160, 80),
|
makeHandDrawnRect(340, 250, 160, 80),
|
||||||
makeText(360, 280, '✨ Idea 5', 18),
|
makeText(360, 280, '✨ Idea 5', 18),
|
||||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-fishbone': [
|
||||||
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
||||||
@@ -382,7 +386,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(360, 340, 160, 50),
|
makeHandDrawnRect(360, 340, 160, 50),
|
||||||
makeText(380, 358, 'Product', 16),
|
makeText(380, 358, 'Product', 16),
|
||||||
makeAddButton(50, 420, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-venn': [
|
||||||
makeText(50, 30, 'Brainstorm — Venn', 30),
|
makeText(50, 30, 'Brainstorm — Venn', 30),
|
||||||
@@ -399,7 +403,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
// Center overlap note
|
// Center overlap note
|
||||||
makeText(245, 190, 'Overlap', 12),
|
makeText(245, 190, 'Overlap', 12),
|
||||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-tree': [
|
||||||
makeText(50, 30, 'Brainstorm — Tree', 30),
|
makeText(50, 30, 'Brainstorm — Tree', 30),
|
||||||
@@ -427,7 +431,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(440, 290, 140, 40),
|
makeHandDrawnRect(440, 290, 140, 40),
|
||||||
makeText(465, 305, 'Leaf 3a', 14),
|
makeText(465, 305, 'Leaf 3a', 14),
|
||||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-converge': [
|
||||||
makeText(50, 30, 'Brainstorm — Converge', 30),
|
makeText(50, 30, 'Brainstorm — Converge', 30),
|
||||||
@@ -450,7 +454,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(240, 350, 220, 50),
|
makeHandDrawnRect(240, 350, 220, 50),
|
||||||
makeText(265, 370, 'Action Plan', 16),
|
makeText(265, 370, 'Action Plan', 16),
|
||||||
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
||||||
makeText(82, 430, 'Add idea...', 16),
|
makeText(82, 430, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
retrospective: [
|
retrospective: [
|
||||||
makeText(50, 30, 'Retrospective', 30),
|
makeText(50, 30, 'Retrospective', 30),
|
||||||
@@ -529,7 +533,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 300, 600, 2),
|
makeHandDrawnRect(50, 300, 600, 2),
|
||||||
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
||||||
makeAddButton(50, 350, '+', 'storymap-add-row'),
|
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: [
|
timeline: [
|
||||||
makeText(50, 30, 'Project Timeline', 30),
|
makeText(50, 30, 'Project Timeline', 30),
|
||||||
@@ -553,7 +557,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(500, 170, 130, 50),
|
makeHandDrawnRect(500, 170, 130, 50),
|
||||||
makeText(515, 185, 'Deploy', 14),
|
makeText(515, 185, 'Deploy', 14),
|
||||||
makeAddButton(80, 240, '+', 'timeline-add'),
|
makeAddButton(80, 240, '+', 'timeline-add'),
|
||||||
makeText(112, 240, 'Add phase...', 14),
|
makeText(112, 240, 'Add phase...', 14, undefined, { templateRole: 'timeline-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
architecture: [
|
architecture: [
|
||||||
makeText(50, 30, 'System Architecture', 30),
|
makeText(50, 30, 'System Architecture', 30),
|
||||||
@@ -580,7 +584,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 200, 160, 70),
|
makeHandDrawnRect(50, 200, 160, 70),
|
||||||
makeText(90, 220, 'CDN', 18),
|
makeText(90, 220, 'CDN', 18),
|
||||||
makeAddButton(300, 290, '+', 'architecture-add'),
|
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': [
|
'er-diagram': [
|
||||||
makeText(50, 30, 'ER Diagram', 30),
|
makeText(50, 30, 'ER Diagram', 30),
|
||||||
@@ -613,7 +617,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 330, 600, 50),
|
makeHandDrawnRect(50, 330, 600, 50),
|
||||||
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
|
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
|
||||||
makeAddButton(50, 400, '+', 'api-add'),
|
makeAddButton(50, 400, '+', 'api-add'),
|
||||||
makeText(82, 400, 'Add endpoint...', 14),
|
makeText(82, 400, 'Add endpoint...', 14, undefined, { templateRole: 'api-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
'sitemap': [
|
'sitemap': [
|
||||||
makeText(50, 30, 'Site Map', 30),
|
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, 460, 180),
|
||||||
makeArrow(350, 140, 630, 180),
|
makeArrow(350, 140, 630, 180),
|
||||||
makeAddButton(50, 260, '+', 'sitemap-add'),
|
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': [
|
'user-persona': [
|
||||||
makeText(50, 30, 'User Persona', 30),
|
makeText(50, 30, 'User Persona', 30),
|
||||||
@@ -658,7 +662,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 330, 620, 70),
|
makeHandDrawnRect(50, 330, 620, 70),
|
||||||
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
|
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
|
||||||
makeAddButton(50, 420, '+', 'persona-add'),
|
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 {
|
.loadingCanvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -414,14 +437,13 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
animation: presentationFadeIn 0.3s var(--ease-out);
|
animation: presentationFadeIn 0.3s var(--ease-out);
|
||||||
background: rgba(0, 0, 0, 0.85);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
padding: var(--space-8);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes presentationFadeIn {
|
@keyframes presentationFadeIn {
|
||||||
@@ -439,6 +461,7 @@
|
|||||||
padding: var(--space-3) var(--space-5);
|
padding: var(--space-3) var(--space-5);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentationLabel {
|
.presentationLabel {
|
||||||
@@ -459,6 +482,7 @@
|
|||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentationSlideThumb {
|
.presentationSlideThumb {
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ interface LibraryItem {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArrowElement {
|
|
||||||
type: 'arrow';
|
|
||||||
points: number[][];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Slide {
|
interface Slide {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -111,6 +107,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 = () => {
|
export const Editor: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -130,7 +161,8 @@ export const Editor: React.FC = () => {
|
|||||||
const currentStateRef = useRef<EditorState | null>(null);
|
const currentStateRef = useRef<EditorState | null>(null);
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>('');
|
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 lastProcessedAddRef = useRef<string | null>(null);
|
||||||
const saveDrawingRef = useRef<() => Promise<void>>(async () => {});
|
const saveDrawingRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const isMutatingSceneRef = useRef(false);
|
const isMutatingSceneRef = useRef(false);
|
||||||
@@ -144,7 +176,6 @@ export const Editor: React.FC = () => {
|
|||||||
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
||||||
const [slideIndex, setSlideIndex] = useState(0);
|
const [slideIndex, setSlideIndex] = useState(0);
|
||||||
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
|
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
|
||||||
const [notEndingArrow, setNotEndingArrow] = useState(false);
|
|
||||||
|
|
||||||
// Load drawing data
|
// Load drawing data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -169,12 +200,9 @@ export const Editor: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const rawSnapshot = revisionsData[0].snapshot;
|
const rawSnapshot = revisionsData[0].snapshot;
|
||||||
const snapshot = typeof rawSnapshot === 'string' ? JSON.parse(rawSnapshot) : rawSnapshot;
|
const snapshot = typeof rawSnapshot === 'string' ? JSON.parse(rawSnapshot) : rawSnapshot;
|
||||||
setInitialData({
|
setInitialData(normalizeSnapshot(snapshot));
|
||||||
elements: snapshot.elements || [],
|
|
||||||
appState: appStateWithoutGrid(snapshot.appState || {}),
|
|
||||||
files: snapshot.files || {},
|
|
||||||
});
|
|
||||||
lastSavedDataRef.current = JSON.stringify(snapshot);
|
lastSavedDataRef.current = JSON.stringify(snapshot);
|
||||||
|
skipNextUnsavedRef.current = true;
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
console.error('Failed to parse revision snapshot:', parseErr);
|
console.error('Failed to parse revision snapshot:', parseErr);
|
||||||
setInitialData({
|
setInitialData({
|
||||||
@@ -183,18 +211,16 @@ export const Editor: React.FC = () => {
|
|||||||
files: {},
|
files: {},
|
||||||
});
|
});
|
||||||
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
||||||
|
skipNextUnsavedRef.current = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for pending template from dashboard
|
// Check for pending template from dashboard
|
||||||
const pendingTemplate = localStorage.getItem(`template_${id}`);
|
const pendingTemplate = localStorage.getItem(`template_${id}`);
|
||||||
if (pendingTemplate) {
|
if (pendingTemplate) {
|
||||||
const tpl = JSON.parse(pendingTemplate);
|
const tpl = JSON.parse(pendingTemplate);
|
||||||
setInitialData({
|
setInitialData(normalizeSnapshot(tpl));
|
||||||
elements: tpl.elements || [],
|
|
||||||
appState: appStateWithoutGrid(tpl.appState || {}),
|
|
||||||
files: tpl.files || {},
|
|
||||||
});
|
|
||||||
lastSavedDataRef.current = JSON.stringify(tpl);
|
lastSavedDataRef.current = JSON.stringify(tpl);
|
||||||
|
skipNextUnsavedRef.current = true;
|
||||||
localStorage.removeItem(`template_${id}`);
|
localStorage.removeItem(`template_${id}`);
|
||||||
} else {
|
} else {
|
||||||
// Start with empty canvas
|
// Start with empty canvas
|
||||||
@@ -204,6 +230,7 @@ export const Editor: React.FC = () => {
|
|||||||
files: {},
|
files: {},
|
||||||
});
|
});
|
||||||
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
||||||
|
skipNextUnsavedRef.current = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -235,13 +262,28 @@ export const Editor: React.FC = () => {
|
|||||||
};
|
};
|
||||||
// Only mark as unsaved if the data actually differs from last saved
|
// Only mark as unsaved if the data actually differs from last saved
|
||||||
const currentJson = JSON.stringify(currentStateRef.current);
|
const currentJson = JSON.stringify(currentStateRef.current);
|
||||||
if (currentJson !== lastSavedDataRef.current) {
|
if (currentJson !== lastSavedDataRef.current && !skipNextUnsavedRef.current) {
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
saveDrawingRef.current();
|
saveDrawingRef.current();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,8 +294,9 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Handle checkbox toggle
|
// Handle checkbox toggle
|
||||||
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox') {
|
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox') {
|
||||||
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
|
if (excalidrawAPI && !recentlyToggledRef.current.has(selectedEl.id)) {
|
||||||
lastToggledCheckboxRef.current = 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 nextChecked = !((selectedEl.customData as Record<string, unknown> | undefined)?.checked as boolean);
|
||||||
const nextElements = elements.map((el) => (
|
const nextElements = elements.map((el) => (
|
||||||
el.id === selectedEl.id
|
el.id === selectedEl.id
|
||||||
@@ -288,37 +331,37 @@ export const Editor: React.FC = () => {
|
|||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
lastToggledCheckboxRef.current = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle correct/incorrect toggle (cycles: empty → correct → incorrect → empty)
|
// Handle correct/incorrect toggle (cycles: empty → correct → incorrect → empty)
|
||||||
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'correct-incorrect') {
|
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'correct-incorrect') {
|
||||||
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
|
if (excalidrawAPI && !recentlyToggledRef.current.has(selectedEl.id)) {
|
||||||
lastToggledCheckboxRef.current = 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';
|
const currentStatus = ((selectedEl.customData as Record<string, unknown> | undefined)?.status as string) || 'empty';
|
||||||
let nextStatus: string;
|
let nextStatus: string;
|
||||||
|
let nextText: string;
|
||||||
let nextColor: string;
|
let nextColor: string;
|
||||||
let nextFill: 'solid' | 'hachure';
|
|
||||||
if (currentStatus === 'empty') {
|
if (currentStatus === 'empty') {
|
||||||
nextStatus = 'correct';
|
nextStatus = 'correct';
|
||||||
|
nextText = '☑';
|
||||||
nextColor = '#22c55e';
|
nextColor = '#22c55e';
|
||||||
nextFill = 'solid';
|
|
||||||
} else if (currentStatus === 'correct') {
|
} else if (currentStatus === 'correct') {
|
||||||
nextStatus = 'incorrect';
|
nextStatus = 'incorrect';
|
||||||
|
nextText = '☒';
|
||||||
nextColor = '#ef4444';
|
nextColor = '#ef4444';
|
||||||
nextFill = 'solid';
|
|
||||||
} else {
|
} else {
|
||||||
nextStatus = 'empty';
|
nextStatus = 'empty';
|
||||||
|
nextText = '☐';
|
||||||
nextColor = '#1e1e1e';
|
nextColor = '#1e1e1e';
|
||||||
nextFill = 'hachure';
|
|
||||||
}
|
}
|
||||||
const nextElements = elements.map((el) =>
|
const nextElements = elements.map((el) =>
|
||||||
el.id === selectedEl.id
|
el.id === selectedEl.id
|
||||||
? {
|
? {
|
||||||
...el,
|
...el,
|
||||||
backgroundColor: nextStatus === 'empty' ? 'transparent' : nextColor,
|
text: nextText,
|
||||||
fillStyle: nextFill,
|
originalText: nextText,
|
||||||
|
strokeColor: nextColor,
|
||||||
customData: {
|
customData: {
|
||||||
...((el.customData as Record<string, unknown> | undefined) || {}),
|
...((el.customData as Record<string, unknown> | undefined) || {}),
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
@@ -349,14 +392,18 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Handle star rating toggle
|
// Handle star rating toggle
|
||||||
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'star-rating') {
|
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'star-rating') {
|
||||||
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
|
if (excalidrawAPI && !recentlyToggledRef.current.has(selectedEl.id)) {
|
||||||
lastToggledCheckboxRef.current = 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 currentRating = ((selectedEl.customData as Record<string, unknown> | undefined)?.rating as number) || 0;
|
||||||
const nextRating = currentRating >= 5 ? 1 : currentRating + 1;
|
const nextRating = currentRating >= 5 ? 1 : currentRating + 1;
|
||||||
|
const stars = '★'.repeat(nextRating) + '☆'.repeat(5 - nextRating);
|
||||||
const nextElements = elements.map((el) =>
|
const nextElements = elements.map((el) =>
|
||||||
el.id === selectedEl.id
|
el.id === selectedEl.id
|
||||||
? {
|
? {
|
||||||
...el,
|
...el,
|
||||||
|
text: stars,
|
||||||
|
originalText: stars,
|
||||||
customData: {
|
customData: {
|
||||||
...((el.customData as Record<string, unknown> | undefined) || {}),
|
...((el.customData as Record<string, unknown> | undefined) || {}),
|
||||||
rating: nextRating,
|
rating: nextRating,
|
||||||
@@ -388,20 +435,48 @@ export const Editor: React.FC = () => {
|
|||||||
// Handle "+" add button click or "Add task..." text click
|
// Handle "+" add button click or "Add task..." text click
|
||||||
const customData = (selectedEl?.customData as Record<string, unknown> | undefined);
|
const customData = (selectedEl?.customData as Record<string, unknown> | undefined);
|
||||||
const isAddButton = customData?.action === 'add';
|
const isAddButton = customData?.action === 'add';
|
||||||
const isAddText = customData?.templateRole && typeof customData.templateRole === 'string' &&
|
const isAddText = selectedEl?.type === 'text' && typeof selectedEl.text === 'string' &&
|
||||||
(customData.templateRole.startsWith('todo-add') ||
|
((selectedEl.text.toLowerCase().includes('add task') ||
|
||||||
customData.templateRole.startsWith('checklist-add') ||
|
selectedEl.text.toLowerCase().includes('add item') ||
|
||||||
customData.templateRole.startsWith('list-add') ||
|
selectedEl.text.toLowerCase().includes('add bullet') ||
|
||||||
customData.templateRole.startsWith('meeting-add-action'));
|
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 (selectedEl && (isAddButton || isAddText) && excalidrawAPI) {
|
||||||
if (lastProcessedAddRef.current === selectedEl.id) {
|
if (lastProcessedAddRef.current === selectedEl.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastProcessedAddRef.current = selectedEl.id;
|
lastProcessedAddRef.current = selectedEl.id;
|
||||||
const role = customData?.templateRole as string;
|
let role = customData?.templateRole as string;
|
||||||
const btnX = (selectedEl.x as number) || 0;
|
if (!role && isAddText) {
|
||||||
const btnY = (selectedEl.y as number) || 0;
|
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 newElements: LooseElement[] = [];
|
||||||
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
|
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
|
||||||
const tid = () => `txt-${Math.random().toString(36).slice(2)}`;
|
const tid = () => `txt-${Math.random().toString(36).slice(2)}`;
|
||||||
@@ -419,8 +494,8 @@ export const Editor: React.FC = () => {
|
|||||||
(el.text as string)?.toLowerCase().includes('add bullet'))
|
(el.text as string)?.toLowerCase().includes('add bullet'))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add a new checkbox + text row below the button
|
// Add a new checkbox + text row where the button currently is
|
||||||
const newY = btnY + 30;
|
const newY = btnY;
|
||||||
newElements.push({
|
newElements.push({
|
||||||
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 20, height: 20,
|
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 20, height: 20,
|
||||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
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',
|
text: 'New task', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||||
baseline: 16, containerId: null, originalText: 'New task', lineHeight: 1.25,
|
baseline: 16, containerId: null, originalText: 'New task', lineHeight: 1.25,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move the add button and "Add task..." text down, plus any notes line for todo
|
// Move the add button and "Add task..." text down by one slot
|
||||||
const moveDown = newY + 40;
|
const moveDown = btnY + 40;
|
||||||
const updated = elements.map((el) => {
|
const updated = elements.map((el) => {
|
||||||
// Move the add button
|
// Move the add button
|
||||||
if (addButtonEl && el.id === addButtonEl.id) {
|
if (addButtonEl && el.id === addButtonEl.id) {
|
||||||
@@ -566,39 +641,261 @@ export const Editor: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic add: add a text line below
|
// Generic add handler for all templates
|
||||||
if (role?.startsWith('list-add') || role?.startsWith('meeting-add') || role?.startsWith('flow-add') ||
|
const genericRoles = ['list-add', 'meeting-add', 'flow-add', 'brainstorm-add', 'retro-add',
|
||||||
role?.startsWith('brainstorm-add') || role?.startsWith('retro-add') || role?.startsWith('swot-add') ||
|
'swot-add', 'storymap-add', 'wireframe-add', 'timeline-add', 'architecture-add',
|
||||||
role?.startsWith('storymap-add') || role?.startsWith('wireframe-add') || role?.startsWith('timeline-add') ||
|
'api-add', 'sitemap-add', 'persona-add'];
|
||||||
role?.startsWith('architecture-add')) {
|
if (genericRoles.some((r) => role?.startsWith(r))) {
|
||||||
// Find the associated add text to move together
|
const addButtonEl = elements.find(el =>
|
||||||
const addButtonEl = elements.find(el =>
|
el.type === 'rectangle' &&
|
||||||
el.type === 'rectangle' &&
|
|
||||||
(el.customData as Record<string, unknown> | undefined)?.templateRole === role
|
(el.customData as Record<string, unknown> | undefined)?.templateRole === role
|
||||||
);
|
);
|
||||||
const addTextEl = elements.find(el =>
|
const addTextEl = elements.find(el =>
|
||||||
el.type === 'text' &&
|
el.type === 'text' &&
|
||||||
((el.text as string)?.toLowerCase().includes('add') ||
|
(el.customData as Record<string, unknown> | undefined)?.templateRole === role
|
||||||
(el.text as string)?.toLowerCase().includes('step') ||
|
|
||||||
(el.text as string)?.toLowerCase().includes('bullet') ||
|
|
||||||
(el.text as string)?.toLowerCase().includes('action'))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const newY = btnY + 30;
|
const newY = btnY;
|
||||||
newElements.push({
|
let slotHeight = 30;
|
||||||
id: tid(), type: 'text', x: btnX + 30, y: newY, width: 150, height: 22,
|
|
||||||
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
// Role-specific element creation
|
||||||
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
if (role?.startsWith('brainstorm-add')) {
|
||||||
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
|
// Sticky note style: rectangle + text
|
||||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
const gid = `note-${uid()}`;
|
||||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
newElements.push({
|
||||||
text: role?.startsWith('list-add') ? '• New item' : '- New item',
|
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 160, height: 50,
|
||||||
fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
angle: 0, strokeColor: '#1e1e1e', backgroundColor: '#fef3c7', fillStyle: 'solid',
|
||||||
baseline: 14, containerId: null, originalText: role?.startsWith('list-add') ? '• New item' : '- New item', lineHeight: 1.25,
|
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
|
// Move both the add button and the add text
|
||||||
const moveDown = newY + 30;
|
const moveDown = btnY + slotHeight;
|
||||||
const updated = elements.map((el) => {
|
const updated = elements.map((el) => {
|
||||||
if (addButtonEl && el.id === addButtonEl.id) {
|
if (addButtonEl && el.id === addButtonEl.id) {
|
||||||
return { ...el, y: moveDown, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() };
|
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;
|
if (!revision.snapshot) return;
|
||||||
try {
|
try {
|
||||||
const snapshot = typeof revision.snapshot === 'string' ? JSON.parse(revision.snapshot) : revision.snapshot;
|
const snapshot = typeof revision.snapshot === 'string' ? JSON.parse(revision.snapshot) : revision.snapshot;
|
||||||
setInitialData({
|
setInitialData(normalizeSnapshot(snapshot));
|
||||||
elements: snapshot.elements || [],
|
|
||||||
appState: appStateWithoutGrid(snapshot.appState || {}),
|
|
||||||
files: snapshot.files || {},
|
|
||||||
});
|
|
||||||
lastSavedDataRef.current = JSON.stringify(snapshot);
|
lastSavedDataRef.current = JSON.stringify(snapshot);
|
||||||
setSelectedRevision(revision.id);
|
setSelectedRevision(revision.id);
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
@@ -786,163 +1079,100 @@ export const Editor: React.FC = () => {
|
|||||||
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: null },
|
{ 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=...)
|
// Library import from URL hash (#addLibrary=...)
|
||||||
useEffect(() => {
|
const processLibraryImport = React.useCallback(() => {
|
||||||
if (!excalidrawAPI) return;
|
if (!excalidrawAPI) return;
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
const match = hash.match(/addLibrary=([^&]+)/);
|
const match = hash.match(/addLibrary=([^&]+)/);
|
||||||
if (match) {
|
if (!match) return;
|
||||||
const libraryUrl = decodeURIComponent(match[1]);
|
const libraryUrl = decodeURIComponent(match[1]);
|
||||||
fetch(libraryUrl)
|
fetch(libraryUrl)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
throw new Error(`HTTP error! status: ${r.status}`);
|
throw new Error(`HTTP error! status: ${r.status}`);
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
// Excalidraw library items come in various formats
|
// Excalidraw library items come in various formats
|
||||||
let libraryItems = data.libraryItems || data.library || data;
|
let libraryItems = data.libraryItems || data.library || data;
|
||||||
// Normalize to Excalidraw's expected library item format: { id, elements, status }
|
// Normalize to Excalidraw's expected library item format: { id, elements, status }
|
||||||
if (Array.isArray(libraryItems)) {
|
if (Array.isArray(libraryItems)) {
|
||||||
libraryItems = libraryItems.map((item: Record<string, unknown>): LibraryItem => {
|
libraryItems = libraryItems.map((item: Record<string, unknown>): LibraryItem => {
|
||||||
if (item.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' };
|
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) {
|
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' };
|
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) {
|
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 { 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;
|
return item as unknown as LibraryItem;
|
||||||
}).filter((item: LibraryItem) => item.elements && Array.isArray(item.elements) && item.elements.length > 0);
|
}).filter((item: LibraryItem) => item.elements && Array.isArray(item.elements) && item.elements.length > 0);
|
||||||
}
|
}
|
||||||
// Validate libraryItems is a valid array before proceeding
|
// Validate libraryItems is a valid array before proceeding
|
||||||
if (!Array.isArray(libraryItems) || libraryItems.length === 0) {
|
if (!Array.isArray(libraryItems) || libraryItems.length === 0) {
|
||||||
console.warn('Library import failed: No valid library items found');
|
console.warn('Library import failed: No valid library items found');
|
||||||
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Use the Excalidraw imperative API to add library items
|
// Use the Excalidraw imperative API to add library items
|
||||||
try {
|
try {
|
||||||
const api = excalidrawAPI as any;
|
const api = excalidrawAPI as any;
|
||||||
if (api.updateLibraryItems) {
|
if (api.updateLibraryItems) {
|
||||||
api.updateLibraryItems(libraryItems, 'merge');
|
api.updateLibraryItems(libraryItems, 'merge');
|
||||||
} else if (api.updateScene) {
|
} else if (api.updateScene) {
|
||||||
// Fallback: add elements directly to the canvas at center
|
// Fallback: add elements directly to the canvas
|
||||||
const currentElements = api.getSceneElements?.() || [];
|
const currentElements = api.getSceneElements?.() || [];
|
||||||
const newElements = libraryItems.flatMap((item: LibraryItem) => item.elements || []);
|
const importedElements = libraryItems.flatMap((item: LibraryItem) => item.elements || []);
|
||||||
if (newElements.length > 0) {
|
if (importedElements.length > 0) {
|
||||||
api.updateScene({
|
// Offset imported elements to current viewport center
|
||||||
elements: [...currentElements, ...newElements] as ExcalidrawElement[],
|
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 (e) {
|
||||||
})
|
console.warn('Library import failed:', e);
|
||||||
.catch((err) => {
|
}
|
||||||
console.error('Failed to load library:', err);
|
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||||
// Clear the hash even on error to prevent repeated failed attempts
|
})
|
||||||
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]);
|
}, [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
|
// 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
|
// Build slides: first slide is whole canvas, then each frame is a slide
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!presentationMode || !excalidrawAPI) return;
|
if (!presentationMode || !excalidrawAPI) return;
|
||||||
@@ -1052,12 +1282,14 @@ export const Editor: React.FC = () => {
|
|||||||
};
|
};
|
||||||
} else if (type === 'correct-incorrect') {
|
} else if (type === 'correct-incorrect') {
|
||||||
newEl = {
|
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',
|
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||||
strokeWidth: 2, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||||
frameId: null, roundness: { type: 2 }, seed: Math.floor(Math.random() * 10000),
|
frameId: null, roundness: null, seed: Math.floor(Math.random() * 10000),
|
||||||
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
|
||||||
boundElements: [], updated: Date.now(), link: null, locked: false,
|
boundElements: [], updated: Date.now(), link: null, locked: false,
|
||||||
|
text: '☐', fontSize: 24, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
|
||||||
|
baseline: 18, containerId: null, originalText: '☐', lineHeight: 1.25,
|
||||||
customData: { templateRole: 'correct-incorrect', status: 'empty' },
|
customData: { templateRole: 'correct-incorrect', status: 'empty' },
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -1215,64 +1447,49 @@ export const Editor: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className={styles.toolbarDivider} />
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => insertCustomElement('checkbox')}
|
|
||||||
title="Insert checkbox"
|
|
||||||
aria-label="Insert a toggleable checkbox"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
|
||||||
<path d="M9 12l2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => insertCustomElement('correct-incorrect')}
|
|
||||||
title="Insert correct/incorrect"
|
|
||||||
aria-label="Insert a correct/incorrect toggle"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
|
||||||
<path d="M9 12l2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => insertCustomElement('star-rating')}
|
|
||||||
title="Insert star rating"
|
|
||||||
aria-label="Insert a star rating element"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1">
|
|
||||||
<polygon points="12,2 15,9 22,9 17,14 19,21 12,17 5,21 7,14 2,9 9,9" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={notEndingArrow ? 'primary' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setNotEndingArrow(!notEndingArrow);
|
|
||||||
if (excalidrawAPI) {
|
|
||||||
const newTool = !notEndingArrow ? 'arrow' : 'selection';
|
|
||||||
(excalidrawAPI as any).setActiveTool?.({ type: newTool });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Not-ending arrow (draws curved arrow that continues until you click)"
|
|
||||||
aria-label="Toggle not-ending arrow mode"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
|
||||||
<path d="M19 3c-2 2-4 4-4 7 0 2-2 4-4 5" strokeDasharray="2 2" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.canvasWrapper}>
|
<div className={styles.canvasWrapper}>
|
||||||
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates) ? styles.canvasNarrow : ''}`}>
|
<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 && (
|
{initialData && (
|
||||||
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
||||||
<ExcalidrawWithLibrary
|
<ExcalidrawWithLibrary
|
||||||
|
|||||||
Reference in New Issue
Block a user