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
35 changes: 14 additions & 21 deletions app/frontend/src/components/BoardCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -22,15 +20,6 @@ interface Pointer {
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();
Expand All @@ -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<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]);
})
boardImage(auth.request, fileId)
.then((file) => api.addFiles([file]))
.catch(() => loading.current.delete(fileId));
}
};
Expand Down Expand Up @@ -157,7 +142,15 @@ function LiveBoard({ connection, editable, spaceId }: { connection: Connection;
theme="light"
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false, toggleTheme: false } }}
viewModeEnabled={!editable || outdated}
/>
>
<MainMenu>
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.ChangeCanvasBackground />
<MainMenu.DefaultItems.Help />
</MainMenu>
</Excalidraw>
</div>
</div>
);
Expand Down
47 changes: 47 additions & 0 deletions app/frontend/src/components/BoardPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div aria-label={label} className="board-preview" role="img">
{state === 'loading' && <Skeleton className="h-40 w-full" />}
{state === 'empty' && <p className="m-0 p-6 text-center text-sm text-muted">Nothing drawn yet</p>}
{state === 'failed' && <p className="m-0 p-6 text-center text-sm text-muted">This board could not be shown</p>}
<div className="board-preview-drawing" ref={frame} />
</div>
);
}
22 changes: 17 additions & 5 deletions app/frontend/src/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand All @@ -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;
Expand Down Expand Up @@ -150,7 +162,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
</span>
</div>
</div>
{editable && !outdated && <BlockHandle editor={editor} onImages={insertImages} />}
{editable && !outdated && <BlockHandle actions={actions} editor={editor} />}
<EditorContent editor={editor} />
</div>
);
Expand All @@ -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 }) => {
Expand All @@ -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 }))),
]
Expand Down
19 changes: 19 additions & 0 deletions app/frontend/src/components/board-files.ts
Original file line number Diff line number Diff line change
@@ -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<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);
});
}

export async function boardImage(request: ReturnType<typeof useAuth>['request'], fileId: string): Promise<BinaryFileData> {
const blob = await request<Blob>(`/files/${fileId}`, {}, readBlob);
return { id: fileId as BinaryFileData['id'], dataURL: await dataUrl(blob), mimeType: blob.type as BinaryFileData['mimeType'], created: Date.now() };
}
2 changes: 1 addition & 1 deletion app/frontend/src/components/live-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
64 changes: 58 additions & 6 deletions app/frontend/src/components/note-extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,8 +74,10 @@ const SuggestionList = forwardRef<ListHandle, SuggestionProps<Candidate>>(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<Item extends Candidate>(): ReturnType<NonNullable<SuggestionOptions<Item>['render']>> {
let renderer: ReactRenderer<ListHandle, SuggestionProps<Candidate>> | undefined;
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand All @@ -261,3 +278,38 @@ export function blockMenuExtension(onImages: ImageHandler) {
},
});
}

function BoardEmbed({ node }: NodeViewProps) {
const boardId = uuidOrNull(node.attrs.boardId);
return (
<NodeViewWrapper className="note-board" data-drag-handle="">
{boardId ? (
<>
<BoardPreview boardId={boardId} label="Board preview" />
<Link className="note-board-open" contentEditable={false} to={`/notes/${boardId}`}>
Open board
</Link>
</>
) : (
<p className="m-0 text-sm text-muted">This board is missing</p>
)}
</NodeViewWrapper>
);
}

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),
});
26 changes: 26 additions & 0 deletions app/frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 8 additions & 4 deletions app/frontend/test/block-menu.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p></p>', immediatelyRender: true });
const created = useEditor({ extensions: [StarterKit, TaskList, TaskItem, Callout, Details, DetailsSummary, DetailsContent, TableKit, blockMenuExtension({ onImages, onBoard })], content: '<p></p>', immediatelyRender: true });
editor = created ?? undefined;
return <EditorContent editor={created} />;
}
Expand All @@ -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 () => {
Expand All @@ -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<HTMLButtonElement>('.suggestion-menu [role="option"]')!.click());
expect(editor!.getText()).toBe('@');
Expand All @@ -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<HTMLButtonElement>('.suggestion-menu [role="option"]')!.click());
expect(onBoard).toHaveBeenCalledWith(editor);
});

it('adds callouts, toggles and tables', async () => {
Expand Down
Loading
Loading