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 @@ -41,6 +41,7 @@
"@tiptap/extension-node-range": "3.31.4",
"@tiptap/extension-table": "3.31.4",
"@tiptap/extension-text-style": "3.31.4",
"@tiptap/extension-unique-id": "3.31.4",
"@tiptap/extensions": "3.31.4",
"@tiptap/pm": "3.31.4",
"@tiptap/react": "3.31.4",
Expand Down
76 changes: 69 additions & 7 deletions app/frontend/src/components/NoteComments.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { timeAgo } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { useAction } from '../useAction';
Expand All @@ -8,14 +8,34 @@ import { LoadError } from './LoadError';
interface NoteComment {
id: string;
parentId: string | null;
blockId: string | null;
body: string;
resolvedAt: string | null;
editedAt: string | null;
createdAt: string;
author: { id: string; displayName: string } | null;
}

function CommentForm({ label, onSave, initial = '', onCancel }: { label: string; onSave: (body: string) => Promise<boolean>; initial?: string; onCancel?: () => void }) {
export interface QuotedBlock {
id: string;
excerpt: string;
}

const blockElement = (blockId: string) => document.querySelector<HTMLElement>(`.note-content [data-id="${CSS.escape(blockId)}"]`);

function CommentForm({
label,
onSave,
initial = '',
onCancel,
autoFocus,
}: {
label: string;
onSave: (body: string) => Promise<boolean>;
initial?: string;
onCancel?: () => void;
autoFocus?: boolean;
}) {
const [body, setBody] = useState(initial);
const [busy, setBusy] = useState(false);
return (
Expand All @@ -30,7 +50,7 @@ function CommentForm({ label, onSave, initial = '', onCancel }: { label: string;
});
}}
>
<textarea aria-label={label} maxLength={2_000} onChange={(event) => setBody(event.currentTarget.value)} placeholder={label} rows={2} value={body} />
<textarea aria-label={label} autoFocus={autoFocus} maxLength={2_000} onChange={(event) => setBody(event.currentTarget.value)} placeholder={label} rows={2} value={body} />
<div className="flex gap-2">
<button className="secondary-button px-3 py-1.5 text-sm" disabled={!body.trim() || busy} type="submit">
{label}
Expand All @@ -45,13 +65,33 @@ function CommentForm({ label, onSave, initial = '', onCancel }: { label: string;
);
}

export function NoteComments({ noteId, editable, owner }: { noteId: string; editable: boolean; owner: boolean }) {
export function NoteComments({
noteId,
editable,
owner,
quoting,
onQuoteDone,
onBlocks,
}: {
noteId: string;
editable: boolean;
owner: boolean;
quoting?: QuotedBlock;
onQuoteDone: () => void;
onBlocks: (blockIds: string[]) => void;
}) {
const auth = useAuth();
const comments = useResource<NoteComment[]>(`/notes/${noteId}/comments`, true);
const action = useAction();
const [replying, setReplying] = useState<string>();
const [editing, setEditing] = useState<string>();
const [showResolved, setShowResolved] = useState(false);
const commented = (comments.data ?? [])
.filter((comment) => comment.blockId && !comment.parentId && !comment.resolvedAt)
.map((comment) => comment.blockId!)
.join(' ');

useEffect(() => onBlocks(commented ? commented.split(' ') : []), [commented, onBlocks]);

if (comments.error) return <LoadError compact error={comments.error} onRetry={comments.reload} />;
if (!comments.data) return null;
Expand All @@ -61,11 +101,12 @@ export function NoteComments({ noteId, editable, owner }: { noteId: string; edit
const shown = threads.filter((thread) => showResolved || !thread.resolvedAt);

const replace = (saved: NoteComment) => comments.mutate((current) => current.map((comment) => (comment.id === saved.id ? saved : comment)));
const add = (body: string, parentId?: string) =>
const add = (body: string, parentId?: string, blockId?: string) =>
action.run(async () => {
const saved = await auth.request<NoteComment>(`/notes/${noteId}/comments`, { method: 'POST', body: JSON.stringify({ body, parentId }) });
const saved = await auth.request<NoteComment>(`/notes/${noteId}/comments`, { method: 'POST', body: JSON.stringify({ body, parentId, blockId }) });
comments.mutate((current) => [...current, saved]);
setReplying(undefined);
if (blockId) onQuoteDone();
return '';
}, 'The comment could not be saved.');
const edit = (id: string, body: string) =>
Expand Down Expand Up @@ -97,6 +138,20 @@ export function NoteComments({ noteId, editable, owner }: { noteId: string; edit
{comment.author?.displayName ?? 'Former member'} · {timeAgo(comment.createdAt)}
{comment.editedAt ? ' · edited' : ''}
</p>
{comment.blockId && (
<button
className="comment-block-quote"
onClick={() => {
const block = blockElement(comment.blockId!);
block?.scrollIntoView({ behavior: 'smooth', block: 'center' });
block?.classList.add('is-flashing');
window.setTimeout(() => block?.classList.remove('is-flashing'), 1600);
}}
type="button"
>
{blockElement(comment.blockId)?.textContent?.trim().slice(0, 80) || 'A block that was removed'}
</button>
)}
<p className="m-0 whitespace-pre-wrap text-sm text-ink">{comment.body}</p>
<div className="flex flex-wrap gap-3 text-sm">
{!comment.parentId && !comment.resolvedAt && (
Expand Down Expand Up @@ -143,7 +198,14 @@ export function NoteComments({ noteId, editable, owner }: { noteId: string; edit
</div>
</article>
))}
<CommentForm label="Comment" onSave={(body) => add(body)} />
{quoting ? (
<div className="grid gap-2">
<p className="comment-block-quote m-0">{quoting.excerpt || 'An empty block'}</p>
<CommentForm autoFocus key={quoting.id} label="Comment on this block" onCancel={onQuoteDone} onSave={(body) => add(body, undefined, quoting.id)} />
</div>
) : (
<CommentForm label="Comment" onSave={(body) => add(body)} />
)}
</section>
);
}
34 changes: 28 additions & 6 deletions app/frontend/src/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ 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, internalPath, pageContent, type BlockActions } from './note-extensions';
import type { QuotedBlock } from './NoteComments';
import { blockChoices, blockMenuExtension, CommentedBlocks, commentedBlocksKey, internalPath, pageContent, type BlockActions } from './note-extensions';

const turnInto = new Set(['text', 'heading', 'subheading', 'bullets', 'numbers', 'checklist', 'quote', 'code', 'callout', 'toggle']);

Expand Down Expand Up @@ -56,7 +57,19 @@ function Toolbar({ editor }: { editor: Editor }) {
);
}

function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connection: Connection; editable: boolean; noteId: string; spaceId: string }) {
interface CommentHooks {
onComment?: (block: QuotedBlock) => void;
commentedBlocks?: string[];
}

function CollaborativeEditor({
connection,
editable,
noteId,
spaceId,
onComment,
commentedBlocks,
}: { connection: Connection; editable: boolean; noteId: string; spaceId: string } & CommentHooks) {
const auth = useAuth();
const show = useSnackbar();
const navigate = useNavigate();
Expand All @@ -74,6 +87,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
Collaboration.configure({ document: connection.document }),
CollaborationCaret.configure({ provider: connection.provider, user: { name: user.displayName, color: colorFor(user.id) } }),
blockMenuExtension(actions),
CommentedBlocks,
],
editorProps: {
attributes: { 'aria-label': 'Page content', class: 'note-content' },
Expand Down Expand Up @@ -128,6 +142,11 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
if (outdated) editor?.setEditable(false);
}, [editor, outdated]);

const commented = (commentedBlocks ?? []).join(' ');
useEffect(() => {
if (editor) editor.view.dispatch(editor.state.tr.setMeta(commentedBlocksKey, new Set(commented ? commented.split(' ') : [])).setMeta('addToHistory', false));
}, [editor, commented]);

if (!editor) return <LinesSkeleton label="Loading the editor" lines={6} />;
return (
<div className="note-editor grid gap-3">
Expand All @@ -146,7 +165,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
</span>
</div>
</div>
{editable && !outdated && <BlockHandle actions={actions} editor={editor} />}
{editable && !outdated && <BlockHandle actions={actions} editor={editor} onComment={onComment} />}
<EditorContent editor={editor} />
</div>
);
Expand All @@ -162,7 +181,7 @@ function HoldHandle({ editor }: { editor: Editor }) {
return null;
}

function BlockHandle({ actions, editor }: { actions: BlockActions; editor: Editor }) {
function BlockHandle({ actions, editor, onComment }: { actions: BlockActions; editor: Editor; onComment?: (block: QuotedBlock) => void }) {
const [block, setBlock] = useState<{ node: BlockNode; pos: number }>();
const at = (pos: number, bias: 1 | -1 = 1) =>
editor.chain().focus().command(({ tr }) => {
Expand All @@ -173,6 +192,9 @@ function BlockHandle({ actions, editor }: { actions: BlockActions; editor: Edito
? [
{ label: 'Duplicate', onSelect: () => editor.chain().focus().insertContentAt(block.pos + block.node.nodeSize, block.node.toJSON()).run() },
{ label: 'Delete', onSelect: () => editor.chain().focus().deleteRange({ from: block.pos, to: block.pos + block.node.nodeSize }).run() },
...(onComment && typeof block.node.attrs.id === 'string'
? [{ label: 'Comment', onSelect: () => onComment({ id: block.node.attrs.id as string, excerpt: block.node.textContent.trim().slice(0, 80) }) }]
: []),
...(block.node.type.name === 'table'
? [
{ label: 'Add a row', onSelect: () => at(block.pos + block.node.nodeSize - 2, -1).addRowAfter().run(), separated: true },
Expand All @@ -197,8 +219,8 @@ function BlockHandle({ actions, editor }: { actions: BlockActions; editor: Edito
);
}

export default function NoteEditor({ editable, noteId, spaceId }: { editable: boolean; noteId: string; spaceId: string }) {
export default function NoteEditor({ editable, noteId, spaceId, ...hooks }: { editable: boolean; noteId: string; spaceId: string } & CommentHooks) {
const connection = useLiveDocument(noteId);
if (!connection) return <LinesSkeleton label="Loading the editor" lines={6} />;
return <CollaborativeEditor connection={connection} editable={editable} noteId={noteId} spaceId={spaceId} />;
return <CollaborativeEditor connection={connection} editable={editable} noteId={noteId} spaceId={spaceId} {...hooks} />;
}
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 = 3;
export const editorFormat = 4;

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

Expand Down
34 changes: 34 additions & 0 deletions app/frontend/src/components/note-extensions.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { Extension, mergeAttributes, Node, type ChainedCommands, type Editor } from '@tiptap/core';
import { isChangeOrigin } from '@tiptap/extension-collaboration';
import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details';
import Highlight from '@tiptap/extension-highlight';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { TableKit } from '@tiptap/extension-table';
import UniqueID from '@tiptap/extension-unique-id';
import StarterKit from '@tiptap/starter-kit';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type NodeViewProps } from '@tiptap/react';
Expand Down Expand Up @@ -319,6 +323,34 @@ export const BoardEmbedExtension = Node.create({
addNodeView: () => ReactNodeViewRenderer(BoardEmbed),
});

export const commentedBlocksKey = new PluginKey<Set<string>>('commentedBlocks');

export const CommentedBlocks = Extension.create({
name: 'commentedBlocks',
addProseMirrorPlugins: () => [
new Plugin<Set<string>>({
key: commentedBlocksKey,
state: {
init: () => new Set(),
apply: (transaction, current) => (transaction.getMeta(commentedBlocksKey) as Set<string> | undefined) ?? current,
},
props: {
decorations(state) {
const commented = commentedBlocksKey.getState(state);
if (!commented?.size) return null;
const marks: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (typeof node.attrs.id === 'string' && commented.has(node.attrs.id)) marks.push(Decoration.node(pos, pos + node.nodeSize, { class: 'has-comments' }));
});
return DecorationSet.create(state.doc, marks);
},
},
}),
],
});

const blockTypes = ['paragraph', 'heading', 'blockquote', 'codeBlock', 'callout', 'details', 'table', 'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem', 'image', 'boardEmbed', 'horizontalRule'];

export function pageContent(request: Request, noteId: string) {
return [
StarterKit.configure({ undoRedo: false, link: { openOnClick: true, autolink: true, protocols: ['https', 'http', 'mailto'] } }),
Expand All @@ -333,5 +365,7 @@ export function pageContent(request: Request, noteId: string) {
mentionExtension(request, noteId),
BoardEmbedExtension,
StoredImageExtension,
UniqueID.configure({ types: blockTypes, filterTransaction: (transaction) => !isChangeOrigin(transaction) }),
];
}

26 changes: 23 additions & 3 deletions app/frontend/src/pages/NotePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { DatabaseView } from '../components/database/DatabaseView';
import { IconPicker } from '../components/IconPicker';
import { PropertyField } from '../components/database/PropertyField';
import { LoadError } from '../components/LoadError';
import { NoteComments } from '../components/NoteComments';
import { NoteComments, type QuotedBlock } from '../components/NoteComments';
import { NoteHistory } from '../components/NoteHistory';
import { ShareLinks } from '../components/ShareLinks';
import { announcePagesChanged, noteTitle, type Note, type NoteDetails, type PropertyValues } from '../notes';
Expand Down Expand Up @@ -35,6 +35,8 @@ export function NotePage() {
const note = useResource<NoteDetails>(`/notes/${id}`, true);
const [wide, setWide] = useState(() => widePages.has(id));
if (wide !== widePages.has(id)) setWide(widePages.has(id));
const [quoting, setQuoting] = useState<QuotedBlock & { noteId: string }>();
const [commentedBlocks, setCommentedBlocks] = useState<string[]>([]);
const pages = useResource<Note[]>(note.data ? `/spaces/${note.data.spaceId}/notes` : null, true);
const links = useResource<{ backlinks: { id: string; kind: string; item: { id: string; spaceId: string; kind: string; title: string } }[] }>(
note.data ? `/items/${note.data.id}/links` : null,
Expand Down Expand Up @@ -217,7 +219,17 @@ export function NotePage() {
{details.kind === 'board' ? (
<BoardCanvas editable={details.editable} key={details.id} noteId={details.id} spaceId={details.spaceId} />
) : (
<NoteEditor editable={details.editable} key={details.id} noteId={details.id} spaceId={details.spaceId} />
<NoteEditor
commentedBlocks={commentedBlocks}
editable={details.editable}
key={details.id}
noteId={details.id}
onComment={(block) => {
setQuoting({ ...block, noteId: details.id });
window.setTimeout(() => document.querySelector('section[aria-label="Comments"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
}}
spaceId={details.spaceId}
/>
)}
</Suspense>
{details.database && <DatabaseView editable={details.editable} key={details.id} noteId={details.id} spaceId={details.spaceId} />}
Expand Down Expand Up @@ -273,7 +285,15 @@ export function NotePage() {
</ul>
</section>
)}
<NoteComments editable={details.editable} key={`comments-${details.id}`} noteId={details.id} owner={space?.role === 'owner'} />
<NoteComments
editable={details.editable}
key={`comments-${details.id}`}
noteId={details.id}
onBlocks={setCommentedBlocks}
onQuoteDone={() => setQuoting(undefined)}
owner={space?.role === 'owner'}
quoting={quoting?.noteId === details.id ? quoting : undefined}
/>
{unconverting && (
<ConfirmDialog
confirmLabel="Turn back into a page"
Expand Down
23 changes: 23 additions & 0 deletions app/frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -629,3 +629,26 @@ button.habit-day {
background: var(--color-line-soft);
}

.note-content .has-comments {
box-shadow: inset 3px 0 0 #d7b24a;
padding-left: 0.6rem;
}

.note-content .is-flashing {
background: var(--color-line-soft);
transition: background 400ms ease;
}

.comment-block-quote {
width: fit-content;
max-width: 100%;
overflow: hidden;
border-left: 3px solid #d7b24a;
padding-left: 0.6rem;
color: var(--color-muted);
font-size: 0.85rem;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}

Loading
Loading