8000 feat(notes): notes tree in the navigation, note pages and the BlockSuite editor by adeelahmad · Pull Request #122 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
21 changes: 21 additions & 0 deletions nextjs-frontend/__tests__/notes-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { hrefFor, pageOf } from "@/components/notes/links";

describe("links in notes", () => {
it("open the thing in the app", () => {
expect(hrefFor("page:3")).toBe("/notes/3");
expect(hrefFor("recording:12")).toBe("/resources/12");
expect(hrefFor("entity:5", "pods")).toBe("/entities?entity=5&ns=pods");
expect(hrefFor("speaker:7")).toBe("/speakers/7");
expect(hrefFor("collection:4", "pods")).toBe("/library?namespace=pods&collection=4");
});

it("ignore what isn't a link to Lens", () => {
expect(hrefFor("https://example.com")).toBeNull();
expect(hrefFor("entity:x")).toBeNull();
expect(hrefFor("thing:3")).toBeNull();
});

it("find a thing's page", () => {
expect(pageOf("entity:5")).toBe("/notes/about/entity/5");
});
});
11 changes: 11 additions & 0 deletions nextjs-frontend/app/(app)/notes/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Metadata } from "next";

import { NotePage } from "@/components/notes/note-page";

export const metadata: Metadata = { title: "Note" };

/** A free note, opened from the tree in the left navigation or from a link in another note. */
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <NotePage key={id} id={Number(id)} />;
}
11 changes: 11 additions & 0 deletions nextjs-frontend/app/(app)/notes/about/[kind]/[key]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Metadata } from "next";

import { NotePage } from "@/components/notes/note-page";

export const metadata: Metadata = { title: "Note" };

/** The page of a recording, entity, collection or speaker: a draft until someone writes on it. */
export default async function Page({ params }: { params: Promise<{ kind: string; key: string }> }) {
const { kind, key } = await params;
return <NotePage key={`${kind}:${key}`} about={`${kind}:${key}`} />;
}
10 changes: 10 additions & 0 deletions nextjs-frontend/app/(app)/notes/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Metadata } from "next";

import { NotesHome } from "@/components/notes/notes-home";

export const metadata: Metadata = { title: "Notes" };

/** Notes: free notes of the namespace in view, newest first; the tree is in the left navigation. */
export default function Page() {
return <NotesHome />;
}
2 changes: 2 additions & 0 deletions nextjs-frontend/components/app-shell/nav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
LibraryBig,
type LucideIcon,
MessagesSquare,
NotebookPen,
RadioTower,
Search,
Settings,
Expand Down Expand Up @@ -41,6 +42,7 @@ export const NAV: NavItem[] = [
},
{ label: "Search", href: "/search", icon: Search },
{ label: "Chat", href: "/chat", icon: MessagesSquare },
{ label: "Notes", href: "/notes", icon: NotebookPen },
{ label: "Entities", href: "/entities", icon: Shapes },
{ label: "Graph", href: "/graph", icon: Waypoints },
{
Expand Down
9 changes: 8 additions & 1 deletion nextjs-frontend/components/app-shell/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";

import { isActive, navFor } from "@/components/app-shell/nav-config";
import { NotesTree } from "@/components/notes/notes-tree";
import { Brand } from "@/components/brand";
import { Tooltip } from "@/components/ui/tooltip";
import { count } from "@/lib/format";
Expand Down Expand Up @@ -77,7 +78,13 @@ export function Nav({
</div>
);
})}
<div className="flex-1" />
{collapsed ? (
<div className="flex-1" />
) : (
<div className="flex min-h-0 flex-1 flex-col">
<NotesTree />
</div>
)}
{onToggle && (
<button
type="button"
Expand Down
13 changes: 9 additions & 4 deletions nextjs-frontend/components/entities/entity-drawer.tsx
BD0
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff, Waypoints } from "lucide-react";
import { Eye, EyeOff, NotebookPen, Waypoints } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";

Expand Down Expand Up @@ -129,9 +129,14 @@ export function EntityDrawer({
width={440}
actions={
d && (
<Button asChild size="xs" variant="ghost" icon={<Waypoints />}>
<Link href={`/graph?focus=e${id}`}>Graph</Link>
</Button>
<>
<Button asChild size="xs" variant="ghost" icon={<NotebookPen />}>
<Link href={`/notes/about/entity/${id}`}>Page</Link>
</Button>
<Button asChild size="xs" variant="ghost" icon={<Waypoints />}>
<Link href={`/graph?focus=e${id}`}>Graph</Link>
</Button>
</>
)
}
>
Expand Down
198 changes: 198 additions & 0 deletions nextjs-frontend/components/notes/block-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"use client";

import "@toeverything/theme/style.css";

import { useEffect, useRef } from "react";

import { cn } from "@/lib/utils";

export type LinkTarget = { target: string; label: string; kind: string };
export type EditorChange = { markdown: string; doc: string };

type Props = {
/** The page's Markdown: read when there is no `doc` (a new page, or one the assistant last wrote). */
markdown: string;
/** The editor's own state (a Yjs update, base64) from the last save, when there is one. */
doc: string | null;
readOnly?: boolean;
onChange: (change: EditorChange) => void;
/** What @ (resources, people, pages) and # (topics) offer for what was typed after them. */
search: (sign: "@" | "#", query: string) => Promise<LinkTarget[]>;
/** A click on a link to something in Lens ("entity:5", "page:3"). */
onOpenLink: (target: string) => void;
className?: string;
};

const LENS_LINK = /^(recording|entity|collection|speaker|page):\d+$/;

function toBase64(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(s);
}

function fromBase64(text: string): Uint8Array {
const s = atob(text);
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}

/** AFFiNE's BlockSuite editor on one page. Its documents are Yjs CRDTs (what OctoBase stores and syncs). It keeps the
* Markdown and its own state in step: @ and # open a menu of what to link, written as @[label](kind:id) in the
* Markdown. Loaded in the browser only. */
export default function BlockEditor({ markdown, doc, readOnly, onChange, search, onOpenLink, className }: Props) {
const host = useRef<HTMLDivElement>(null);
const latest = useRef({ onChange, search, onOpenLink });
latest.current = { onChange, search, onOpenLink };
// The page is read once per mount: the parent remounts the editor (key) for another page.
const initial = useRef({ markdown, doc, readOnly });

useEffect(() => {
let disposed = false;
let cleanup = () => {};
(async () => {
const [blockEffects, presetEffects, presets, blocks, store, std, Y] = await Promise.all([
import("@blocksuite/blocks/effects"),
import("@blocksuite/presets/effects"),
import("@blocksuite/presets"),
import("@blocksuite/blocks"),
import("@blocksuite/store"),
import("@blocksuite/block-std"),
import("yjs"),
]);
if (!customElements.get("affine-editor-container")) {
blockEffects.effects();
presetEffects.effects();
}
if (disposed || !host.current) return;
const { markdown, doc: state, readOnly } = initial.current;
const schema = new store.Schema().register(blocks.AffineSchemas);
const collection = new store.DocCollection({ schema });
collection.meta.initialize();

let id = "page";
if (state) {
const d = collection.createDoc({ id });
d.load();
Y.applyUpdate(d.spaceDoc, fromBase64(state));
} else {
const job = new store.Job({ collection });
const made = markdown.trim()
? await new blocks.MarkdownAdapter(job).toDoc({ file: markdown, assets: job.assetsManager })
: undefined;
if (made) id = made.id;
else {
const d = collection.createDoc({ id });
d.load(() => {
const root = d.addBlock("affine:page", {});
d.addBlock("affine:surface", {}, root);
d.addBlock("affine:paragraph", {}, d.addBlock("affine:note", {}, root));
});
}
}
const page = collection.getDoc(id, { readonly: Boolean(readOnly) });
if (!page || disposed || !host.current) return;
page.load();

const linkedWidget = {
triggerKeys: ["@", "#"],
convertTriggerKey: false,
ignoreBlockTypes: ["affine:code"],
getMenus: async (
query: string,
abort: () => void,
_host: unknown,
inline: {
yTextString: string;
getInlineRange: () => { index: number; length: number } | null;
deleteText: (r: { index: number; length: number }) => void;
insertText: (r: { index: number; length: number }, text: string, attrs?: Record<string, unknown>) => void;
setInlineRange: (r: { index: number; length: number }) => void;
},
) => {
const range = inline.getInlineRange();
const at = range ? range.index - query.length - 1 : -1;
const sign = inline.yTextString[at] === "#" ? "#" : "@";
const hits = await latest.current.search(sign, query).catch(() => [] as LinkTarget[]);
return [
{
name: sign === "#" ? "Topics" : "Link to",
maxDisplay: 8,
overflowText: "more",
items: hits.map((h) => ({
key: h.target,
name: h.label,
icon: sign === "#" ? blocks.TagsIcon : blocks.LinkedDocIcon,
suffix: h.kind,
action: () => {
abort(); // takes away what was typed: the sign and the query
inline.insertText({ index: at, length: 0 }, sign);
inline.insertText({ index: at + 1, length: 0 }, h.label, { link: h.target });
inline.insertText({ index: at + 1 + h.label.length, length: 0 }, " ");
inline.setInlineRange({ index: at + 2 + h.label.length, length: 0 });
},
})),
},
];
},
};

const editor = new presets.AffineEditorContainer();
editor.pageSpecs = [...blocks.PageEditorBlockSpecs, std.ConfigExtension("affine:page", { linkedWidget })];
editor.doc = page;
editor.mode = "page";
host.current.replaceChildren(editor);

// Links to things in Lens open in the app, not as web addresses.
const MouseEvent) => {
const a = (e.composedPath().find((n) => n instanceof HTMLAnchorElement) ?? null) as HTMLAnchorElement | null;
const href = a?.getAttribute("href") ?? "";
if (!LENS_LINK.test(href)) return;
e.preventDefault();
e.stopPropagation();
latest.current.onOpenLink(href);
};
host.current.addEventListener("click", onClick, true);

let timer: ReturnType<typeof setTimeout> | undefined;
const save = async (flush = false) => {
const job = new store.Job({ collection });
const snapshot = job.docToSnapshot(page);
if (!snapshot) return;
const out = await new blocks.MarkdownAdapter(job).fromDocSnapshot({ snapshot, assets: job.assetsManager });
if (disposed && !flush) return;
const title = String((page.root as { title?: { toString(): string } } | null)?.title ?? "");
let md = out.file.trim();
if (title && md.startsWith(`# ${title}`)) md = md.slice(title.length + 2).trimStart();
latest.current.onChange({
markdown: md,
doc: toBase64(Y.encodeStateAsUpdate(page.spaceDoc)),
});
};
const changed = page.slots.blockUpdated.on(() => {
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void save();
}, 600);
});
const el = host.current;
cleanup = () => {
if (timer) void save(true); // what was typed in the last moment before leaving the page
clearTimeout(timer);
changed.dispose();
el.removeEventListener("click", onClick, true);
el.replaceChildren();
collection.dispose();
};
})().catch((err) => console.error("The editor didn't load", err));
return () => {
disposed = true;
cleanup();
};
}, []);

// The page's title is Lens's own field above the editor, so the editor's title line is hidden.
return <div ref={host} className={cn("lens-block-editor min-h-[240px] [&_doc-title]:hidden", className)} />;
}
33 changes: 33 additions & 0 deletions nextjs-frontend/components/notes/links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** Where a link in a note goes in the app: "recording:12", "entity:5", "page:3", "collection:4", "speaker:7". */
export function hrefFor(target: string, ns?: string | null): string | null {
const [kind, id] = target.split(":");
if (!id || !/^\d+$/.test(id)) return null;
const q = ns ? `&ns=${encodeURIComponent(ns)}` : "";
switch (kind) {
case "page":
return `/notes/${id}`;
case "recording":
return `/resources/${id}`;
case "entity":
return `/entities?entity=${id}${q}`;
case "speaker":
return `/speakers/${id}`;
case "collection":
return ns ? `/library?namespace=${encodeURIComponent(ns)}&collection=${id}` : "/library";
default:
return null;
}
}

/** The page of a thing: /notes/about/entity/5. */
export function pageOf(target: string): string {
const [kind, id] = target.split(":");
return `/notes/about/${kind}/${id}`;
}

export const PLACES = [
{ value: "project", label: "Project" },
{ value: "area", label: "Area" },
{ value: "resource", label: "Resource" },
{ value: "archive", label: "Archive" },
] as const;
Loading
Loading
0