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
3 changes: 3 additions & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@
"@tiptap/core": "3.31.4",
"@tiptap/extension-collaboration": "3.31.4",
"@tiptap/extension-collaboration-caret": "3.31.4",
"@tiptap/extension-image": "3.31.4",
"@tiptap/extension-list": "3.31.4",
"@tiptap/extension-mention": "3.31.4",
"@tiptap/extensions": "3.31.4",
"@tiptap/pm": "3.31.4",
"@tiptap/react": "3.31.4",
"@tiptap/starter-kit": "3.31.4",
"@tiptap/suggestion": "3.31.4",
"uqr": "0.1.3",
"y-indexeddb": "9.0.12",
"yjs": "13.6.33"
Expand Down
50 changes: 46 additions & 4 deletions app/frontend/src/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { Avatar, LinesSkeleton, useSnackbar } from '@project-graphite/ui';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCaret from '@tiptap/extension-collaboration-caret';
import { TaskItem, TaskList } from '@tiptap/extension-list';
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 { mentionExtension, StoredImageExtension } from './note-extensions';

interface Connection {
document: Y.Doc;
Expand Down Expand Up @@ -68,9 +73,10 @@ function Toolbar({ editor }: { editor: Editor }) {
);
}

function CollaborativeEditor({ connection, editable }: { connection: Connection; editable: boolean }) {
function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connection: Connection; editable: boolean; noteId: string; spaceId: string }) {
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);
Expand All @@ -86,12 +92,48 @@ function CollaborativeEditor({ connection, editable }: { connection: Connection;
Placeholder.configure({ placeholder: editable ? 'Start writing…' : '' }),
Collaboration.configure({ document: connection.document }),
CollaborationCaret.configure({ provider: connection.provider, user: { name: user.displayName, color: colorFor(user.id) } }),
mentionExtension(auth.request, noteId),
StoredImageExtension,
],
editorProps: { attributes: { 'aria-label': 'Page content', class: 'note-content' } },
editorProps: {
attributes: { 'aria-label': 'Page content', class: 'note-content' },
handleClickOn: (_view, _position, node) => {
if (node.type.name !== 'mention' || !node.attrs.href) return false;
navigate(String(node.attrs.href));
return true;
},
handlePaste: (view, event) => insertImages(view, [...(event.clipboardData?.files ?? [])]),
handleDrop: (view, event) => insertImages(view, [...((event as DragEvent).dataTransfer?.files ?? [])]),
},
},
[connection],
);

function insertImages(view: EditorView, files: File[]) {
const images = files.filter((file) => file.type.startsWith('image/'));
if (!editable || images.length === 0) return false;
void (async () => {
for (const file of images) {
if (file.size > maxUploadBytes) {
show({ message: `${file.name} is over 25 MB.`, tone: 'error' });
continue;
}
try {
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),
});
const image = view.state.schema.nodes.image!.create({ fileId: uploaded.id, alt: file.name });
view.dispatch(view.state.tr.replaceSelectionWith(image));
} catch {
show({ message: `Could not upload ${file.name}.`, tone: 'error' });
}
}
})();
return true;
}

useEffect(() => {
const { provider } = connection;
const onStatus = ({ status: next }: { status: 'connecting' | 'connected' | 'disconnected' }) => setStatus(next);
Expand Down Expand Up @@ -153,7 +195,7 @@ function CollaborativeEditor({ connection, editable }: { connection: Connection;
);
}

export default function NoteEditor({ editable, noteId }: { editable: boolean; noteId: string }) {
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);
Expand All @@ -175,5 +217,5 @@ export default function NoteEditor({ editable, noteId }: { editable: boolean; no
}, [noteId]);

if (!connection) return <LinesSkeleton label="Loading the editor" lines={6} />;
return <CollaborativeEditor connection={connection} editable={editable} />;
return <CollaborativeEditor connection={connection} editable={editable} noteId={noteId} spaceId={spaceId} />;
}
181 changes: 181 additions & 0 deletions app/frontend/src/components/note-extensions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type NodeViewProps } from '@tiptap/react';
import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion';
import { readBlob } from '../api';
import { useAuth } from '../auth';
import { itemHref } from '../spaces';

export interface MentionCandidate {
id: string;
label: string;
href: string;
hint: string;
}

interface SearchReply {
items: { id: string; spaceId: string; spaceName: string; kind: string; title: string }[];
}

type Request = ReturnType<typeof useAuth>['request'];

interface ListHandle {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}

const MentionList = forwardRef<ListHandle, SuggestionProps<MentionCandidate>>(function MentionList({ command, items }, ref) {
const [active, setActive] = useState(0);
useEffect(() => setActive(0), [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (items.length === 0) return false;
if (event.key === 'ArrowDown') setActive((current) => (current + 1) % items.length);
else if (event.key === 'ArrowUp') setActive((current) => (current - 1 + items.length) % items.length);
else if (event.key === 'Enter') command({ id: items[active]!.id, label: items[active]!.label, href: items[active]!.href } as never);
else return false;
return true;
},
}));
return (
<div className="mention-menu" role="listbox">
{items.length === 0 ? (
<p className="m-0 px-3 py-2 text-sm text-muted">Nothing matches</p>
) : (
items.map((item, index) => (
<button
aria-selected={index === active}
className="mention-option"
key={item.id}
onClick={() => command({ id: item.id, label: item.label, href: item.href } as never)}
onMouseEnter={() => setActive(index)}
role="option"
type="button"
>
<span className="min-w-0 truncate">{item.label}</span>
<span className="mono-sm shrink-0 text-faint">{item.hint}</span>
</button>
))
)}
</div>
);
});

export function mentionExtension(request: Request, noteId: string) {
return Mention.extend({
addAttributes() {
return {
...this.parent?.(),
href: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-href'),
renderHTML: (attributes: { href?: string | null }) => (attributes.href ? { 'data-href': attributes.href } : {}),
},
};
},
}).configure({
HTMLAttributes: { class: 'mention' },
renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`,
renderHTML: ({ node, options }) => ['span', { ...options.HTMLAttributes, 'data-href': node.attrs.href }, `@${node.attrs.label ?? node.attrs.id}`],
suggestion: {
items: async ({ query }) => {
if (query.trim().length < 2) return [];
const reply = await request<SearchReply>(`/search?q=${encodeURIComponent(query.trim())}`).catch(() => ({ items: [] }));
return reply.items
.filter((item) => item.id !== noteId)
.slice(0, 8)
.map((item) => ({ id: item.id, label: item.title || 'Untitled', href: itemHref(item) ?? `/spaces/${item.spaceId}`, hint: `${item.kind} · ${item.spaceName}` }));
},
render: () => {
let renderer: ReactRenderer<ListHandle, SuggestionProps<MentionCandidate>> | undefined;
const place = (props: SuggestionProps<MentionCandidate>) => {
const box = props.clientRect?.();
const element = renderer?.element as HTMLElement | undefined;
if (!box || !element) return;
element.style.position = 'fixed';
element.style.left = `${box.left}px`;
element.style.top = `${box.bottom + 6}px`;
element.style.zIndex = '50';
};
return {
onStart: (props) => {
renderer = new ReactRenderer(MentionList, { props, editor: props.editor });
document.body.append(renderer.element);
place(props);
},
onUpdate: (props) => {
renderer?.updateProps(props);
place(props);
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
renderer?.destroy();
renderer?.element.remove();
renderer = undefined;
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
renderer?.destroy();
renderer?.element.remove();
renderer = undefined;
},
};
},
},
});
}

function StoredImage({ node, selected }: NodeViewProps) {
const { request } = useAuth();
const [source, setSource] = useState<string>();
const [failed, setFailed] = useState(false);
const fileId = node.attrs.fileId as string | null;

useEffect(() => {
if (!fileId) return;
let url = '';
let cancelled = false;
request<Blob>(`/files/${fileId}`, {}, readBlob).then(
(blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setSource(url);
},
() => !cancelled && setFailed(true),
);
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [fileId, request]);

return (
<NodeViewWrapper className={`note-image ${selected ? 'is-selected' : ''}`} data-drag-handle="">
{failed ? (
<span className="text-sm text-muted">This image is no longer available.</span>
) : source ? (
<img alt={(node.attrs.alt as string) ?? ''} src={source} />
) : (
<span aria-hidden="true" className="skeleton block h-40 w-full" />
)}
</NodeViewWrapper>
);
}

export const StoredImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
fileId: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-file-id'),
renderHTML: (attributes: { fileId?: string | null }) => (attributes.fileId ? { 'data-file-id': attributes.fileId } : {}),
},
};
},
addNodeView() {
return ReactNodeViewRenderer(StoredImage);
},
});
24 changes: 22 additions & 2 deletions app/frontend/src/pages/NotePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useAuth } from '../auth';
import { LoadError } from '../components/LoadError';
import { NoteHistory } from '../components/NoteHistory';
import { noteTitle, type Note, type NoteDetails } from '../notes';
import { useSpaces } from '../spaces';
import { itemHref, useSpaces } from '../spaces';
import { useAction } from '../useAction';
import { useResource } from '../useResource';

Expand All @@ -18,6 +18,11 @@ export function NotePage() {
const navigate = useNavigate();
const note = useResource<NoteDetails>(`/notes/${id}`, true);
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,
true,
);
const backlinks = (links.data?.backlinks ?? []).filter((link) => link.kind === 'mention');
const renaming = useAction();
const adding = useAction();
const [history, setHistory] = useState(false);
Expand Down Expand Up @@ -82,7 +87,7 @@ export function NotePage() {
readOnly={!details.editable}
/>
<Suspense fallback={<LinesSkeleton label="Loading the editor" lines={6} />}>
<NoteEditor editable={details.editable} key={details.id} noteId={details.id} />
<NoteEditor editable={details.editable} key={details.id} noteId={details.id} spaceId={details.spaceId} />
</Suspense>
{(children.length > 0 || details.editable) && (
<section className="grid gap-2 border-t border-line-soft pt-6">
Expand Down Expand Up @@ -121,6 +126,21 @@ export function NotePage() {
)}
</section>
)}
{backlinks.length > 0 && (
<section className="grid gap-2 border-t border-line-soft pt-6">
<h2 className="m-0 text-sm font-medium text-muted">Linked from</h2>
<ul className="m-0 grid list-none gap-1 p-0">
{backlinks.map(({ id: linkId, item }) => (
<li key={linkId}>
<Link className="text-ink no-underline hover:underline" to={itemHref(item) ?? `/spaces/${item.spaceId}`}>
{noteTitle(item)}
</Link>
<span className="mono-sm text-faint"> · {item.kind}</span>
</li>
))}
</ul>
</section>
)}
{history && <NoteHistory editable={details.editable} noteId={details.id} onClose={() => setHistory(false)} />}
</article>
);
Expand Down
Loading
Loading