Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 78 additions & 5 deletions app/frontend/src/components/BoardCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -35,6 +36,8 @@ function LiveBoard({ boardId, connection, editable, spaceId }: { boardId: string
const [api, setApi] = useState<ExcalidrawImperativeAPI>();
const [picked, setPicked] = useState<string[]>([]);
const [making, setMaking] = useState(false);
const [empty, setEmpty] = useState(false);
const [loaded, setLoaded] = useState(connection.provider.isSynced);
const synced = useRef(new Map<string, number>());
const loading = useRef(new Set<string>());
const pending = useRef<number | undefined>(undefined);
Expand Down Expand Up @@ -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<typeof convertToExcalidrawElements>[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;
Expand Down Expand Up @@ -156,20 +209,39 @@ function LiveBoard({ boardId, connection, editable, spaceId }: { boardId: string
</span>
</div>
<div aria-label="Board" className="board-canvas" role="region">
{editable && !outdated && loaded && empty && (
<div aria-label="Templates" className="board-templates" role="group">
<span className="text-sm text-muted">Start from a template</span>
{boardTemplates.map((template) => (
<button className="board-action" key={template.id} onClick={() => addElements(template.build(), true)} type="button">
{template.label}
</button>
))}
</div>
)}
<Excalidraw
excalidrawAPI={setApi}
generateIdForFile={upload}
isCollaborating
onChange={(scene, appState) => {
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 ? (
<button className="board-make-tasks" disabled={making} onClick={() => void makeTasks()} type="button">
Make {picked.length === 1 ? 'a task' : `${picked.length} tasks`}
</button>
editable && !outdated ? (
<div className="flex gap-2">
{picked.length > 0 && (
<button className="board-action" disabled={making} onClick={() => void makeTasks()} type="button">
Make {picked.length === 1 ? 'a task' : `${picked.length} tasks`}
</button>
)}
<button className="board-action" onClick={addVote} type="button">
Add a vote
</button>
</div>
) : null
}
onPointerUpdate={({ pointer, button }) => connection.provider.setAwarenessField('pointer', { ...pointer, button })}
Expand All @@ -180,6 +252,7 @@ function LiveBoard({ boardId, connection, editable, spaceId }: { boardId: string
<MainMenu>
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export />
<MainMenu.Item onSelect={() => void printBoard()}>Print or save as PDF</MainMenu.Item>
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.ChangeCanvasBackground />
<MainMenu.DefaultItems.Help />
Expand Down
86 changes: 86 additions & 0 deletions app/frontend/src/components/board-templates.ts
Original file line number Diff line number Diff line change
@@ -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' },
}),
),
],
},
];
31 changes: 30 additions & 1 deletion app/frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,44 @@
font-size: 0.85rem;
}

.board-make-tasks {
.board-action {
border-radius: 0.5rem;
background: #1e1e1e;
padding: 0.4rem 0.75rem;
color: #ffffff;
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);
Expand Down
24 changes: 24 additions & 0 deletions app/frontend/test/board-templates.spec.tsx
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
Loading