"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
ArrowDownToLine,
BookOpen,
Check,
ChevronDown,
ChevronRight,
Copy,
FilePlus2,
Image,
LayoutTemplate,
Maximize2,
MessageSquare,
MoreHorizontal,
PanelLeft,
Plus,
Redo2,
Search,
Star,
Trash2,
Undo2,
} from "lucide-react";
import {
Editor,
createEditorStore,
defaultCatalog,
specToMarkdown,
type EditorSpec,
} from "@reopt-ai/opt-editor";
import {
Avatar,
Badge,
Button,
DialogRoot,
DialogPanel,
DialogHeader,
DialogHeading,
DialogDescription,
DialogBody,
DialogDismiss,
DropdownMenu,
DropdownTrigger,
DropdownContent,
DropdownItem,
DropdownSeparator,
Input,
Textarea,
cn,
} from "@reopt-ai/opt-ui";
import type { EditorExampleDemoProps } from "./basic-editor-demo";
import {
NOTES_STORAGE_KEY,
NOTE_TEMPLATES,
createNotesWorkspace,
descendantIds,
pageText,
parseNotesWorkspace,
templateDocument,
type NotesWorkspace,
type NoteTemplateId,
type WorkspacePage,
} from "./notes-workspace-data";
import styles from "./notes-app.module.css";
type WorkspaceDialog =
| "search"
| "templates"
| "trash"
| "comments"
| "navigation"
| null;
const ICONS = ["🌿", "📄", "🚀", "💭", "🗓️", "🔎", "🎨", "📚"];
function Modal({
open,
onClose,
title,
description,
children,
navigation = false,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: ReactNode;
navigation?: boolean;
}) {
return (
<DialogRoot
open={open}
onOpenChange={(value) => {
if (!value) onClose();
}}
>
<DialogPanel className={navigation ? styles.navigationDialog : undefined}>
<DialogHeader>
<div className="flex items-center justify-between gap-3">
<DialogHeading>{title}</DialogHeading>
<DialogDismiss aria-label="Close dialog" />
</div>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<DialogBody>{children}</DialogBody>
</DialogPanel>
</DialogRoot>
);
}
function PageEditor({
page,
pages,
onChange,
onNavigate,
}: {
page: WorkspacePage;
pages: WorkspacePage[];
onChange: (spec: EditorSpec) => void;
onNavigate: (id: string) => void;
}) {
// A store belongs to one mounted page. Metadata and autosave never recreate it.
const [store] = useState(() => createEditorStore(structuredClone(page.spec)));
const changeRef = useRef(onChange);
changeRef.current = onChange;
const [history, setHistory] = useState({ undo: false, redo: false });
useEffect(
() =>
store.subscribe(() => {
changeRef.current(store.getSpec());
setHistory({ undo: store.canUndo(), redo: store.canRedo() });
}),
[store],
);
return (
<>
<div
className={styles.editorTools}
role="toolbar"
aria-label="Document history"
>
<span>
Type <kbd>/</kbd> for blocks · <kbd>@</kbd> to link a page
</span>
<Button
variant="ghost"
size="sm"
aria-label="Undo edit"
disabled={!history.undo}
onClick={() => store.undo()}
>
<Undo2 size={15} />
</Button>
<Button
variant="ghost"
size="sm"
aria-label="Redo edit"
disabled={!history.redo}
onClick={() => store.redo()}
>
<Redo2 size={15} />
</Button>
</div>
<Editor
store={store}
catalog={defaultCatalog}
mode="edit"
ariaLabel="Page content"
className={styles.editor}
onMentionSearch={async (_kind, query) =>
pages
.filter(
(item) =>
!item.trashed &&
item.id !== page.id &&
item.title.toLowerCase().includes(query.toLowerCase()),
)
.map((item) => ({
kind: "page",
id: item.id,
label: item.title || "Untitled",
}))
}
renderMention={(mention) => (
<button
type="button"
className={styles.mention}
onClick={() => onNavigate(mention.id)}
>
{mention.label}
</button>
)}
/>
</>
);
}
export function NotesAppDemo({ compact = false }: EditorExampleDemoProps) {
const [workspace, setWorkspace] =
useState<NotesWorkspace>(createNotesWorkspace);
const [ready, setReady] = useState(false);
const [saveState, setSaveState] = useState<"saved" | "saving" | "error">(
"saved",
);
const [dialog, setDialog] = useState<WorkspaceDialog>(null);
const [query, setQuery] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [comment, setComment] = useState("");
const [notice, setNotice] = useState("");
const titleRef = useRef<HTMLTextAreaElement>(null);
const focusTitle = useRef(false);
const liveWorkspace = useRef(workspace);
liveWorkspace.current = workspace;
const saveEnabled = useRef(false);
useEffect(() => {
if (compact) {
setReady(true);
return;
}
try {
const raw = localStorage.getItem(NOTES_STORAGE_KEY);
if (raw) {
const restored = parseNotesWorkspace(raw);
if (restored) setWorkspace(restored);
else {
setSaveState("error");
setNotice(
"Saved pages could not be read. Export your work before leaving.",
);
setReady(true);
return;
}
}
saveEnabled.current = true;
} catch {
setSaveState("error");
}
setReady(true);
}, [compact]);
const save = useCallback(() => {
if (!saveEnabled.current) return;
try {
localStorage.setItem(
NOTES_STORAGE_KEY,
JSON.stringify(liveWorkspace.current),
);
setSaveState("saved");
} catch {
setSaveState("error");
}
}, []);
useEffect(() => {
if (!ready || compact || !saveEnabled.current) return;
setSaveState("saving");
const timeout = window.setTimeout(save, 350);
return () => window.clearTimeout(timeout);
}, [workspace, ready, compact, save]);
useEffect(() => {
if (!ready || compact) return;
window.addEventListener("pagehide", save);
return () => {
window.removeEventListener("pagehide", save);
save();
};
}, [ready, compact, save]);
useEffect(() => {
if (compact) return;
const handleKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
const selection = window.getSelection();
if (
event.target instanceof Element &&
event.target.closest("[data-opt-editor]") &&
selection &&
!selection.isCollapsed
)
return;
event.preventDefault();
event.stopImmediatePropagation();
setQuery("");
setDialog("search");
}
};
window.addEventListener("keydown", handleKey, true);
return () => window.removeEventListener("keydown", handleKey, true);
}, [compact]);
useEffect(() => {
setComment("");
if (focusTitle.current) {
titleRef.current?.focus();
titleRef.current?.select();
focusTitle.current = false;
}
}, [workspace.activeId]);
useEffect(() => {
if (!notice) return;
const timer = window.setTimeout(() => setNotice(""), 4000);
return () => window.clearTimeout(timer);
}, [notice]);
const pages = workspace.pages.filter((page) => !page.trashed);
const active =
pages.find((page) => page.id === workspace.activeId) ?? pages[0];
const children = pages.filter((page) => page.parentId === active?.id);
const parent = pages.find((page) => page.id === active?.parentId);
const results = pages.filter((page) =>
`${page.title} ${pageText(page)}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
const openComments =
active?.comments.filter((item) => !item.resolved).length ?? 0;
const selectPage = (id: string) => {
setWorkspace((current) => ({ ...current, activeId: id }));
setDialog(null);
setComment("");
};
const patchPage = useCallback(
(id: string, patch: Partial<WorkspacePage>) =>
setWorkspace((current) => ({
...current,
pages: current.pages.map((page) =>
page.id === id ? { ...page, ...patch } : page,
),
})),
[],
);
const createPage = (
template: NoteTemplateId = "blank",
parentId: string | null = null,
) => {
const preset = NOTE_TEMPLATES.find((item) => item.id === template)!;
const page: WorkspacePage = {
id: crypto.randomUUID(),
parentId,
title: template === "blank" ? "Untitled" : preset.title,
icon: preset.icon,
cover: "none",
favorite: false,
trashed: false,
wide: false,
spec: templateDocument(template),
comments: [],
};
focusTitle.current = true;
if (parentId)
setCollapsed((current) => {
const next = new Set(current);
next.delete(parentId);
return next;
});
setWorkspace((current) => ({
...current,
activeId: page.id,
pages: [...current.pages, page],
}));
setDialog(null);
};
const duplicatePage = () => {
if (!active) return;
const copy = {
...structuredClone(active),
id: crypto.randomUUID(),
title: `${active.title || "Untitled"} (copy)`,
favorite: false,
comments: [],
};
setWorkspace((current) => ({
...current,
activeId: copy.id,
pages: [...current.pages, copy],
}));
setNotice("Page duplicated");
};
const trashPage = () => {
if (!active) return;
const ids = descendantIds(workspace.pages, active.id);
setWorkspace((current) => ({
...current,
activeId: pages.find((page) => !ids.has(page.id))?.id ?? "",
pages: current.pages.map((page) =>
ids.has(page.id) ? { ...page, trashed: true } : page,
),
}));
setNotice("Page moved to Trash. You can restore it anytime.");
};
const restorePage = (page: WorkspacePage) => {
const ids = descendantIds(workspace.pages, page.id);
setWorkspace((current) => ({
...current,
activeId: page.id,
pages: current.pages.map((item) =>
ids.has(item.id)
? {
...item,
trashed: false,
parentId:
item.id === page.id &&
!pages.some((parentPage) => parentPage.id === item.parentId)
? null
: item.parentId,
}
: item,
),
}));
setDialog(null);
setNotice("Page restored");
};
const exportPage = () => {
if (!active) return;
const content = `# ${active.title || "Untitled"}\n\n${specToMarkdown(active.spec)}`;
const url = URL.createObjectURL(
new Blob([content], { type: "text/markdown;charset=utf-8" }),
);
const link = document.createElement("a");
link.href = url;
link.download = `${(active.title || "Untitled").replace(/[^\p{L}\p{N}\s_-]/gu, "").slice(0, 80)}.md`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice("Markdown exported");
};
const toggleExpanded = (id: string) =>
setCollapsed((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const pageRow = (page: WorkspacePage, depth = 0, tree = false): ReactNode => {
const childPages = tree
? pages.filter((item) => item.parentId === page.id)
: [];
return (
<div key={page.id}>
<div
className={cn(
styles.pageRow,
active?.id === page.id && styles.selected,
)}
style={{ paddingLeft: 8 + depth * 16 }}
>
{tree && childPages.length > 0 ? (
<button
type="button"
className={styles.expand}
aria-label={`${collapsed.has(page.id) ? "Expand" : "Collapse"} ${page.title}`}
aria-expanded={!collapsed.has(page.id)}
onClick={() => toggleExpanded(page.id)}
>
{collapsed.has(page.id) ? (
<ChevronRight size={13} />
) : (
<ChevronDown size={13} />
)}
</button>
) : tree ? (
<span className={styles.expand} />
) : null}
<button
type="button"
className={styles.pageLink}
aria-current={active?.id === page.id ? "page" : undefined}
onClick={() => selectPage(page.id)}
>
<span className={styles.pageEmoji} aria-hidden>
{page.icon}
</span>
<span>{page.title || "Untitled"}</span>
</button>
{tree && (
<button
type="button"
className={styles.addChild}
aria-label={`Add subpage to ${page.title || "Untitled"}`}
onClick={() => createPage("blank", page.id)}
>
<Plus size={13} />
</button>
)}
</div>
{tree &&
!collapsed.has(page.id) &&
childPages.map((child) => pageRow(child, depth + 1, true))}
</div>
);
};
const sidebar = (
<div className={styles.sidebarInner}>
<div className={styles.workspaceName}>
<span className={styles.brandMark}>m.</span>
<div>
<strong>Margin</strong>
<span>Personal workspace</span>
</div>
<BookOpen size={16} aria-hidden />
</div>
<div className={styles.sidebarActions}>
<Button
variant="ghost"
size="sm"
className={styles.navAction}
onClick={() => {
setQuery("");
setDialog("search");
}}
>
<Search size={16} />
<span>Search</span>
<kbd>⌘ K</kbd>
</Button>
<Button
variant="ghost"
size="sm"
className={styles.navAction}
onClick={() => createPage()}
>
<FilePlus2 size={16} />
<span>New page</span>
<Plus size={14} />
</Button>
</div>
<nav aria-label="Workspace pages" className={styles.pageNavigation}>
{pages.some((page) => page.favorite) && (
<section>
<h2 className={styles.navLabel}>Favorites</h2>
{pages.filter((page) => page.favorite).map((page) => pageRow(page))}
</section>
)}
<section>
<div className={styles.sectionLabel}>
<h2 className={styles.navLabel}>Workspace</h2>
<span>{pages.length}</span>
</div>
{pages
.filter(
(page) =>
!page.parentId ||
!pages.some((parentPage) => parentPage.id === page.parentId),
)
.map((page) => pageRow(page, 0, true))}
<button
type="button"
className={styles.addPage}
onClick={() => createPage()}
>
<Plus size={15} /> Add a page
</button>
</section>
</nav>
<div className={styles.sidebarBottom}>
<Button
variant="ghost"
size="sm"
className={styles.navAction}
onClick={() => setDialog("templates")}
>
<LayoutTemplate size={16} />
<span>Templates</span>
</Button>
<Button
variant="ghost"
size="sm"
className={styles.navAction}
onClick={() => setDialog("trash")}
>
<Trash2 size={16} />
<span>Trash</span>
</Button>
<div className={styles.localStatus}>
<span
className={
saveState === "error" ? styles.errorDot : styles.savedDot
}
/>
<span>
{compact
? "Interactive preview"
: saveState === "saving"
? "Saving…"
: saveState === "error"
? "Not saved · export a copy"
: "Saved on this device"}
</span>
</div>
<div className={styles.profile}>
<Avatar name="You" size="sm" />
<div>
<strong>Your workspace</strong>
<span>A little space to think.</span>
</div>
</div>
</div>
</div>
);
return (
<div
className={cn(styles.app, compact && styles.compact)}
data-notes-workspace
data-ready={ready}
>
<div className={styles.frame}>
<aside className={styles.sidebar}>{sidebar}</aside>
<div className={styles.main}>
<header className={styles.topbar}>
<Button
variant="ghost"
size="sm"
className={styles.mobileMenu}
aria-label="Open workspace navigation"
onClick={() => setDialog("navigation")}
>
<PanelLeft size={18} />
</Button>
<div className={styles.breadcrumb}>
<span className={styles.workspaceCrumb}>Workspace</span>
<ChevronRight size={13} aria-hidden />
{parent && (
<>
<button type="button" onClick={() => selectPage(parent.id)}>
{parent.title || "Untitled"}
</button>
<ChevronRight size={13} aria-hidden />
</>
)}
<span>
{active?.icon} {active?.title || "Your pages"}
</span>
</div>
{active && (
<div className={styles.topActions}>
<span className={styles.savedText} role="status">
{compact
? "Preview"
: saveState === "saving"
? "Saving…"
: saveState === "error"
? "Not saved"
: "Saved locally"}
</span>
<Button
variant="ghost"
size="sm"
aria-label="Open comments"
className={styles.iconButton}
onClick={() => setDialog("comments")}
>
<MessageSquare size={17} />
{openComments > 0 && <span>{openComments}</span>}
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
styles.iconButton,
active.favorite && styles.favorite,
)}
aria-label={
active.favorite
? "Remove from favorites"
: "Add to favorites"
}
aria-pressed={active.favorite}
onClick={() =>
patchPage(active.id, { favorite: !active.favorite })
}
>
<Star
size={17}
fill={active.favorite ? "currentColor" : "none"}
/>
</Button>
<DropdownMenu>
<DropdownTrigger
aria-label="Page actions"
className={styles.iconButton}
>
<MoreHorizontal size={20} />
</DropdownTrigger>
<DropdownContent>
<DropdownItem
onClick={() => createPage("blank", active.id)}
>
<FilePlus2 size={15} /> Add subpage
</DropdownItem>
<DropdownItem onClick={duplicatePage}>
<Copy size={15} /> Duplicate page
</DropdownItem>
<DropdownItem onClick={exportPage}>
<ArrowDownToLine size={15} /> Export Markdown
</DropdownItem>
<DropdownSeparator />
<DropdownItem
onClick={() =>
patchPage(active.id, { wide: !active.wide })
}
>
<Maximize2 size={15} />{" "}
{active.wide ? "Standard width" : "Full width"}
</DropdownItem>
<DropdownItem
onClick={() =>
patchPage(active.id, {
cover: active.cover === "none" ? "sage" : "none",
})
}
>
<Image size={15} />{" "}
{active.cover === "none" ? "Add cover" : "Remove cover"}
</DropdownItem>
<DropdownSeparator />
<DropdownItem variant="destructive" onClick={trashPage}>
<Trash2 size={15} /> Move to Trash
</DropdownItem>
</DropdownContent>
</DropdownMenu>
</div>
)}
</header>
{active ? (
<div className={styles.documentScroll}>
{active.cover !== "none" && (
<div className={styles.cover} data-cover={active.cover}>
<div className={styles.coverOrb} />
<div className={styles.coverArch} />
<span>A little clarity goes a long way.</span>
<Button
variant="secondary"
size="sm"
className={styles.coverChange}
onClick={() =>
patchPage(active.id, {
cover:
active.cover === "sage"
? "sand"
: active.cover === "sand"
? "blue"
: "sage",
})
}
>
Change cover
</Button>
</div>
)}
<article
className={cn(
styles.document,
active.wide && styles.wide,
active.cover === "none" && styles.noCover,
)}
>
<div className={styles.pageHeading}>
<DropdownMenu>
<DropdownTrigger
aria-label="Change page icon"
className={styles.documentIcon}
>
{active.icon}
</DropdownTrigger>
<DropdownContent className={styles.iconPicker}>
{ICONS.map((icon) => (
<DropdownItem
key={icon}
aria-label={`Use ${icon} icon`}
onClick={() => patchPage(active.id, { icon })}
>
{icon}
</DropdownItem>
))}
</DropdownContent>
</DropdownMenu>
<h1 className="sr-only">{active.title || "Untitled"}</h1>
<textarea
ref={titleRef}
aria-label="Page title"
rows={1}
className={styles.title}
placeholder="Untitled"
value={active.title}
onChange={(event) =>
patchPage(active.id, {
title: event.target.value.replace(/\n/g, ""),
})
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget
.closest("article")
?.querySelector<HTMLElement>("[data-opt-editable]")
?.focus();
}
}}
/>
<div className={styles.pageMeta}>
<Avatar name="You" size="xs" />
<span>Your personal workspace</span>
<span>·</span>
<span>
{active.id === "wiki"
? "A home for good ideas"
: "Only on this device"}
</span>
</div>
</div>
{children.length > 0 && (
<div className={styles.linkedPages} aria-label="Subpages">
{children.map((page) => (
<button
type="button"
key={page.id}
onClick={() => selectPage(page.id)}
>
<span aria-hidden>{page.icon}</span>
<span>{page.title || "Untitled"}</span>
</button>
))}
</div>
)}
<PageEditor
key={`${active.id}-${ready}`}
page={active}
pages={workspace.pages}
onChange={(spec) => patchPage(active.id, { spec })}
onNavigate={selectPage}
/>
<button
type="button"
className={styles.documentAdd}
onClick={() => createPage("blank", active.id)}
>
<Plus size={15} /> Add a subpage
</button>
</article>
</div>
) : (
<div className={styles.emptyWorkspace}>
<BookOpen size={40} />
<h1>A fresh start</h1>
<p>Your next idea deserves a page of its own.</p>
<Button onClick={() => createPage()}>Create a page</Button>
<Button variant="ghost" onClick={() => setDialog("trash")}>
Open Trash
</Button>
</div>
)}
</div>
</div>
<div className={styles.notice} role="status">
{notice}
</div>
<Modal
open={dialog === "navigation"}
onClose={() => setDialog(null)}
title="Your workspace"
navigation
>
{sidebar}
</Modal>
<Modal
open={dialog === "search"}
onClose={() => setDialog(null)}
title="Find a page"
description="Search page titles and everything you’ve written."
>
<Input
autoFocus
fullWidth
aria-label="Search pages"
placeholder="Search your workspace…"
value={query}
onChange={(event) => setQuery(event.target.value)}
leftIcon={<Search size={17} />}
/>
<div className={styles.searchResults}>
{results.length ? (
results.map((page) => (
<button
type="button"
key={page.id}
onClick={() => selectPage(page.id)}
>
<span className={styles.searchIcon}>{page.icon}</span>
<span>
<strong>{page.title || "Untitled"}</strong>
<small>
{pageText(page).slice(0, 100) ||
"An empty page, full of possibilities."}
</small>
</span>
</button>
))
) : (
<p className={styles.emptyMessage}>
No pages found. Try a different word.
</p>
)}
</div>
</Modal>
<Modal
open={dialog === "templates"}
onClose={() => setDialog(null)}
title="Start with a little structure"
description="Choose a starting point, then make it yours."
>
<div className={styles.templates}>
{NOTE_TEMPLATES.map((template) => (
<button
type="button"
key={template.id}
onClick={() => createPage(template.id)}
>
<span>{template.icon}</span>
<strong>{template.title}</strong>
<small>{template.description}</small>
<Plus size={16} />
</button>
))}
</div>
</Modal>
<Modal
open={dialog === "trash"}
onClose={() => setDialog(null)}
title="Trash"
description="Deleted pages stay here until you’re ready to bring them back."
>
<div className={styles.trashList}>
{workspace.pages
.filter(
(page) =>
page.trashed &&
!workspace.pages.some(
(parentPage) =>
parentPage.id === page.parentId && parentPage.trashed,
),
)
.map((page) => (
<div key={page.id}>
<span>
{page.icon} {page.title || "Untitled"}
</span>
<Button
size="sm"
variant="secondary"
aria-label={`Restore ${page.title || "Untitled"}`}
onClick={() => restorePage(page)}
>
Restore
</Button>
</div>
))}
{!workspace.pages.some((page) => page.trashed) && (
<p className={styles.emptyMessage}>
Nothing in Trash. A clean slate.
</p>
)}
</div>
</Modal>
<Modal
open={dialog === "comments"}
onClose={() => setDialog(null)}
title="Page comments"
description={`Notes for ${active?.title || "Untitled"}. Comments are saved on this device.`}
>
<div className={styles.comments}>
{active?.comments.map((item) => (
<div className={styles.comment} key={item.id}>
<div>
<Avatar name="You" size="xs" />
<strong>You</strong>
{item.resolved ? (
<Badge variant="success" size="sm">
Resolved
</Badge>
) : (
<Button
variant="ghost"
size="sm"
aria-label="Resolve comment"
onClick={() =>
patchPage(active.id, {
comments: active.comments.map((entry) =>
entry.id === item.id
? { ...entry, resolved: true }
: entry,
),
})
}
>
<Check size={15} />
</Button>
)}
</div>
<p>{item.text}</p>
</div>
))}
{active?.comments.length === 0 && (
<p className={styles.emptyMessage}>
Capture a thought or leave a note for later.
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
if (!active || !comment.trim()) return;
patchPage(active.id, {
comments: [
...active.comments,
{
id: crypto.randomUUID(),
text: comment.trim(),
resolved: false,
},
],
});
setComment("");
}}
>
<Textarea
aria-label="Write a comment"
placeholder="Leave a thought…"
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={3}
/>
<Button type="submit" size="sm" disabled={!comment.trim()}>
Add comment
</Button>
</form>
</div>
</Modal>
</div>
);
}