diff --git a/app/frontend/src/components/BoardCanvas.tsx b/app/frontend/src/components/BoardCanvas.tsx index 90ab8ac..b7c89f6 100644 --- a/app/frontend/src/components/BoardCanvas.tsx +++ b/app/frontend/src/components/BoardCanvas.tsx @@ -1,6 +1,6 @@ import '@excalidraw/excalidraw/index.css'; import { useEffect, useRef, useState } from 'react'; -import { CaptureUpdateAction, Excalidraw, isInvisiblySmallElement, MainMenu, reconcileElements } from '@excalidraw/excalidraw'; +import { CaptureUpdateAction, convertToExcalidrawElements, Excalidraw, exportToBlob, isInvisiblySmallElement, MainMenu, reconcileElements } from '@excalidraw/excalidraw'; import type { RemoteExcalidrawElement } from '@excalidraw/excalidraw/data/reconcile'; import type { ExcalidrawElement, FileId } from '@excalidraw/excalidraw/element/types'; import type { Collaborator, ExcalidrawImperativeAPI, SocketId } from '@excalidraw/excalidraw/types'; @@ -9,6 +9,7 @@ import { useAuth } from '../auth'; import { maxUploadBytes, preparedUpload } from '../files'; import type { Item } from '../spaces'; import { boardImage } from './board-files'; +import { boardTemplates } from './board-templates'; import { colorFor, useLiveDocument, useLiveStatus, type Connection } from './live-document'; const boardElementsKey = 'board'; @@ -35,6 +36,8 @@ function LiveBoard({ boardId, connection, editable, spaceId }: { boardId: string const [api, setApi] = useState(); const [picked, setPicked] = useState([]); const [making, setMaking] = useState(false); + const [empty, setEmpty] = useState(false); + const [loaded, setLoaded] = useState(connection.provider.isSynced); const synced = useRef(new Map()); const loading = useRef(new Set()); const pending = useRef(undefined); @@ -98,6 +101,56 @@ function LiveBoard({ boardId, connection, editable, spaceId }: { boardId: string useEffect(() => () => window.clearTimeout(pending.current), []); + useEffect(() => { + const onSynced = () => setLoaded(true); + connection.provider.on('synced', onSynced); + return () => { + connection.provider.off('synced', onSynced); + }; + }, [connection]); + + function addElements(skeletons: Parameters[0], fit: boolean) { + if (!api) return; + const added = convertToExcalidrawElements(skeletons, { regenerateIds: true }); + api.updateScene({ elements: [...api.getSceneElementsIncludingDeleted(), ...added], captureUpdate: CaptureUpdateAction.IMMEDIATELY }); + if (fit) api.scrollToContent(added, { fitToContent: true }); + } + + function addVote() { + if (!api) return; + const { scrollX, scrollY, width, height, zoom } = api.getAppState(); + const color = colorFor(user.id); + const x = -scrollX + width / 2 / zoom.value + (Math.random() - 0.5) * 40; + const y = -scrollY + height / 2 / zoom.value + (Math.random() - 0.5) * 40; + addElements([{ type: 'ellipse', x, y, width: 22, height: 22, backgroundColor: color, strokeColor: color, fillStyle: 'solid' }], false); + } + + async function printBoard() { + if (!api) return; + const blob = await exportToBlob({ + elements: api.getSceneElements(), + files: api.getFiles(), + mimeType: 'image/png', + appState: { exportBackground: true, viewBackgroundColor: '#ffffff' }, + }); + const url = URL.createObjectURL(blob); + const frame = document.createElement('iframe'); + frame.className = 'board-print-frame'; + document.body.append(frame); + const page = frame.contentWindow!; + const image = page.document.createElement('img'); + image.style.width = '100%'; + image.onload = () => { + page.addEventListener('afterprint', () => { + frame.remove(); + URL.revokeObjectURL(url); + }); + page.print(); + }; + image.src = url; + page.document.body.append(image); + } + function sendChanges() { pending.current = undefined; if (!api) return; @@ -156,6 +209,16 @@ function LiveBoard({ boardId, connection, editable, spaceId }: { boardId: string
+ {editable && !outdated && loaded && empty && ( +
+ Start from a template + {boardTemplates.map((template) => ( + + ))} +
+ )} { if (editable && !outdated && pending.current === undefined) pending.current = window.setTimeout(sendChanges, syncEveryMs); const texts = selectedTexts(scene, appState.selectedElementIds); + const nothing = !scene.some((element) => !element.isDeleted); + setEmpty((current) => (current === nothing ? current : nothing)); setPicked((current) => (current.join('\n') === texts.join('\n') ? current : texts)); }} renderTopRightUI={() => - editable && !outdated && picked.length > 0 ? ( - + editable && !outdated ? ( +
+ {picked.length > 0 && ( + + )} + +
) : null } onPointerUpdate={({ pointer, button }) => connection.provider.setAwarenessField('pointer', { ...pointer, button })} @@ -180,6 +252,7 @@ function LiveBoard({ boardId, connection, editable, spaceId }: { boardId: string + void printBoard()}>Print or save as PDF diff --git a/app/frontend/src/components/board-templates.ts b/app/frontend/src/components/board-templates.ts new file mode 100644 index 0000000..fa734b6 --- /dev/null +++ b/app/frontend/src/components/board-templates.ts @@ -0,0 +1,86 @@ +import type { ExcalidrawElementSkeleton } from '@excalidraw/excalidraw/data/transform'; + +type Skeleton = ExcalidrawElementSkeleton; + +const note = (x: number, y: number, text: string, backgroundColor = '#fff3bf'): Skeleton => ({ + type: 'rectangle', + x, + y, + width: 200, + height: 90, + backgroundColor, + fillStyle: 'solid', + strokeColor: '#1e1e1e', + roundness: { type: 3 }, + label: { text, fontSize: 18 }, +}); + +function columns(titles: string[], colors: string[], height: number, sample?: string): Skeleton[] { + return titles.flatMap((title, index): Skeleton[] => { + const x = index * 260; + return [ + { type: 'text', x: x + 10, y: 0, text: title, fontSize: 28 }, + { type: 'rectangle', x, y: 50, width: 240, height, strokeColor: '#adb5bd', strokeStyle: 'dashed', roundness: { type: 3 } }, + ...(index === 0 && sample ? [note(x + 20, 80, sample, colors[index])] : []), + ]; + }); +} + +function mindMap(): Skeleton[] { + const branches = ['Idea', 'Question', 'Next step', 'Resource']; + const spots = [ + [-320, -160], + [320, -160], + [-320, 160], + [320, 160], + ] as const; + return [ + { type: 'ellipse', id: 'centre', x: -110, y: -50, width: 220, height: 100, backgroundColor: '#d0bfff', fillStyle: 'solid', label: { text: 'Main idea', fontSize: 24 } }, + ...branches.flatMap((text, index): Skeleton[] => { + const [x, y] = spots[index]!; + return [ + { type: 'ellipse', id: `branch-${index}`, x: x - 90, y: y - 40, width: 180, height: 80, backgroundColor: '#e7f5ff', fillStyle: 'solid', label: { text } }, + { type: 'arrow', x: 0, y: 0, start: { id: 'centre' }, end: { id: `branch-${index}` }, endArrowhead: null }, + ]; + }), + ]; +} + +function flowchart(): Skeleton[] { + return [ + { type: 'ellipse', id: 'start', x: 0, y: 0, width: 180, height: 70, backgroundColor: '#b2f2bb', fillStyle: 'solid', label: { text: 'Start' } }, + { type: 'rectangle', id: 'step', x: 0, y: 140, width: 180, height: 80, label: { text: 'Do something' } }, + { type: 'diamond', id: 'check', x: -10, y: 290, width: 200, height: 120, backgroundColor: '#ffec99', fillStyle: 'solid', label: { text: 'Done?' } }, + { type: 'ellipse', id: 'end', x: 0, y: 480, width: 180, height: 70, backgroundColor: '#ffc9c9', fillStyle: 'solid', label: { text: 'End' } }, + { type: 'arrow', x: 0, y: 0, start: { id: 'start' }, end: { id: 'step' } }, + { type: 'arrow', x: 0, y: 0, start: { id: 'step' }, end: { id: 'check' } }, + { type: 'arrow', x: 0, y: 0, start: { id: 'check' }, end: { id: 'end' }, label: { text: 'yes' } }, + ]; +} + +export const boardTemplates: { id: string; label: string; build: () => Skeleton[] }[] = [ + { id: 'kanban', label: 'Kanban', build: () => columns(['To do', 'Doing', 'Done'], ['#fff3bf'], 520, 'A first card') }, + { id: 'retro', label: 'Retro', build: () => columns(['Went well', 'To improve', 'Actions'], ['#b2f2bb'], 520, 'Something that worked') }, + { id: 'mind-map', label: 'Mind map', build: mindMap }, + { id: 'flowchart', label: 'Flowchart', build: flowchart }, + { id: 'week', label: 'Weekly planner', build: () => columns(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], ['#e7f5ff'], 420, 'Plan') }, + { + id: 'mood', + label: 'Mood board', + build: () => [ + { type: 'text', x: 0, y: 0, text: 'Mood board', fontSize: 32 }, + ...[0, 1, 2, 3, 4, 5].map( + (index): Skeleton => ({ + type: 'rectangle', + x: (index % 3) * 260, + y: 70 + Math.floor(index / 3) * 220, + width: 240, + height: 200, + strokeColor: '#adb5bd', + strokeStyle: 'dashed', + label: { text: 'Drop an image', fontSize: 16, strokeColor: '#868e96' }, + }), + ), + ], + }, +]; diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css index 4ceac0c..2bb129b 100644 --- a/app/frontend/src/styles/global.css +++ b/app/frontend/src/styles/global.css @@ -82,7 +82,7 @@ font-size: 0.85rem; } -.board-make-tasks { +.board-action { border-radius: 0.5rem; background: #1e1e1e; padding: 0.4rem 0.75rem; @@ -90,7 +90,36 @@ font-size: 0.85rem; } +.board-templates { + position: absolute; + bottom: 4.5rem; + left: 50%; + z-index: 5; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: max-content; + max-width: calc(100% - 2rem); + transform: translateX(-50%); + border: 1px solid #dee2e6; + border-radius: 0.75rem; + background: #ffffff; + padding: 0.6rem 0.8rem; + box-shadow: 0 4px 16px rgb(0 0 0 / 12%); +} + +.board-print-frame { + position: fixed; + width: 0; + height: 0; + border: 0; + visibility: hidden; +} + .board-canvas { + position: relative; height: max(28rem, calc(100dvh - 16rem)); overflow: hidden; border: 1px solid var(--color-line); diff --git a/app/frontend/test/board-templates.spec.tsx b/app/frontend/test/board-templates.spec.tsx new file mode 100644 index 0000000..517b90e --- /dev/null +++ b/app/frontend/test/board-templates.spec.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { boardTemplates } from '../src/components/board-templates'; + +describe('Board templates', () => { + it('builds every template, with arrows that point at shapes in it', () => { + expect(boardTemplates.map((template) => template.label)).toEqual(['Kanban', 'Retro', 'Mind map', 'Flowchart', 'Weekly planner', 'Mood board']); + for (const template of boardTemplates) { + const skeletons = template.build(); + expect(skeletons.length).toBeGreaterThan(2); + const ids = new Set(skeletons.flatMap((skeleton) => ('id' in skeleton && skeleton.id ? [skeleton.id] : []))); + for (const skeleton of skeletons) { + if (skeleton.type !== 'arrow') continue; + const ends = skeleton as { start?: { id?: string }; end?: { id?: string } }; + expect(ids.has(ends.start!.id!)).toBe(true); + expect(ids.has(ends.end!.id!)).toBe(true); + } + } + expect(boardTemplates.find((template) => template.id === 'kanban')!.build().filter((skeleton) => skeleton.type === 'text').map((skeleton) => (skeleton as { text: string }).text)).toEqual([ + 'To do', + 'Doing', + 'Done', + ]); + }); +});