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
1 change: 1 addition & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"vitest": "5.0.1"
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.1",
"@hocuspocus/provider": "4.7.0",
"@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.3.0/project-graphite-ui-0.3.0.tgz",
"@tiptap/core": "3.31.4",
Expand Down
170 changes: 170 additions & 0 deletions app/frontend/src/components/BoardCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import '@excalidraw/excalidraw/index.css';
import { useEffect, useRef, useState } from 'react';
import { CaptureUpdateAction, Excalidraw, isInvisiblySmallElement, reconcileElements } from '@excalidraw/excalidraw';
import type { RemoteExcalidrawElement } from '@excalidraw/excalidraw/data/reconcile';
import type { ExcalidrawElement, FileId } from '@excalidraw/excalidraw/element/types';
import type { BinaryFileData, Collaborator, DataURL, ExcalidrawImperativeAPI, SocketId } from '@excalidraw/excalidraw/types';
import { Avatar, LinesSkeleton, useSnackbar } from '@project-graphite/ui';
import { readBlob } from '../api';
import { useAuth } from '../auth';
import { maxUploadBytes, preparedUpload } from '../files';
import type { Item } from '../spaces';
import { colorFor, useLiveDocument, useLiveStatus, type Connection } from './live-document';

(window as { EXCALIDRAW_ASSET_PATH?: string }).EXCALIDRAW_ASSET_PATH = '/excalidraw/';

const boardElementsKey = 'board';
const syncEveryMs = 80;

interface Pointer {
x: number;
y: number;
tool: 'pointer' | 'laser';
}

function dataUrl(blob: Blob) {
return new Promise<DataURL>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as DataURL);
reader.onerror = () => reject(reader.error ?? new Error('Could not read the image'));
reader.readAsDataURL(blob);
});
}

function LiveBoard({ connection, editable, spaceId }: { connection: Connection; editable: boolean; spaceId: string }) {
const auth = useAuth();
const show = useSnackbar();
const { label, people } = useLiveStatus(connection, editable);
const [api, setApi] = useState<ExcalidrawImperativeAPI>();
const synced = useRef(new Map<string, number>());
const loading = useRef(new Set<string>());
const pending = useRef<number | undefined>(undefined);
const elements = connection.document.getMap<ExcalidrawElement>(boardElementsKey);
const user = auth.user!;

useEffect(() => {
connection.provider.setAwarenessField('user', { name: user.displayName, color: colorFor(user.id) });
}, [connection, user.displayName, user.id]);

useEffect(() => {
if (!api) return;
const loadImages = (scene: readonly ExcalidrawElement[]) => {
const known = api.getFiles();
for (const element of scene) {
if (element.type !== 'image' || !element.fileId || element.isDeleted || known[element.fileId] || loading.current.has(element.fileId)) continue;
const fileId = element.fileId;
loading.current.add(fileId);
auth
.request<Blob>(`/files/${fileId}`, {}, readBlob)
.then(async (blob) => {
const file: BinaryFileData = { id: fileId, dataURL: await dataUrl(blob), mimeType: blob.type as BinaryFileData['mimeType'], created: Date.now() };
api.addFiles([file]);
})
.catch(() => loading.current.delete(fileId));
}
};
const applyRemote = () => {
const remote = [...elements.values()];
for (const element of remote) synced.current.set(element.id, element.version);
const scene = reconcileElements(api.getSceneElementsIncludingDeleted(), remote as RemoteExcalidrawElement[], api.getAppState());
api.updateScene({ elements: scene, captureUpdate: CaptureUpdateAction.NEVER });
loadImages(scene);
};
applyRemote();
const observer = (_event: unknown, transaction: { origin: unknown }) => {
if (transaction.origin !== api) applyRemote();
};
elements.observe(observer);
return () => elements.unobserve(observer);
}, [api, auth, elements]);

useEffect(() => {
if (!api) return;
const toCollaborators = () => {
const collaborators = new Map<SocketId, Collaborator>();
for (const [clientId, state] of connection.provider.awareness?.getStates() ?? []) {
if (clientId === connection.provider.awareness?.clientID || !state.user) continue;
const person = state.user as { name: string; color: string };
const pointer = state.pointer as (Pointer & { button: 'up' | 'down' }) | undefined;
collaborators.set(String(clientId) as SocketId, {
username: person.name,
color: { background: person.color, stroke: person.color },
pointer: pointer && { x: pointer.x, y: pointer.y, tool: pointer.tool },
button: pointer?.button,
});
}
api.updateScene({ collaborators, captureUpdate: CaptureUpdateAction.NEVER });
};
connection.provider.on('awarenessChange', toCollaborators);
return () => {
connection.provider.off('awarenessChange', toCollaborators);
};
}, [api, connection]);

useEffect(() => () => window.clearTimeout(pending.current), []);

function sendChanges() {
pending.current = undefined;
if (!api) return;
const changed = api
.getSceneElementsIncludingDeleted()
.filter((element) => synced.current.get(element.id) !== element.version && !isInvisiblySmallElement(element));
if (changed.length === 0) return;
connection.document.transact(() => {
for (const element of changed) {
elements.set(element.id, element);
synced.current.set(element.id, element.version);
}
}, api);
}

async function upload(file: File) {
if (file.size > maxUploadBytes) {
show({ message: `${file.name} is over 25 MB.`, tone: 'error' });
throw new Error('Too large');
}
const uploaded = await auth.request<Item>(`/spaces/${spaceId}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream', 'X-File-Name': encodeURIComponent(file.name) },
body: await preparedUpload(file),
});
return uploaded.id as FileId;
}

return (
<div className="grid gap-3">
<div className="flex items-center justify-end gap-3">
{people.length > 0 && (
<div aria-label={`Also here: ${people.map((person) => person.name).join(', ')}`} className="flex -space-x-2" role="group">
{people.slice(0, 5).map((person) => (
<Avatar key={person.clientId} name={person.name} present />
))}
</div>
)}
<span className="mono-sm text-faint" role="status">
{label}
</span>
</div>
<div aria-label="Board" className="board-canvas" role="region">
<Excalidraw
excalidrawAPI={setApi}
generateIdForFile={upload}
isCollaborating
onChange={() => {
if (editable && pending.current === undefined) pending.current = window.setTimeout(sendChanges, syncEveryMs);
}}
onPointerUpdate={({ pointer, button }) => connection.provider.setAwarenessField('pointer', { ...pointer, button })}
theme="light"
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false, toggleTheme: false } }}
viewModeEnabled={!editable}
/>
</div>
</div>
);
}

export default function BoardCanvas({ editable, noteId, spaceId }: { editable: boolean; noteId: string; spaceId: string }) {
const connection = useLiveDocument(noteId);
if (!connection) return <LinesSkeleton label="Loading the board" lines={6} />;
return <LiveBoard connection={connection} editable={editable} spaceId={spaceId} />;
}
89 changes: 3 additions & 86 deletions app/frontend/src/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { useEffect, useRef, useState } from 'react';
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import { Avatar, LinesSkeleton, useSnackbar } from '@project-graphite/ui';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCaret from '@tiptap/extension-collaboration-caret';
Expand All @@ -8,34 +6,13 @@ import type { EditorView } from '@tiptap/pm/view';
import { Placeholder } from '@tiptap/extensions';
import { EditorContent, useEditor, useEditorState, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { IndexeddbPersistence } from 'y-indexeddb';
import * as Y from 'yjs';
import { useNavigate } from 'react-router';
import { useAuth } from '../auth';
import { maxUploadBytes, preparedUpload } from '../files';
import { realtimeUrl } from '../notes';
import type { Item } from '../spaces';
import { colorFor, useLiveDocument, useLiveStatus, type Connection } from './live-document';
import { internalPath, mentionExtension, StoredImageExtension } from './note-extensions';

interface Connection {
document: Y.Doc;
provider: HocuspocusProvider;
}

interface Person {
clientId: number;
name: string;
color: string;
}

const colors = ['#e5736b', '#e0915a', '#d7b24a', '#5fbf7f', '#4fb3c4', '#6b8fe5', '#a07be5', '#e57bb5'];

function colorFor(id: string) {
let hash = 0;
for (const character of id) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
return colors[hash % colors.length]!;
}

function Toolbar({ editor }: { editor: Editor }) {
const state = useEditorState({
editor,
Expand Down Expand Up @@ -77,9 +54,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
const auth = useAuth();
const show = useSnackbar();
const navigate = useNavigate();
const [people, setPeople] = useState<Person[]>([]);
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const [unsynced, setUnsynced] = useState(0);
const { label, people } = useLiveStatus(connection, editable);
const user = auth.user!;

const editor = useEditor(
Expand Down Expand Up @@ -135,46 +110,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
return true;
}

useEffect(() => {
const { provider } = connection;
const onStatus = ({ status: next }: { status: 'connecting' | 'connected' | 'disconnected' }) =>
setStatus((current) => (next === 'connecting' && current !== 'connecting' ? 'disconnected' : next));
const onUnsynced = ({ number }: { number: number }) => setUnsynced(number);
const onAwareness = () =>
setPeople(
[...(provider.awareness?.getStates().entries() ?? [])]
.filter(([clientId, state]) => clientId !== provider.awareness?.clientID && state.user)
.map(([clientId, state]) => ({ clientId, ...(state.user as { name: string; color: string }) })),
);
const onStateless = ({ payload }: { payload: string }) => {
if (payload.includes('too-large')) show({ message: 'This page is too large to save. Split it into smaller pages.', tone: 'error' });
};
provider.on('status', onStatus);
provider.on('unsyncedChanges', onUnsynced);
provider.on('awarenessChange', onAwareness);
provider.on('stateless', onStateless);
return () => {
provider.off('status', onStatus);
provider.off('unsyncedChanges', onUnsynced);
provider.off('awarenessChange', onAwareness);
provider.off('stateless', onStateless);
};
}, [connection, show]);

if (!editor) return <LinesSkeleton label="Loading the editor" lines={6} />;
const label =
status === 'disconnected'
? editable
? 'Offline. Changes are kept on this device'
: 'Offline'
: status === 'connecting'
? 'Connecting…'
: unsynced > 0
? 'Saving…'
: editable
? 'Saved'
: 'View only';

return (
<div className="note-editor grid gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
Expand All @@ -198,26 +134,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
}

export default function NoteEditor({ editable, noteId, spaceId }: { editable: boolean; noteId: string; spaceId: string }) {
const auth = useAuth();
const [connection, setConnection] = useState<Connection>();
const accessToken = useRef(auth.accessToken);
accessToken.current = auth.accessToken;

useEffect(() => {
const document = new Y.Doc();
const drafts = new IndexeddbPersistence(`strata-note-${noteId}`, document);
const websocketProvider = new HocuspocusProviderWebsocket({ url: realtimeUrl() });
const provider = new HocuspocusProvider({ name: noteId, document, websocketProvider, token: () => accessToken.current() });
provider.attach();
setConnection({ document, provider });
return () => {
provider.destroy();
websocketProvider.destroy();
void drafts.destroy();
document.destroy();
};
}, [noteId]);

const connection = useLiveDocument(noteId);
if (!connection) return <LinesSkeleton label="Loading the editor" lines={6} />;
return <CollaborativeEditor connection={connection} editable={editable} noteId={noteId} spaceId={spaceId} />;
}
Loading
Loading