Compare commits

...

4 Commits

Author SHA1 Message Date
Tomas Dvorak 54c8088404 fix(db): handle explicit null values in update requests
Docker Images / Build and push (push) Failing after 16s
Introduce a `NullString` type to distinguish between JSON `null` and
absent fields in `UpdateDrawingRequest`. This ensures that when a
field is explicitly set to `null` in a request, the database can
correctly process the update.

Additionally, refactor the folder order migration to include proper
`goose` up/down instructions.
2026-05-21 14:17:58 +02:00
Tomas Dvorak cd22ee1ee8 feat(ui,api): implement multi-select and folder management enhancements
Implements multi-select functionality in the file browser, allowing users to
perform batch actions such as deleting or moving multiple drawings at once.
Adds full CRUD support for folders, including updating folder properties
and reordering folders via a new `sort_order` column in the database.

- feat(ui): add multi-select, batch delete, and batch move in FileBrowser
- feat(api): add endpoints for updating, deleting, and reordering folders
- feat(db): add `sort_order` column and index to `workspace_folders`
- fix(editor): integrate `useHandleLibrary` for better library management
- chore(deps): update excalidraw subproject
2026-05-21 13:20:44 +02:00
Tomas Dvorak 19e7ed6ea1 feat(ui): enhance file browser drag-and-drop and move functionality
Docker Images / Build and push (push) Failing after 15s
Refactor the file browser to improve the user experience and reliability of
folder management and item movement.

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