mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
feat(editor): implement autosave functionality and enhance UI
Docker Images / Build and push (push) Failing after 17s
Docker Images / Build and push (push) Failing after 17s
Implements an autosave mechanism to prevent data loss by periodically sending snapshots of the drawing to the backend. This includes new API endpoints on the server and updated frontend services. Additionally, improves the editor experience with: - Enhanced CSRF protection and origin validation in the backend. - Fix for React "Maximum update depth exceeded" error during scene mutations using a mutation guard. - New presentation slide thumbnails and navigation UI. - Expanded template library with various brainstorming layouts. - Refined dashboard statistics and layout styling. - Improved sidebar logo using SVG for better scaling.
This commit is contained in:
@@ -75,6 +75,7 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoMark {
|
.logoMark {
|
||||||
|
|||||||
@@ -37,13 +37,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
|
|||||||
>
|
>
|
||||||
<div className={styles.sidebarHeader}>
|
<div className={styles.sidebarHeader}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img
|
<svg viewBox="0 0 120 28" className={styles.logoImg} aria-label="Excalidraw+">
|
||||||
src="https://plus.excalidraw.com/images/logo.svg"
|
<text x="0" y="22" fontFamily="Virgil, Segoe UI Emoji, sans-serif" fontSize="20" fontWeight="700" fill="#ffffff">Excalidraw</text>
|
||||||
alt="Excalidraw"
|
<text x="96" y="22" fontFamily="Virgil, Segoe UI Emoji, sans-serif" fontSize="20" fontWeight="700" fill="#ffffff" opacity="0.7">+</text>
|
||||||
className={styles.logoImg}
|
</svg>
|
||||||
width={120}
|
|
||||||
height={28}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork, Lightbulb, RotateCcw, Shield, Map, Timer, Layers } from 'lucide-react';
|
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork, Lightbulb, RotateCcw, Shield, Map, Timer, Layers, Database, Code, Globe, UserCircle } from 'lucide-react';
|
||||||
import { Card } from '@/components';
|
import { Card } from '@/components';
|
||||||
import styles from './TemplatePicker.module.scss';
|
import styles from './TemplatePicker.module.scss';
|
||||||
|
|
||||||
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap' | 'brainstorm' | 'retrospective' | 'swot' | 'storymap' | 'timeline' | 'architecture';
|
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap' | 'brainstorm' | 'brainstorm-star' | 'brainstorm-matrix' | 'brainstorm-freeform' | 'brainstorm-fishbone' | 'brainstorm-venn' | 'brainstorm-tree' | 'brainstorm-converge' | 'retrospective' | 'swot' | 'storymap' | 'timeline' | 'architecture' | 'er-diagram' | 'api-design' | 'sitemap' | 'user-persona';
|
||||||
|
|
||||||
interface TemplatePickerProps {
|
interface TemplatePickerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -291,6 +291,167 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(70, 300, 'Notes & connections:', 18),
|
makeText(70, 300, 'Notes & connections:', 18),
|
||||||
makeText(70, 330, '- Write insights here', 16),
|
makeText(70, 330, '- Write insights here', 16),
|
||||||
],
|
],
|
||||||
|
'brainstorm-star': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Star', 30),
|
||||||
|
// Central hub
|
||||||
|
makeHandDrawnRect(260, 200, 180, 60),
|
||||||
|
makeText(290, 220, 'Core Idea', 22),
|
||||||
|
// 6 radial branches (top, top-right, bottom-right, bottom, bottom-left, top-left)
|
||||||
|
makeHandDrawnRect(280, 60, 140, 50),
|
||||||
|
makeText(300, 76, 'Branch 1', 18),
|
||||||
|
makeArrow(350, 200, 350, 110),
|
||||||
|
makeHandDrawnRect(480, 140, 140, 50),
|
||||||
|
makeText(500, 156, 'Branch 2', 18),
|
||||||
|
makeArrow(440, 220, 480, 165),
|
||||||
|
makeHandDrawnRect(480, 280, 140, 50),
|
||||||
|
makeText(500, 296, 'Branch 3', 18),
|
||||||
|
makeArrow(440, 240, 480, 305),
|
||||||
|
makeHandDrawnRect(280, 320, 140, 50),
|
||||||
|
makeText(300, 336, 'Branch 4', 18),
|
||||||
|
makeArrow(350, 260, 350, 320),
|
||||||
|
makeHandDrawnRect(60, 280, 140, 50),
|
||||||
|
makeText(80, 296, 'Branch 5', 18),
|
||||||
|
makeArrow(260, 240, 200, 305),
|
||||||
|
makeHandDrawnRect(60, 140, 140, 50),
|
||||||
|
makeText(80, 156, 'Branch 6', 18),
|
||||||
|
makeArrow(260, 220, 200, 165),
|
||||||
|
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 400, 'Add branch...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-matrix': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Matrix', 30),
|
||||||
|
// 2x2 grid
|
||||||
|
makeHandDrawnRect(50, 90, 300, 160),
|
||||||
|
makeText(150, 130, 'Quadrant A', 20),
|
||||||
|
makeText(80, 170, '- Idea 1', 16),
|
||||||
|
makeText(80, 200, '- Idea 2', 16),
|
||||||
|
makeHandDrawnRect(370, 90, 300, 160),
|
||||||
|
makeText(470, 130, 'Quadrant B', 20),
|
||||||
|
makeText(400, 170, '- Idea 1', 16),
|
||||||
|
makeText(400, 200, '- Idea 2', 16),
|
||||||
|
makeHandDrawnRect(50, 270, 300, 160),
|
||||||
|
makeText(150, 310, 'Quadrant C', 20),
|
||||||
|
makeText(80, 350, '- Idea 1', 16),
|
||||||
|
makeText(80, 380, '- Idea 2', 16),
|
||||||
|
makeHandDrawnRect(370, 270, 300, 160),
|
||||||
|
makeText(470, 310, 'Quadrant D', 20),
|
||||||
|
makeText(400, 350, '- Idea 1', 16),
|
||||||
|
makeText(400, 380, '- Idea 2', 16),
|
||||||
|
makeAddButton(50, 450, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 450, 'Add idea...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-freeform': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
||||||
|
makeText(50, 70, 'Drag sticky notes anywhere!', 16),
|
||||||
|
// Scattered sticky notes
|
||||||
|
makeHandDrawnRect(60, 110, 160, 80),
|
||||||
|
makeText(80, 140, '💡 Idea 1', 18),
|
||||||
|
makeHandDrawnRect(260, 130, 160, 80),
|
||||||
|
makeText(280, 160, '🚀 Idea 2', 18),
|
||||||
|
makeHandDrawnRect(460, 110, 160, 80),
|
||||||
|
makeText(480, 140, '🎯 Idea 3', 18),
|
||||||
|
makeHandDrawnRect(120, 230, 160, 80),
|
||||||
|
makeText(140, 260, '❓ Idea 4', 18),
|
||||||
|
makeHandDrawnRect(340, 250, 160, 80),
|
||||||
|
makeText(360, 280, '✨ Idea 5', 18),
|
||||||
|
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 360, 'Add note...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-fishbone': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
||||||
|
// Spine
|
||||||
|
makeArrow(100, 250, 600, 250),
|
||||||
|
// Problem head
|
||||||
|
makeHandDrawnRect(600, 220, 140, 60),
|
||||||
|
makeText(620, 240, 'Problem', 18),
|
||||||
|
// Top branches
|
||||||
|
makeArrow(220, 250, 180, 160),
|
||||||
|
makeHandDrawnRect(120, 110, 160, 50),
|
||||||
|
makeText(140, 128, 'People', 16),
|
||||||
|
makeArrow(380, 250, 340, 160),
|
||||||
|
makeHandDrawnRect(280, 110, 160, 50),
|
||||||
|
makeText(300, 128, 'Process', 16),
|
||||||
|
makeArrow(540, 250, 500, 160),
|
||||||
|
makeHandDrawnRect(440, 110, 160, 50),
|
||||||
|
makeText(460, 128, 'Policy', 16),
|
||||||
|
// Bottom branches
|
||||||
|
makeArrow(280, 250, 240, 340),
|
||||||
|
makeHandDrawnRect(180, 340, 160, 50),
|
||||||
|
makeText(200, 358, 'Place', 16),
|
||||||
|
makeArrow(460, 250, 420, 340),
|
||||||
|
makeHandDrawnRect(360, 340, 160, 50),
|
||||||
|
makeText(380, 358, 'Product', 16),
|
||||||
|
makeAddButton(50, 420, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 420, 'Add cause...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-venn': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Venn', 30),
|
||||||
|
// Three overlapping circles
|
||||||
|
makeHandDrawnRect(120, 120, 160, 160),
|
||||||
|
makeText(170, 130, 'A', 20),
|
||||||
|
makeText(140, 190, 'Set A traits', 14),
|
||||||
|
makeHandDrawnRect(280, 120, 160, 160),
|
||||||
|
makeText(330, 130, 'B', 20),
|
||||||
|
makeText(300, 190, 'Set B traits', 14),
|
||||||
|
makeHandDrawnRect(200, 220, 160, 160),
|
||||||
|
makeText(250, 230, 'C', 20),
|
||||||
|
makeText(220, 290, 'Set C traits', 14),
|
||||||
|
// Center overlap note
|
||||||
|
makeText(245, 190, 'Overlap', 12),
|
||||||
|
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 400, 'Add set...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-tree': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Tree', 30),
|
||||||
|
// Root
|
||||||
|
makeHandDrawnRect(280, 90, 160, 50),
|
||||||
|
makeText(310, 110, 'Root Topic', 18),
|
||||||
|
// Branches
|
||||||
|
makeArrow(360, 140, 200, 200),
|
||||||
|
makeHandDrawnRect(120, 190, 160, 50),
|
||||||
|
makeText(145, 210, 'Branch 1', 16),
|
||||||
|
makeArrow(360, 140, 360, 200),
|
||||||
|
makeHandDrawnRect(280, 190, 160, 50),
|
||||||
|
makeText(305, 210, 'Branch 2', 16),
|
||||||
|
makeArrow(360, 140, 520, 200),
|
||||||
|
makeHandDrawnRect(440, 190, 160, 50),
|
||||||
|
makeText(465, 210, 'Branch 3', 16),
|
||||||
|
// Leaves
|
||||||
|
makeArrow(200, 240, 140, 300),
|
||||||
|
makeHandDrawnRect(60, 290, 140, 40),
|
||||||
|
makeText(85, 305, 'Leaf 1a', 14),
|
||||||
|
makeArrow(200, 240, 260, 300),
|
||||||
|
makeHandDrawnRect(200, 290, 140, 40),
|
||||||
|
makeText(225, 305, 'Leaf 1b', 14),
|
||||||
|
makeArrow(520, 240, 460, 300),
|
||||||
|
makeHandDrawnRect(440, 290, 140, 40),
|
||||||
|
makeText(465, 305, 'Leaf 3a', 14),
|
||||||
|
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 360, 'Add branch...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-converge': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Converge', 30),
|
||||||
|
// Diverging ideas (top)
|
||||||
|
makeHandDrawnRect(80, 90, 140, 50),
|
||||||
|
makeText(105, 110, 'Idea A', 16),
|
||||||
|
makeHandDrawnRect(280, 90, 140, 50),
|
||||||
|
makeText(305, 110, 'Idea B', 16),
|
||||||
|
makeHandDrawnRect(480, 90, 140, 50),
|
||||||
|
makeText(505, 110, 'Idea C', 16),
|
||||||
|
// Converging arrows
|
||||||
|
makeArrow(150, 140, 290, 220),
|
||||||
|
makeArrow(350, 140, 330, 220),
|
||||||
|
makeArrow(550, 140, 370, 220),
|
||||||
|
// Converged outcome
|
||||||
|
makeHandDrawnRect(220, 220, 260, 70),
|
||||||
|
makeText(260, 245, 'Combined Solution', 20),
|
||||||
|
// Next steps
|
||||||
|
makeArrow(350, 290, 350, 350),
|
||||||
|
makeHandDrawnRect(240, 350, 220, 50),
|
||||||
|
makeText(265, 370, 'Action Plan', 16),
|
||||||
|
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 430, 'Add idea...', 16),
|
||||||
|
],
|
||||||
retrospective: [
|
retrospective: [
|
||||||
makeText(50, 30, 'Retrospective', 30),
|
makeText(50, 30, 'Retrospective', 30),
|
||||||
// Went Well
|
// Went Well
|
||||||
@@ -421,6 +582,84 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeAddButton(300, 290, '+', 'architecture-add'),
|
makeAddButton(300, 290, '+', 'architecture-add'),
|
||||||
makeText(332, 290, 'Add component...', 14),
|
makeText(332, 290, 'Add component...', 14),
|
||||||
],
|
],
|
||||||
|
'er-diagram': [
|
||||||
|
makeText(50, 30, 'ER Diagram', 30),
|
||||||
|
// User entity
|
||||||
|
makeHandDrawnRect(50, 90, 160, 120),
|
||||||
|
makeText(70, 110, 'User', 18),
|
||||||
|
makeText(70, 140, 'id: PK', 14),
|
||||||
|
makeText(70, 164, 'email: string', 14),
|
||||||
|
makeText(70, 188, 'name: string', 14),
|
||||||
|
// Relationship line
|
||||||
|
makeArrow(210, 150, 270, 150),
|
||||||
|
makeText(220, 130, '1:N', 12),
|
||||||
|
// Order entity
|
||||||
|
makeHandDrawnRect(270, 90, 160, 120),
|
||||||
|
makeText(290, 110, 'Order', 18),
|
||||||
|
makeText(290, 140, 'id: PK', 14),
|
||||||
|
makeText(290, 164, 'user_id: FK', 14),
|
||||||
|
makeText(290, 188, 'total: number', 14),
|
||||||
|
],
|
||||||
|
'api-design': [
|
||||||
|
makeText(50, 30, 'API Design', 30),
|
||||||
|
makeHandDrawnRect(50, 90, 600, 50),
|
||||||
|
makeText(70, 110, 'GET /users → List users', 16),
|
||||||
|
makeHandDrawnRect(50, 150, 600, 50),
|
||||||
|
makeText(70, 170, 'POST /users → Create user', 16),
|
||||||
|
makeHandDrawnRect(50, 210, 600, 50),
|
||||||
|
makeText(70, 230, 'GET /users/:id → Get user', 16),
|
||||||
|
makeHandDrawnRect(50, 270, 600, 50),
|
||||||
|
makeText(70, 290, 'PATCH /users/:id → Update user', 16),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
'sitemap': [
|
||||||
|
makeText(50, 30, 'Site Map', 30),
|
||||||
|
// Home
|
||||||
|
makeHandDrawnRect(280, 90, 140, 50),
|
||||||
|
makeText(320, 112, 'Home', 18),
|
||||||
|
// Pages below
|
||||||
|
makeHandDrawnRect(50, 180, 140, 50),
|
||||||
|
makeText(75, 202, 'Products', 16),
|
||||||
|
makeHandDrawnRect(220, 180, 140, 50),
|
||||||
|
makeText(250, 202, 'Pricing', 16),
|
||||||
|
makeHandDrawnRect(390, 180, 140, 50),
|
||||||
|
makeText(420, 202, 'About', 16),
|
||||||
|
makeHandDrawnRect(560, 180, 140, 50),
|
||||||
|
makeText(585, 202, 'Contact', 16),
|
||||||
|
// Connections
|
||||||
|
makeArrow(350, 140, 120, 180),
|
||||||
|
makeArrow(350, 140, 290, 180),
|
||||||
|
makeArrow(350, 140, 460, 180),
|
||||||
|
makeArrow(350, 140, 630, 180),
|
||||||
|
makeAddButton(50, 260, '+', 'sitemap-add'),
|
||||||
|
makeText(82, 260, 'Add page...', 14),
|
||||||
|
],
|
||||||
|
'user-persona': [
|
||||||
|
makeText(50, 30, 'User Persona', 30),
|
||||||
|
// Name & role
|
||||||
|
makeHandDrawnRect(50, 90, 300, 70),
|
||||||
|
makeText(70, 112, 'Name: Alex, 32, Designer', 18),
|
||||||
|
// Goals
|
||||||
|
makeHandDrawnRect(50, 180, 300, 130),
|
||||||
|
makeText(70, 200, 'Goals 🎯', 18),
|
||||||
|
makeText(70, 230, '- Save time on workflows', 14),
|
||||||
|
makeText(70, 254, '- Collaborate easily', 14),
|
||||||
|
// Frustrations
|
||||||
|
makeHandDrawnRect(370, 90, 300, 220),
|
||||||
|
makeText(390, 112, 'Frustrations 😤', 18),
|
||||||
|
makeText(390, 142, '- Too many tools', 14),
|
||||||
|
makeText(390, 166, '- Slow feedback loops', 14),
|
||||||
|
makeText(390, 190, '- Hard to share ideas', 14),
|
||||||
|
makeText(390, 214, '- No single source of truth', 14),
|
||||||
|
// Behaviors
|
||||||
|
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),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPTIONS: TemplateOption[] = [
|
const OPTIONS: TemplateOption[] = [
|
||||||
@@ -434,11 +673,22 @@ const OPTIONS: TemplateOption[] = [
|
|||||||
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
|
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
|
||||||
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: GitFork, elements: [] },
|
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: GitFork, elements: [] },
|
||||||
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a central topic', icon: Lightbulb, elements: [] },
|
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a central topic', icon: Lightbulb, elements: [] },
|
||||||
|
{ id: 'brainstorm-star', label: 'Star Brainstorm', description: 'Radial branches from core idea', icon: Lightbulb, elements: [] },
|
||||||
|
{ id: 'brainstorm-matrix', label: 'Matrix Brainstorm', description: '2×2 grid for categorizing ideas', icon: Lightbulb, elements: [] },
|
||||||
|
{ id: 'brainstorm-freeform', label: 'Freeform Notes', description: 'Scattered sticky notes layout', icon: Lightbulb, elements: [] },
|
||||||
|
{ id: 'brainstorm-fishbone', label: 'Fishbone Diagram', description: 'Root-cause analysis with causes', icon: Lightbulb, elements: [] },
|
||||||
|
{ id: 'brainstorm-venn', label: 'Venn Diagram', description: 'Compare overlapping sets', icon: Lightbulb, elements: [] },
|
||||||
|
{ id: 'brainstorm-tree', label: 'Tree Diagram', description: 'Hierarchical branching topics', icon: Lightbulb, elements: [] },
|
||||||
|
{ id: 'brainstorm-converge', label: 'Converge Map', description: 'Ideas merging into a solution', icon: Lightbulb, elements: [] },
|
||||||
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: RotateCcw, elements: [] },
|
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: RotateCcw, elements: [] },
|
||||||
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opportunities, threats', icon: Shield, elements: [] },
|
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opportunities, threats', icon: Shield, elements: [] },
|
||||||
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: Map, elements: [] },
|
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: Map, elements: [] },
|
||||||
{ id: 'timeline', label: 'Timeline', description: 'Project phases and milestones', icon: Timer, elements: [] },
|
{ id: 'timeline', label: 'Timeline', description: 'Project phases and milestones', icon: Timer, elements: [] },
|
||||||
{ id: 'architecture', label: 'Architecture', description: 'System components and connections', icon: Layers, elements: [] },
|
{ id: 'architecture', label: 'Architecture', description: 'System components and connections', icon: Layers, elements: [] },
|
||||||
|
{ id: 'er-diagram', label: 'ER Diagram', description: 'Entity relationship with tables', icon: Database, elements: [] },
|
||||||
|
{ id: 'api-design', label: 'API Design', description: 'REST endpoints and methods', icon: Code, elements: [] },
|
||||||
|
{ id: 'sitemap', label: 'Site Map', description: 'Website page hierarchy', icon: Globe, elements: [] },
|
||||||
|
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: UserCircle, elements: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
|
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
|
||||||
|
|||||||
@@ -85,10 +85,12 @@
|
|||||||
border: 1px solid var(--default-border-color);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
transition: box-shadow 0.15s ease;
|
transition: all 0.2s var(--ease-out);
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: var(--shadow-island-stronger);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +100,9 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
min-height: 150px;
|
min-height: 140px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statTop {
|
.statTop {
|
||||||
@@ -106,44 +110,55 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statIcon {
|
.statIcon {
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--color-primary-light);
|
background: var(--color-surface-low);
|
||||||
border: 1px solid var(--default-border-color);
|
border: 1px solid var(--color-gray-20);
|
||||||
}
|
opacity: 0.9;
|
||||||
|
|
||||||
.sparkline {
|
|
||||||
width: 100%;
|
|
||||||
height: 28px;
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.statValue {
|
.statValue {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statLabel {
|
.statLabel {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statBarTrack {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-gray-20);
|
||||||
|
border-radius: var(--border-radius-full);
|
||||||
|
margin-top: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statBarFill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--border-radius-full);
|
||||||
|
transition: width 0.6s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressBarWrap {
|
.progressBarWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
margin-top: var(--space-3);
|
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -326,20 +341,26 @@
|
|||||||
|
|
||||||
.activityItem {
|
.activityItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) 0;
|
padding: var(--space-3) var(--space-2);
|
||||||
border-bottom: 1px solid var(--color-gray-20);
|
border-bottom: 1px solid var(--color-gray-20);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-low);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityAvatar {
|
.activityAvatar {
|
||||||
width: 32px;
|
width: 34px;
|
||||||
height: 32px;
|
height: 34px;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-full);
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -348,17 +369,19 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid var(--default-border-color);
|
border: 2px solid var(--island-bg-color);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityInfo {
|
.activityInfo {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityText {
|
.activityText {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-gray-80);
|
color: var(--color-gray-80);
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityTime {
|
.activityTime {
|
||||||
|
|||||||
@@ -9,43 +9,15 @@ import styles from './Dashboard.module.scss';
|
|||||||
|
|
||||||
const ACTIVITY_LIMIT = 5;
|
const ACTIVITY_LIMIT = 5;
|
||||||
|
|
||||||
const ProgressBar: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
|
const StatBar: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
|
||||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div className={styles.progressBarWrap} aria-hidden="true">
|
<div className={styles.statBarTrack} aria-hidden="true">
|
||||||
<div className={styles.progressBarBg} />
|
<div
|
||||||
<div className={styles.progressBarFill} style={{ width: `${pct}%`, background: color }} />
|
className={styles.statBarFill}
|
||||||
</div>
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MiniSparkline: React.FC<{ data: number[]; color?: string }> = ({ data, color = '#6965db' }) => {
|
|
||||||
if (!data.length) return null;
|
|
||||||
const w = 140;
|
|
||||||
const h = 40;
|
|
||||||
const max = Math.max(...data, 1);
|
|
||||||
const min = Math.min(...data, 0);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const stepX = w / (data.length - 1 || 1);
|
|
||||||
|
|
||||||
const points = data.map((v, i) => {
|
|
||||||
const x = i * stepX;
|
|
||||||
const y = h - ((v - min) / range) * (h - 4) - 2;
|
|
||||||
return `${x},${y}`;
|
|
||||||
}).join(' ');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg className={styles.sparkline} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
|
|
||||||
<polyline
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
points={points}
|
|
||||||
opacity="0.5"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,13 +85,6 @@ export const Dashboard: React.FC = () => {
|
|||||||
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
|
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
|
||||||
|
|
||||||
const statColors = ['#6965db', '#339af0', '#40c057', '#fcc419', '#ff6b6b'];
|
const statColors = ['#6965db', '#339af0', '#40c057', '#fcc419', '#ff6b6b'];
|
||||||
const sparkData = [
|
|
||||||
[2, 4, 3, 8, 5, 9, statsData.drawings],
|
|
||||||
[1, 2, 3, 3, 4, 5, statsData.projects + statsData.folders],
|
|
||||||
[1, 1, 1, 1, 2, 2, statsData.teams],
|
|
||||||
[5, 8, 12, 15, 20, 25, statsData.revisions],
|
|
||||||
[1024, 2048, 4096, 8192, 16384, 32768, Number(statsData.storage_bytes)],
|
|
||||||
];
|
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText, color: statColors[0] },
|
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText, color: statColors[0] },
|
||||||
@@ -172,18 +137,17 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.statsGrid}>
|
<div className={styles.statsGrid}>
|
||||||
{stats.map((stat, idx) => (
|
{stats.map((stat) => (
|
||||||
<Card key={stat.label} className={styles.statCardWrapper}>
|
<Card key={stat.label} className={styles.statCardWrapper}>
|
||||||
<CardContent className={styles.statCard}>
|
<CardContent className={styles.statCard}>
|
||||||
<div className={styles.statTop}>
|
<div className={styles.statTop}>
|
||||||
<div className={styles.statIcon} style={{ color: stat.color, borderColor: stat.color }}>
|
<div className={styles.statIcon} style={{ color: stat.color, borderColor: stat.color }}>
|
||||||
<stat.icon size={22} />
|
<stat.icon size={22} />
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar value={stat.chartValue} max={stat.max} color={stat.color} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statValue} style={{ color: stat.color }}>{stat.value}</div>
|
<div className={styles.statValue} style={{ color: stat.color }}>{stat.value}</div>
|
||||||
<div className={styles.statLabel}>{stat.label}</div>
|
<div className={styles.statLabel}>{stat.label}</div>
|
||||||
<MiniSparkline data={sparkData[idx]} color={stat.color} />
|
<StatBar value={stat.chartValue} max={stat.max} color={stat.color} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -73,11 +73,23 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
:global(.excalidraw) {
|
:global(.excalidraw) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.excalidrew-wrapper) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Excalidraw's internal layout fills the container
|
||||||
|
:global(.excalidraw .excalidraw-canvas-container) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadingCanvas {
|
.loadingCanvas {
|
||||||
@@ -420,6 +432,70 @@
|
|||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presentationSlides {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
border: 1px solid var(--default-border-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: var(--space-2);
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
|
max-width: 400px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentationSlideThumb {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 56px;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--color-gray-20);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
color: var(--color-gray-70);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-gray-10);
|
||||||
|
border-color: var(--color-gray-30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentationSlideActive {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentationSlideNumber {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--border-radius-full);
|
||||||
|
background: var(--color-gray-20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentationSlideActive .presentationSlideNumber {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentationSlideName {
|
||||||
|
font-size: 10px;
|
||||||
|
max-width: 60px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.modalOverlay {
|
.modalOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, StickyNote, LayoutTemplate, MonitorPlay, X, Plus } from 'lucide-react';
|
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, ChevronLeft, StickyNote, LayoutTemplate, MonitorPlay, X, Plus, Frame } from 'lucide-react';
|
||||||
import { Button } from '@/components';
|
import { Button } from '@/components';
|
||||||
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
@@ -99,6 +99,9 @@ export const Editor: React.FC = () => {
|
|||||||
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 lastToggledCheckboxRef = useRef<string | null>(null);
|
||||||
|
const lastProcessedAddRef = useRef<string | null>(null);
|
||||||
|
const saveDrawingRef = useRef<() => Promise<void>>(async () => {});
|
||||||
|
const isMutatingSceneRef = useRef(false);
|
||||||
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
|
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||||
|
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
@@ -107,6 +110,8 @@ export const Editor: React.FC = () => {
|
|||||||
const [templateName, setTemplateName] = useState('');
|
const [templateName, setTemplateName] = useState('');
|
||||||
const [templateDesc, setTemplateDesc] = useState('');
|
const [templateDesc, setTemplateDesc] = useState('');
|
||||||
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
||||||
|
const [slideIndex, setSlideIndex] = useState(0);
|
||||||
|
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
|
||||||
|
|
||||||
// Load drawing data
|
// Load drawing data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -187,6 +192,22 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Handle changes from Excalidraw
|
// Handle changes from Excalidraw
|
||||||
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
||||||
|
// Skip mutation processing if we are in the middle of applying a scene mutation
|
||||||
|
// to prevent React error #185 (Maximum update depth exceeded)
|
||||||
|
if (isMutatingSceneRef.current) {
|
||||||
|
currentStateRef.current = {
|
||||||
|
elements: elements as unknown as ExcalidrawElement[],
|
||||||
|
appState: appStateWithoutGrid(appState),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveDrawingRef.current();
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
||||||
const selectedEl = selectedIds.length === 1
|
const selectedEl = selectedIds.length === 1
|
||||||
? elements.find((el) => el.id === selectedIds[0] && !el.isDeleted)
|
? elements.find((el) => el.id === selectedIds[0] && !el.isDeleted)
|
||||||
@@ -213,11 +234,19 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
: el
|
: el
|
||||||
));
|
));
|
||||||
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
|
const nextEls = nextElements;
|
||||||
|
const nextAppState = appStateWithoutGrid(appState);
|
||||||
|
const nextFiles = files;
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
// Defer updateScene to prevent synchronous re-trigger of onChange (React error #185)
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: nextEls as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
currentStateRef.current = {
|
currentStateRef.current = {
|
||||||
elements: nextElements,
|
elements: nextEls,
|
||||||
appState: appStateWithoutGrid(appState),
|
appState: nextAppState,
|
||||||
files,
|
files: nextFiles,
|
||||||
};
|
};
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
@@ -228,6 +257,10 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Handle "+" add button click
|
// Handle "+" add button click
|
||||||
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
|
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
|
||||||
|
if (lastProcessedAddRef.current === selectedEl.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastProcessedAddRef.current = selectedEl.id;
|
||||||
const customData = (selectedEl.customData as Record<string, unknown>) || {};
|
const customData = (selectedEl.customData as Record<string, unknown>) || {};
|
||||||
const role = customData.templateRole as string;
|
const role = customData.templateRole as string;
|
||||||
const btnX = (selectedEl.x as number) || 0;
|
const btnX = (selectedEl.x as number) || 0;
|
||||||
@@ -264,7 +297,12 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const merged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: merged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -299,7 +337,12 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + cardH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + cardH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const kanbanMerged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: kanbanMerged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,7 +391,12 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + nodeH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + nodeH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const mindmapMerged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: mindmapMerged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -375,10 +423,17 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const genericMerged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: genericMerged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
lastProcessedAddRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStateRef.current = {
|
currentStateRef.current = {
|
||||||
@@ -391,45 +446,30 @@ export const Editor: React.FC = () => {
|
|||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
saveDrawing();
|
saveDrawingRef.current();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
// Auto-save functionality
|
// Auto-save: updates drawing snapshot directly without creating a revision
|
||||||
const saveDrawing = useCallback(async () => {
|
const saveDrawing = useCallback(async () => {
|
||||||
if (!id || !currentStateRef.current || isSaving) return;
|
if (!id || !currentStateRef.current) return;
|
||||||
|
|
||||||
const { elements, appState, files } = currentStateRef.current;
|
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
type: 'excalidraw',
|
type: 'excalidraw',
|
||||||
version: 2,
|
version: 2,
|
||||||
source: window.location.hostname,
|
source: window.location.hostname,
|
||||||
elements,
|
elements: currentStateRef.current.elements,
|
||||||
appState: {
|
appState: currentStateRef.current.appState,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
files: currentStateRef.current.files,
|
||||||
gridSize: appState.gridSize,
|
|
||||||
gridStep: appState.gridStep,
|
|
||||||
gridModeEnabled: appState.gridModeEnabled,
|
|
||||||
theme: appState.theme,
|
|
||||||
zenModeEnabled: appState.zenModeEnabled,
|
|
||||||
viewModeEnabled: appState.viewModeEnabled,
|
|
||||||
editingGroup: appState.editingGroup,
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
},
|
|
||||||
files,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshotJson = JSON.stringify(snapshot);
|
const snapshotJson = JSON.stringify(snapshot);
|
||||||
if (snapshotJson === lastSavedDataRef.current) {
|
if (snapshotJson === lastSavedDataRef.current) {
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
await api.revisions.create(id, snapshot, 'Auto-save');
|
await api.drawings.autosave(id, snapshot);
|
||||||
lastSavedDataRef.current = snapshotJson;
|
lastSavedDataRef.current = snapshotJson;
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -438,10 +478,16 @@ export const Editor: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [id, isSaving]);
|
}, [id]);
|
||||||
|
|
||||||
|
// Keep ref in sync with latest saveDrawing closure
|
||||||
|
useEffect(() => {
|
||||||
|
saveDrawingRef.current = saveDrawing;
|
||||||
|
}, [saveDrawing]);
|
||||||
|
|
||||||
// Remove unused revisions warning by displaying count in UI
|
// Remove unused revisions warning by displaying count in UI
|
||||||
const revisionCount = revisions.length;
|
const meaningfulRevisions = revisions.filter((r) => r.change_summary !== 'Auto-save');
|
||||||
|
const revisionCount = meaningfulRevisions.length;
|
||||||
|
|
||||||
// Restore a specific revision
|
// Restore a specific revision
|
||||||
const handleRestoreRevision = (revision: DrawingRevision) => {
|
const handleRestoreRevision = (revision: DrawingRevision) => {
|
||||||
@@ -461,12 +507,40 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Manual save
|
// Manual save: creates a named revision
|
||||||
const handleManualSave = async () => {
|
const handleManualSave = async () => {
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (!id || !currentStateRef.current) return;
|
||||||
|
const snapshot = {
|
||||||
|
type: 'excalidraw',
|
||||||
|
version: 2,
|
||||||
|
source: window.location.hostname,
|
||||||
|
elements: currentStateRef.current.elements,
|
||||||
|
appState: currentStateRef.current.appState,
|
||||||
|
files: currentStateRef.current.files,
|
||||||
|
};
|
||||||
|
const snapshotJson = JSON.stringify(snapshot);
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveStatus('saving');
|
||||||
|
// Create a named revision for manual save
|
||||||
|
await api.revisions.create(id, snapshot, 'Manual save');
|
||||||
|
lastSavedDataRef.current = snapshotJson;
|
||||||
|
setSaveStatus('saved');
|
||||||
|
// Refresh revisions list
|
||||||
|
try {
|
||||||
|
const revData = await api.revisions.list(id);
|
||||||
|
setRevisions(revData);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save:', err);
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
await saveDrawing();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ctrl+S keyboard shortcut
|
// Ctrl+S keyboard shortcut
|
||||||
@@ -523,9 +597,20 @@ export const Editor: React.FC = () => {
|
|||||||
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
|
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
|
||||||
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: null },
|
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: null },
|
||||||
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a topic', icon: null },
|
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a topic', icon: null },
|
||||||
|
{ id: 'brainstorm-star', label: 'Star Brainstorm', description: 'Radial branches from core', icon: null },
|
||||||
|
{ id: 'brainstorm-matrix', label: 'Matrix Brainstorm', description: '2×2 grid for ideas', icon: null },
|
||||||
|
{ id: 'brainstorm-freeform', label: 'Freeform Notes', description: 'Scattered sticky notes', icon: null },
|
||||||
|
{ id: 'brainstorm-fishbone', label: 'Fishbone Diagram', description: 'Root-cause analysis', icon: null },
|
||||||
|
{ id: 'brainstorm-venn', label: 'Venn Diagram', description: 'Compare overlapping sets', icon: null },
|
||||||
|
{ id: 'brainstorm-tree', label: 'Tree Diagram', description: 'Hierarchical branching', icon: null },
|
||||||
|
{ id: 'brainstorm-converge', label: 'Converge Map', description: 'Ideas into solution', icon: null },
|
||||||
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: null },
|
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: null },
|
||||||
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opps, threats', icon: null },
|
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opps, threats', icon: null },
|
||||||
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: null },
|
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: null },
|
||||||
|
{ id: 'er-diagram', label: 'ER Diagram', description: 'Entity relationship tables', icon: null },
|
||||||
|
{ id: 'api-design', label: 'API Design', description: 'REST endpoints and methods', icon: null },
|
||||||
|
{ id: 'sitemap', label: 'Site Map', description: 'Website page hierarchy', icon: null },
|
||||||
|
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -540,6 +625,122 @@ export const Editor: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
|
// Library import from URL hash (#addLibrary=...)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!excalidrawAPI) return;
|
||||||
|
const hash = window.location.hash;
|
||||||
|
const match = hash.match(/addLibrary=([^&]+)/);
|
||||||
|
if (match) {
|
||||||
|
const libraryUrl = decodeURIComponent(match[1]);
|
||||||
|
fetch(libraryUrl)
|
||||||
|
.then((r) => 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: any) => {
|
||||||
|
if (item.libraryItem) {
|
||||||
|
return { id: item.id || item.libraryItem.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.libraryItem.elements || [], status: 'published' };
|
||||||
|
}
|
||||||
|
if (item.data) {
|
||||||
|
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.data.elements || item.elements || [], status: 'published' };
|
||||||
|
}
|
||||||
|
if (item.elements) {
|
||||||
|
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements, status: 'published' };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 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: any) => item.elements || []);
|
||||||
|
if (newElements.length > 0) {
|
||||||
|
api.updateScene({
|
||||||
|
elements: [...currentElements, ...newElements] as ExcalidrawElement[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Library import failed:', e);
|
||||||
|
}
|
||||||
|
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to load library:', err));
|
||||||
|
}
|
||||||
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
|
// Build slides: first slide is whole canvas, then each frame is a slide
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentationMode || !excalidrawAPI) return;
|
||||||
|
const currentElements = (excalidrawAPI.getSceneElements?.() || []) as ExcalidrawElement[];
|
||||||
|
const frameElements = currentElements
|
||||||
|
.filter((el: any) => el.type === 'frame')
|
||||||
|
.sort((a: any, b: any) => (a.y - b.y) || (a.x - b.x));
|
||||||
|
const allSlides: ExcalidrawElement[] = [];
|
||||||
|
// Slide 0: whole canvas (represented by a virtual placeholder)
|
||||||
|
if (currentElements.length > 0) {
|
||||||
|
allSlides.push({ id: '__whole_canvas__', type: 'frame', x: 0, y: 0, width: 1, height: 1, name: 'Canvas', isDeleted: false } as any);
|
||||||
|
}
|
||||||
|
// Subsequent slides: frames
|
||||||
|
frameElements.forEach((f: any) => allSlides.push(f));
|
||||||
|
setSlides(allSlides);
|
||||||
|
setSlideIndex(0);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const api = excalidrawAPI as any;
|
||||||
|
if (allSlides.length > 0 && api.scrollToContent) {
|
||||||
|
if (allSlides[0].id === '__whole_canvas__') {
|
||||||
|
api.zoomToFit?.();
|
||||||
|
} else {
|
||||||
|
api.scrollToContent?.([allSlides[0]], { fitToContent: true, animate: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, [presentationMode, excalidrawAPI]);
|
||||||
|
|
||||||
|
// Presentation keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentationMode) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex((prev) => Math.min(prev + 1, slides.length - 1));
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex(0);
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex(slides.length - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [presentationMode, slides.length]);
|
||||||
|
|
||||||
|
// Scroll to current slide when slideIndex changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentationMode || !excalidrawAPI || slides.length === 0) return;
|
||||||
|
const currentSlide = slides[slideIndex];
|
||||||
|
if (!currentSlide) return;
|
||||||
|
const api = excalidrawAPI as any;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (currentSlide.id === '__whole_canvas__') {
|
||||||
|
api.zoomToFit?.();
|
||||||
|
} else if (api.scrollToContent) {
|
||||||
|
api.scrollToContent?.([currentSlide], { fitToContent: true, animate: true });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}, [slideIndex, slides, presentationMode, excalidrawAPI]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -566,7 +767,12 @@ export const Editor: React.FC = () => {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
|
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
<Button variant="ghost" size="sm" onClick={async () => {
|
||||||
|
if (saveStatus === 'unsaved') {
|
||||||
|
await saveDrawingRef.current();
|
||||||
|
}
|
||||||
|
navigate(drawing?.folder_id ? `/folder/${drawing.folder_id}` : '/files');
|
||||||
|
}}>
|
||||||
<ArrowLeft size={18} />
|
<ArrowLeft size={18} />
|
||||||
{t('editor.back')}
|
{t('editor.back')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -618,6 +824,68 @@ export const Editor: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<LayoutTemplate size={16} />
|
<LayoutTemplate size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!excalidrawAPI) return;
|
||||||
|
const appState = excalidrawAPI.getAppState?.() || {};
|
||||||
|
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
||||||
|
const elements = excalidrawAPI.getSceneElements?.() || [];
|
||||||
|
const selectedEls = elements.filter((el) => selectedIds.includes(el.id));
|
||||||
|
if (selectedEls.length === 0) {
|
||||||
|
alert('Select elements on canvas to create a slide');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
selectedEls.forEach((el) => {
|
||||||
|
minX = Math.min(minX, el.x);
|
||||||
|
minY = Math.min(minY, el.y);
|
||||||
|
maxX = Math.max(maxX, el.x + el.width);
|
||||||
|
maxY = Math.max(maxY, el.y + el.height);
|
||||||
|
});
|
||||||
|
const padding = 40;
|
||||||
|
const frameEl = {
|
||||||
|
id: `frame-${Math.random().toString(36).slice(2)}`,
|
||||||
|
type: 'frame',
|
||||||
|
x: minX - padding,
|
||||||
|
y: minY - padding,
|
||||||
|
width: maxX - minX + padding * 2,
|
||||||
|
height: maxY - minY + padding * 2,
|
||||||
|
angle: 0,
|
||||||
|
strokeColor: '#1e1e1e',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fillStyle: 'hachure' as const,
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeStyle: 'solid' as const,
|
||||||
|
roughness: 1,
|
||||||
|
opacity: 100,
|
||||||
|
groupIds: [],
|
||||||
|
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,
|
||||||
|
customData: { templateRole: 'slide' },
|
||||||
|
name: `Slide ${elements.filter((e) => e.type === 'frame').length + 1}`,
|
||||||
|
};
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
excalidrawAPI.updateScene({
|
||||||
|
elements: [...elements, frameEl] as ExcalidrawElement[],
|
||||||
|
appState: { ...appState, selectedElementIds: { [frameEl.id]: true } },
|
||||||
|
});
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
}}
|
||||||
|
title="Create slide from selection"
|
||||||
|
aria-label="Create a presentation slide from selected elements"
|
||||||
|
>
|
||||||
|
<Frame size={16} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -673,10 +941,10 @@ export const Editor: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.revisionList}>
|
<div className={styles.revisionList}>
|
||||||
{revisions.length === 0 ? (
|
{meaningfulRevisions.length === 0 ? (
|
||||||
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
|
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
|
||||||
) : (
|
) : (
|
||||||
revisions.map((rev) => (
|
meaningfulRevisions.map((rev) => (
|
||||||
<button
|
<button
|
||||||
key={rev.id}
|
key={rev.id}
|
||||||
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
|
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
|
||||||
@@ -742,11 +1010,45 @@ export const Editor: React.FC = () => {
|
|||||||
{presentationMode && (
|
{presentationMode && (
|
||||||
<div className={styles.presentationOverlay} role="presentation">
|
<div className={styles.presentationOverlay} role="presentation">
|
||||||
<div className={styles.presentationToolbar}>
|
<div className={styles.presentationToolbar}>
|
||||||
<span className={styles.presentationLabel}>Presentation Mode — Press Esc to exit</span>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSlideIndex((prev) => Math.max(prev - 1, 0))}
|
||||||
|
disabled={slideIndex <= 0}
|
||||||
|
aria-label="Previous slide"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</Button>
|
||||||
|
<span className={styles.presentationLabel}>
|
||||||
|
Slide {slides.length > 0 ? slideIndex + 1 : 0} / {slides.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSlideIndex((prev) => Math.min(prev + 1, slides.length - 1))}
|
||||||
|
disabled={slideIndex >= slides.length - 1}
|
||||||
|
aria-label="Next slide"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setPresentationMode(false)} aria-label="Exit presentation">
|
<Button variant="ghost" size="sm" onClick={() => setPresentationMode(false)} aria-label="Exit presentation">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.presentationSlides}>
|
||||||
|
{slides.map((slide, idx) => (
|
||||||
|
<button
|
||||||
|
key={slide.id || idx}
|
||||||
|
className={`${styles.presentationSlideThumb} ${idx === slideIndex ? styles.presentationSlideActive : ''}`}
|
||||||
|
onClick={() => setSlideIndex(idx)}
|
||||||
|
aria-label={`Go to slide ${idx + 1}`}
|
||||||
|
title={idx === 0 ? 'Whole canvas' : (slide as any).name || `Slide ${idx}`}
|
||||||
|
>
|
||||||
|
<div className={styles.presentationSlideNumber}>{idx + 1}</div>
|
||||||
|
<div className={styles.presentationSlideName}>{idx === 0 ? 'Canvas' : ((slide as any).name || `Slide ${idx}`)}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
@@ -44,6 +45,8 @@ export const api = {
|
|||||||
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||||
delete: (id: string): Promise<{ ok: boolean }> =>
|
delete: (id: string): Promise<{ ok: boolean }> =>
|
||||||
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
|
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
|
||||||
|
autosave: (id: string, snapshot: object): Promise<{ ok: boolean }> =>
|
||||||
|
fetchApi(`/drawings/${id}/autosave`, { method: 'PATCH', body: JSON.stringify({ snapshot }) }),
|
||||||
},
|
},
|
||||||
revisions: {
|
revisions: {
|
||||||
list: (drawingId: string): Promise<DrawingRevision[]> =>
|
list: (drawingId: string): Promise<DrawingRevision[]> =>
|
||||||
|
|||||||
+50
-9
@@ -56,6 +56,7 @@ func (a *API) Routes() chi.Router {
|
|||||||
r.Post("/drawings", a.handleCreateDrawing)
|
r.Post("/drawings", a.handleCreateDrawing)
|
||||||
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
|
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
|
||||||
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
|
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
|
||||||
|
r.Patch("/drawings/{drawingID}/autosave", a.handleAutosaveDrawing)
|
||||||
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
|
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
|
||||||
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
|
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
|
||||||
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
|
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
|
||||||
@@ -134,6 +135,14 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// If the request carries a valid session cookie, it has already been
|
||||||
|
// authenticated by requireSession middleware. The SameSite=Lax cookie
|
||||||
|
// attribute provides sufficient CSRF protection for same-site requests,
|
||||||
|
// so we trust authenticated mutations without a strict Origin check.
|
||||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -150,17 +159,34 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
|
|||||||
proto = "https"
|
proto = "https"
|
||||||
}
|
}
|
||||||
expected := proto + "://" + host
|
expected := proto + "://" + host
|
||||||
if origin != expected {
|
if origin == expected {
|
||||||
// also allow without port in case proxy strips it
|
next.ServeHTTP(w, r)
|
||||||
expectedNoPort := proto + "://" + strings.SplitN(host, ":", 2)[0]
|
return
|
||||||
originNoPort := strings.SplitN(origin, "://", 2)[1]
|
}
|
||||||
originNoPort = strings.SplitN(originNoPort, ":", 2)[0]
|
// allow without port in case proxy strips it
|
||||||
if originNoPort != expectedNoPort {
|
expectedHost := strings.SplitN(host, ":", 2)[0]
|
||||||
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
originHost := ""
|
||||||
|
if parts := strings.SplitN(origin, "://", 2); len(parts) == 2 {
|
||||||
|
originHost = strings.SplitN(parts[1], ":", 2)[0]
|
||||||
|
}
|
||||||
|
if originHost != "" && originHost == expectedHost {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// fallback: check Referer hostname matches
|
||||||
|
referer := r.Header.Get("Referer")
|
||||||
|
if referer != "" {
|
||||||
|
refHost := ""
|
||||||
|
if parts := strings.SplitN(referer, "://", 2); len(parts) == 2 {
|
||||||
|
refHost = strings.SplitN(parts[1], "/", 2)[0]
|
||||||
|
refHost = strings.SplitN(refHost, ":", 2)[0]
|
||||||
|
}
|
||||||
|
if refHost != "" && refHost == expectedHost {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +382,21 @@ func (a *API) handleUpdateDrawing(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, drawing)
|
writeJSON(w, http.StatusOK, drawing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) handleAutosaveDrawing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := currentUser(r)
|
||||||
|
var req struct {
|
||||||
|
Snapshot json.RawMessage `json:"snapshot"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &req, 10<<20) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.AutosaveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req.Snapshot); err != nil {
|
||||||
|
writeLookupError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
|
||||||
user, _ := currentUser(r)
|
user, _ := currentUser(r)
|
||||||
if err := a.store.ArchiveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID")); err != nil {
|
if err := a.store.ArchiveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID")); err != nil {
|
||||||
@@ -639,7 +680,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, limit int64) bo
|
|||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||||
decoder := json.NewDecoder(r.Body)
|
decoder := json.NewDecoder(r.Body)
|
||||||
decoder.DisallowUnknownFields()
|
// Allow unknown fields so frontend can send extra data without breaking
|
||||||
if err := decoder.Decode(dst); err != nil {
|
if err := decoder.Decode(dst); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Invalid request body")
|
writeError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -635,6 +635,47 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
|
|||||||
return s.GetDrawing(ctx, userID, drawingID)
|
return s.GetDrawing(ctx, userID, drawingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) AutosaveDrawing(ctx context.Context, userID, drawingID string, snapshot json.RawMessage) error {
|
||||||
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(snapshot) == 0 || !json.Valid(snapshot) {
|
||||||
|
return fmt.Errorf("snapshot must be valid JSON")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE workspace_drawings SET updated_at = ? WHERE id = ?`, now, drawingID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Upsert the latest revision snapshot directly without creating a new revision entry
|
||||||
|
var existingRevID string
|
||||||
|
var revNumber int
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT id, revision_number FROM workspace_drawing_revisions WHERE drawing_id = ? ORDER BY revision_number DESC LIMIT 1`, drawingID).Scan(&existingRevID, &revNumber)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// Create initial revision if none exists
|
||||||
|
revID := newID()
|
||||||
|
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_drawing_revisions
|
||||||
|
(id, drawing_id, revision_number, snapshot_path, snapshot_size, content_hash, snapshot_json, created_by, created_at, change_summary)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
revID, drawingID, 1, fmt.Sprintf("teams/drawings/%s/revisions/1.json", drawingID), int64(len(snapshot)),
|
||||||
|
func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(),
|
||||||
|
[]byte(snapshot), userID, now, "Auto-save",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings SET latest_revision_id = ?, updated_at = ? WHERE id = ?`, revID, now, drawingID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Update existing latest revision snapshot
|
||||||
|
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawing_revisions SET snapshot_json = ?, snapshot_size = ?, content_hash = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
[]byte(snapshot), int64(len(snapshot)), func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(), now, existingRevID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ArchiveDrawing(ctx context.Context, userID, drawingID string) error {
|
func (s *Store) ArchiveDrawing(ctx context.Context, userID, drawingID string) error {
|
||||||
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user