diff --git a/app/frontend/src/components/BoardCanvas.tsx b/app/frontend/src/components/BoardCanvas.tsx index 31949677..f6e2bcf3 100644 --- a/app/frontend/src/components/BoardCanvas.tsx +++ b/app/frontend/src/components/BoardCanvas.tsx @@ -1,18 +1,16 @@ import '@excalidraw/excalidraw/index.css'; import { useEffect, useRef, useState } from 'react'; -import { CaptureUpdateAction, Excalidraw, isInvisiblySmallElement, reconcileElements } from '@excalidraw/excalidraw'; +import { CaptureUpdateAction, Excalidraw, 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 { BinaryFileData, Collaborator, DataURL, ExcalidrawImperativeAPI, SocketId } from '@excalidraw/excalidraw/types'; +import type { Collaborator, 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 { boardImage } from './board-files'; 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; @@ -22,15 +20,6 @@ interface Pointer { tool: 'pointer' | 'laser'; } -function dataUrl(blob: Blob) { - return new Promise((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(); @@ -54,12 +43,8 @@ function LiveBoard({ connection, editable, spaceId }: { connection: Connection; 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(`/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]); - }) + boardImage(auth.request, fileId) + .then((file) => api.addFiles([file])) .catch(() => loading.current.delete(fileId)); } }; @@ -157,7 +142,15 @@ function LiveBoard({ connection, editable, spaceId }: { connection: Connection; theme="light" UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false, toggleTheme: false } }} viewModeEnabled={!editable || outdated} - /> + > + + + + + + + + ); diff --git a/app/frontend/src/components/BoardPreview.tsx b/app/frontend/src/components/BoardPreview.tsx new file mode 100644 index 00000000..c7962327 --- /dev/null +++ b/app/frontend/src/components/BoardPreview.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'; +import type { BinaryFiles } from '@excalidraw/excalidraw/types'; +import { Skeleton } from '@project-graphite/ui'; +import { useAuth } from '../auth'; +import { boardImage } from './board-files'; + +export function BoardPreview({ boardId, label }: { boardId: string; label: string }) { + const auth = useAuth(); + const frame = useRef(null); + const [state, setState] = useState<'loading' | 'empty' | 'shown' | 'failed'>('loading'); + + useEffect(() => { + let cancelled = false; + (async () => { + const { elements } = await auth.request<{ elements: ExcalidrawElement[] }>(`/notes/${boardId}/board`); + if (elements.length === 0) return 'empty' as const; + const [{ exportToSvg }, images] = await Promise.all([ + import('@excalidraw/excalidraw'), + Promise.all( + elements.flatMap((element) => (element.type === 'image' && element.fileId ? [boardImage(auth.request, element.fileId).catch(() => null)] : [])), + ), + ]); + const files: BinaryFiles = Object.fromEntries(images.flatMap((file) => (file ? [[file.id, file]] : []))); + const svg = await exportToSvg({ elements, appState: { exportBackground: true, viewBackgroundColor: '#ffffff' }, files }); + svg.removeAttribute('width'); + svg.removeAttribute('height'); + if (!cancelled) frame.current?.replaceChildren(svg); + return 'shown' as const; + })().then( + (next) => !cancelled && setState(next), + () => !cancelled && setState('failed'), + ); + return () => { + cancelled = true; + }; + }, [auth.request, boardId]); + + return ( +
+ {state === 'loading' && } + {state === 'empty' &&

Nothing drawn yet

} + {state === 'failed' &&

This board could not be shown

} +
+
+ ); +} diff --git a/app/frontend/src/components/NoteEditor.tsx b/app/frontend/src/components/NoteEditor.tsx index d1bc84f1..2c740dc0 100644 --- a/app/frontend/src/components/NoteEditor.tsx +++ b/app/frontend/src/components/NoteEditor.tsx @@ -18,7 +18,7 @@ import { useAuth } from '../auth'; import { maxUploadBytes, preparedUpload } from '../files'; import type { Item } from '../spaces'; import { colorFor, useLiveDocument, useLiveStatus, type Connection } from './live-document'; -import { blockChoices, blockMenuExtension, Callout, internalPath, mentionExtension, StoredImageExtension } from './note-extensions'; +import { blockChoices, blockMenuExtension, BoardEmbedExtension, Callout, internalPath, mentionExtension, StoredImageExtension, type BlockActions } from './note-extensions'; const turnInto = new Set(['text', 'heading', 'subheading', 'bullets', 'numbers', 'checklist', 'quote', 'code', 'callout', 'toggle']); @@ -68,6 +68,8 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec const { label, people, outdated } = useLiveStatus(connection, editable); const user = auth.user!; + const actions: BlockActions = { onImages: insertImages, onBoard: addBoard }; + const editor = useEditor( { editable, @@ -85,7 +87,8 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec DetailsContent, TableKit.configure({ table: { resizable: false } }), mentionExtension(auth.request, noteId), - blockMenuExtension(insertImages), + blockMenuExtension(actions), + BoardEmbedExtension, StoredImageExtension, ], editorProps: { @@ -103,6 +106,15 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec [connection], ); + function addBoard(target: Editor) { + auth + .request<{ id: string }>(`/spaces/${spaceId}/notes`, { method: 'POST', body: JSON.stringify({ board: true, parentId: noteId, title: 'Board' }) }) + .then( + (board) => target.chain().focus().insertContent({ type: 'boardEmbed', attrs: { boardId: board.id } }).run(), + () => show({ message: 'Could not add a board.', tone: 'error' }), + ); + } + function insertImages(view: EditorView, files: File[]) { const images = files.filter((file) => file.type.startsWith('image/')); if (!editable || images.length === 0) return false; @@ -150,7 +162,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
- {editable && !outdated && } + {editable && !outdated && } ); @@ -166,7 +178,7 @@ function HoldHandle({ editor }: { editor: Editor }) { return null; } -function BlockHandle({ editor, onImages }: { editor: Editor; onImages: (view: EditorView, files: File[]) => boolean }) { +function BlockHandle({ actions, editor }: { actions: BlockActions; editor: Editor }) { const [block, setBlock] = useState<{ node: BlockNode; pos: number }>(); const at = (pos: number, bias: 1 | -1 = 1) => editor.chain().focus().command(({ tr }) => { @@ -182,7 +194,7 @@ function BlockHandle({ editor, onImages }: { editor: Editor; onImages: (view: Ed { label: 'Add a row', onSelect: () => at(block.pos + block.node.nodeSize - 2, -1).addRowAfter().run(), separated: true }, { label: 'Add a column', onSelect: () => at(block.pos + block.node.nodeSize - 2, -1).addColumnAfter().run() }, ] - : blockChoices(onImages) + : blockChoices(actions) .filter((choice) => turnInto.has(choice.id)) .map((choice, index) => ({ label: `Turn into ${choice.label.toLowerCase()}`, onSelect: () => choice.run(at(block.pos + 1).clearNodes(), editor), separated: index === 0 }))), ] diff --git a/app/frontend/src/components/board-files.ts b/app/frontend/src/components/board-files.ts new file mode 100644 index 00000000..2d74e25c --- /dev/null +++ b/app/frontend/src/components/board-files.ts @@ -0,0 +1,19 @@ +import type { BinaryFileData, DataURL } from '@excalidraw/excalidraw/types'; +import { readBlob } from '../api'; +import type { useAuth } from '../auth'; + +(window as { EXCALIDRAW_ASSET_PATH?: string }).EXCALIDRAW_ASSET_PATH = '/excalidraw/'; + +function dataUrl(blob: Blob) { + return new Promise((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); + }); +} + +export async function boardImage(request: ReturnType['request'], fileId: string): Promise { + const blob = await request(`/files/${fileId}`, {}, readBlob); + return { id: fileId as BinaryFileData['id'], dataURL: await dataUrl(blob), mimeType: blob.type as BinaryFileData['mimeType'], created: Date.now() }; +} diff --git a/app/frontend/src/components/live-document.ts b/app/frontend/src/components/live-document.ts index d63b0bdd..b7921c35 100644 --- a/app/frontend/src/components/live-document.ts +++ b/app/frontend/src/components/live-document.ts @@ -17,7 +17,7 @@ export interface Person { color: string; } -export const editorFormat = 2; +export const editorFormat = 3; const colors = ['#e5736b', '#e0915a', '#d7b24a', '#5fbf7f', '#4fb3c4', '#6b8fe5', '#a07be5', '#e57bb5']; diff --git a/app/frontend/src/components/note-extensions.tsx b/app/frontend/src/components/note-extensions.tsx index 53dcca7b..f465f05e 100644 --- a/app/frontend/src/components/note-extensions.tsx +++ b/app/frontend/src/components/note-extensions.tsx @@ -4,9 +4,11 @@ import Image from '@tiptap/extension-image'; import Mention from '@tiptap/extension-mention'; import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type NodeViewProps } from '@tiptap/react'; import Suggestion, { type SuggestionKeyDownProps, type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion'; +import { Link } from 'react-router'; import { readBlob } from '../api'; import { useAuth } from '../auth'; import { itemHref } from '../spaces'; +import { BoardPreview } from './BoardPreview'; interface Candidate { id: string; @@ -72,8 +74,10 @@ const SuggestionList = forwardRef>(functi export const internalPath = (href: unknown) => (typeof href === 'string' && /^\/(?![/\\])/.test(href) ? href : null); -export const storedFileId = (fileId: unknown) => - typeof fileId === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(fileId) ? fileId : null; +const uuidOrNull = (value: unknown) => + typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) ? value : null; + +export const storedFileId = uuidOrNull; function suggestionPopup(): ReturnType['render']>> { let renderer: ReactRenderer> | undefined; @@ -203,9 +207,12 @@ export const Callout = Node.create({ renderHTML: ({ HTMLAttributes }) => ['div', mergeAttributes(HTMLAttributes, { 'data-callout': '', class: 'note-callout' }), 0], }); -type ImageHandler = (view: Editor['view'], files: File[]) => void; +export interface BlockActions { + onImages: (view: Editor['view'], files: File[]) => void; + onBoard: (editor: Editor) => void; +} -export function blockChoices(onImages: ImageHandler): BlockChoice[] { +export function blockChoices({ onImages, onBoard }: BlockActions): BlockChoice[] { const chooseImages = (editor: Editor) => { const input = document.createElement('input'); input.type = 'file'; @@ -237,12 +244,22 @@ export function blockChoices(onImages: ImageHandler): BlockChoice[] { chooseImages(editor); }, }, + { + id: 'board', + label: 'Board', + hint: 'whiteboard', + words: 'board whiteboard drawing sketch canvas diagram', + run: (chain, editor) => { + chain.run(); + onBoard(editor); + }, + }, { id: 'link', label: 'Link to a page', hint: '@', words: 'link page mention reference', run: (chain) => chain.insertContent('@').run() }, ]; } -export function blockMenuExtension(onImages: ImageHandler) { - const choices = blockChoices(onImages); +export function blockMenuExtension(actions: BlockActions) { + const choices = blockChoices(actions); return Extension.create({ name: 'blockMenu', addProseMirrorPlugins() { @@ -261,3 +278,38 @@ export function blockMenuExtension(onImages: ImageHandler) { }, }); } + +function BoardEmbed({ node }: NodeViewProps) { + const boardId = uuidOrNull(node.attrs.boardId); + return ( + + {boardId ? ( + <> + + + Open board + + + ) : ( +

This board is missing

+ )} +
+ ); +} + +export const BoardEmbedExtension = Node.create({ + name: 'boardEmbed', + group: 'block', + atom: true, + draggable: true, + addAttributes: () => ({ + boardId: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-board'), + renderHTML: (attributes: { boardId?: string | null }) => ({ 'data-board': attributes.boardId }), + }, + }), + parseHTML: () => [{ tag: 'div[data-board]' }], + renderHTML: ({ HTMLAttributes }) => ['div', HTMLAttributes], + addNodeView: () => ReactNodeViewRenderer(BoardEmbed), +}); diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css index 7a43c0bd..34a32108 100644 --- a/app/frontend/src/styles/global.css +++ b/app/frontend/src/styles/global.css @@ -56,6 +56,32 @@ min-height: 3rem; } +.note-board { + display: grid; + gap: 0.4rem; + margin: 0.75em 0; +} + +.board-preview { + overflow: hidden; + border: 1px solid var(--color-line); + border-radius: 0.75rem; + background: #ffffff; +} + +.board-preview-drawing svg { + display: block; + width: 100%; + max-height: 24rem; + height: auto; +} + +.note-board-open { + width: fit-content; + color: var(--color-muted); + font-size: 0.85rem; +} + .board-canvas { height: max(28rem, calc(100dvh - 16rem)); overflow: hidden; diff --git a/app/frontend/test/block-menu.spec.tsx b/app/frontend/test/block-menu.spec.tsx index 1b77a8c8..0bc11e5f 100644 --- a/app/frontend/test/block-menu.spec.tsx +++ b/app/frontend/test/block-menu.spec.tsx @@ -28,9 +28,9 @@ describe('The / block menu', () => { const options = () => [...document.querySelectorAll('.suggestion-menu [role="option"]')].map((option) => option.textContent); - async function open(onImages = vi.fn()) { + async function open(onImages = vi.fn(), onBoard = vi.fn()) { function Page() { - const created = useEditor({ extensions: [StarterKit, TaskList, TaskItem, Callout, Details, DetailsSummary, DetailsContent, TableKit, blockMenuExtension(onImages)], content: '

', immediatelyRender: true }); + const created = useEditor({ extensions: [StarterKit, TaskList, TaskItem, Callout, Details, DetailsSummary, DetailsContent, TableKit, blockMenuExtension({ onImages, onBoard })], content: '

', immediatelyRender: true }); editor = created ?? undefined; return ; } @@ -41,7 +41,7 @@ describe('The / block menu', () => { await act(async () => { editor!.commands.focus('end'); }); - return onImages; + return { onImages, onBoard }; } it('filters blocks as you type and turns the line into the one you pick', async () => { @@ -60,7 +60,7 @@ describe('The / block menu', () => { }); it('starts a page link and asks for images through the editor', async () => { - const onImages = await open(); + const { onImages, onBoard } = await open(); await typeIn('/link'); await act(async () => document.querySelector('.suggestion-menu [role="option"]')!.click()); expect(editor!.getText()).toBe('@'); @@ -74,6 +74,10 @@ describe('The / block menu', () => { picker.onchange?.(new Event('change')); expect(onImages).toHaveBeenCalledWith(editor!.view, []); click.mockRestore(); + + await typeIn(' /whiteboard'); + await act(async () => document.querySelector('.suggestion-menu [role="option"]')!.click()); + expect(onBoard).toHaveBeenCalledWith(editor); }); it('adds callouts, toggles and tables', async () => { diff --git a/app/server/src/notes/document-text.ts b/app/server/src/notes/document-text.ts index 89b65d06..8fcc1477 100644 --- a/app/server/src/notes/document-text.ts +++ b/app/server/src/notes/document-text.ts @@ -43,6 +43,7 @@ function collect(node: Y.XmlElement | Y.XmlFragment, found: { mentions: Set(boardElementsKey).values()].filter((element) => !element.isDeleted); + return { elements }; + } + async update(userId: string, noteId: string, input: UpdateNoteDto) { const found = await this.note(userId, noteId, 'edit'); if (found.trashedAt) throw new BadRequestException('Restore this page from the trash before changing it'); diff --git a/app/server/src/realtime/realtime.service.ts b/app/server/src/realtime/realtime.service.ts index cb16a113..2a807147 100644 --- a/app/server/src/realtime/realtime.service.ts +++ b/app/server/src/realtime/realtime.service.ts @@ -22,7 +22,7 @@ const maxDocumentsPerUser = 40; const recheckMs = 60_000; const versionEveryMs = 15 * 60 * 1000; const maxLinksPerNote = 200; -export const editorFormat = 2; +export const editorFormat = 3; interface BoardVersion { version?: number; diff --git a/app/server/test/document-text.spec.ts b/app/server/test/document-text.spec.ts index 99cc34e7..520f79c2 100644 --- a/app/server/test/document-text.spec.ts +++ b/app/server/test/document-text.spec.ts @@ -37,4 +37,13 @@ describe('documentText', () => { expect(documentText(document)).toBe('Seating plan'); expect([...documentLinks(document).files]).toEqual(['photo-file']); }); + + it('links the boards embedded in a page', () => { + const document = new Y.Doc(); + const embed = new Y.XmlElement('boardEmbed'); + embed.setAttribute('boardId', 'seating-board'); + document.getXmlFragment('default').push([embed]); + + expect([...documentLinks(document).mentions]).toEqual(['seating-board']); + }); }); diff --git a/app/server/test/integration/notes.spec.ts b/app/server/test/integration/notes.spec.ts index 5dc25b79..da75b40e 100644 --- a/app/server/test/integration/notes.spec.ts +++ b/app/server/test/integration/notes.spec.ts @@ -212,6 +212,8 @@ describe('Notes and real-time editing against Postgres', () => { (row) => Boolean(row?.bodyText.includes('Top table')), ); expect(stored?.bodyText).toBe('Top table'); + expect((await friend.call('GET', `/notes/${board.id}/board`)).body.elements).toEqual([expect.objectContaining({ id: 'table-1', text: 'Top table' })]); + expect((await friend.call('GET', `/notes/${page.id}/board`)).status).toBe(404); theirs.provider.destroy(); mine.provider.destroy();