feat(workspace): a block editor for authoring pages in-app - #323
feat(workspace): a block editor for authoring pages in-app#323Deodat-Lawson wants to merge 2 commits into
Conversation
The product could read documents but not write them. This adds the missing half: a nested page tree the user authors in, with a block-based editor modelled on Notion's. Five new product tables. A page's body is one JSONB ProseMirror document rather than a row per block — a page then loads in a single read, and the editor owns its own transaction model instead of reconciling one against the database. `content_text` is the flattened projection search runs against. Database rows are themselves pages, as in Notion, so a row opens as a full page for free. The editor is Tiptap. Blocks Tiptap does not ship — callout, columns, synced block, template button, page link, table of contents, breadcrumb, the six media blocks and the database embed — are custom nodes here. The `/`, `@` and `:` menus are built by factories closing over a ref, because the extension array is fixed for the lifetime of an editor instance while the page tree behind those menus is not. Colour is deliberately not Notion's. Every value resolves through the app's own --ink/--panel/--accent tokens so the editor inherits the product's theme and flips with it; only three lightness knobs are editor-specific, and they exist so a palette hue stays legible as both text and background in either theme. All @tiptap/* packages move 3.22.4 to 3.29.2. The new extensions peer-depend on an exact core version, and a split would have put two copies of @tiptap/core in the bundle, breaking instanceof across the schema. Verified against a live Postgres: the full page lifecycle — create, nest, save, search, backlink, snapshot, duplicate, move, trash, restore, export — plus a test that mounts the real editor and asserts the schema actually builds, since typechecking proves the extensions compile but not that they can coexist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_89261b19-5ed5-4686-829c-5b5109a2a37c) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0ce70ff4c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const ids = await collectSubtree(userId, pageId); | ||
| if (ids.length === 0) return []; | ||
|
|
||
| await db.delete(workspaceComments).where(inArray(workspaceComments.pageId, ids)); |
There was a problem hiding this comment.
Verify root ownership before deleting dependent records
When an authenticated caller supplies another user's page ID to DELETE /api/workspace/pages/:id?permanent=true, collectSubtree still returns that caller-supplied root because it seeds the traversal unconditionally, and these dependent-record deletions are not scoped by userId. Consequently, knowing a page UUID is sufficient to delete its comments, versions, databases, and link rows even though the final page deletion is owner-scoped; verify that the root belongs to the caller before cleanup or apply ownership predicates to every delete.
Useful? React with 👍 / 👎.
| const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); | ||
| let html: string; | ||
| try { | ||
| const response = await fetch(target, { |
There was a problem hiding this comment.
Block private-network targets in bookmark fetching
An authenticated user can submit URLs such as loopback, RFC1918, link-local, or cloud-metadata addresses, and this server-side fetch follows them and returns parsed metadata to the client. This creates an SSRF primitive, including through redirects or DNS resolution; reject private/reserved destinations after resolution and revalidate every redirect before fetching.
Useful? React with 👍 / 👎.
| // A pending save must not be lost to a navigation. | ||
| useEffect(() => { | ||
| return () => { | ||
| if (saveTimer.current) window.clearTimeout(saveTimer.current); |
There was a problem hiding this comment.
Flush pending body edits before unmounting
When a user types and navigates to another page within the 800 ms debounce window, the keyed PageShell unmounts and this cleanup cancels the only scheduled persistence without sending the latest document. The edit is therefore silently lost despite the preceding comment; retain the latest document and flush it during navigation/unmount rather than merely clearing the timer.
Useful? React with 👍 / 👎.
| ? row.parentPageId | ||
| : (idMap.get(row.parentPageId ?? "") ?? row.parentPageId), | ||
| parentType: row.parentType, | ||
| databaseId: row.databaseId, |
There was a problem hiding this comment.
Clone databases when duplicating page subtrees
When the duplicated subtree contains an inline database, copied row pages retain the original databaseId, while no corresponding workspaceDatabases definition is copied and the document's databaseBlock ID is not remapped. The duplicate consequently continues loading and editing the original database, and its copied rows appear in that original database; clone database definitions and remap both block and row database IDs as part of duplication.
Useful? React with 👍 / 👎.
Only `pnpm-lock.yaml` conflicted. It is generated, so rather than hand-merging hunks the resolution takes main's copy — which carries the four new workspace packages from #320 — and re-runs `pnpm install` to fold this branch's @tiptap additions back in. That is the only way to get a lockfile that is internally consistent with every package.json on both sides. `jest.config.js` auto-merged: main added moduleNameMapper entries for the new packages, this branch restructured transformIgnorePatterns. Neither touched the other's lines. No product migration landed on main in between, so `20260810020856_notion_workspace` is still last in the journal and needs no renumbering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_34630b0c-8d5a-4bd2-a820-d0535059be97) |
The product could read documents but not write them. This adds the missing half: an internal editor, reachable at
/employer/workspaceand from Studio → Workspace → Pages.What it is
A nested page tree with a block editor modelled on Notion's — slash menu, drag handle, selection toolbar, Markdown input rules, and the block set that goes with them.
Blocks — text, H1–H3, bulleted/numbered/to-do lists, toggle lists, toggle headings, quote, callout, divider, code (language picker, copy, wrap, caption), tables, columns (2–5), inline and block equations, image (resize/align/caption), video, audio, file, bookmark with OG preview, embeds (YouTube, Figma, Maps, Gist, Loom, CodePen, Tweet, Drive, Miro), table of contents, breadcrumb, synced block, template button, child pages, link-to-page.
Inline —
@mentions (pages, people, dates),:emoji, links (external or internalpage://), ten text colours and ten block backgrounds.Page — cover with reposition and gallery, icon picker, font/small-text/full-width/lock, favourites, duplicate, move, trash, page history with restore, export to Markdown/HTML/text, word count, backlinks, comment threads with resolve.
Databases — table, board, list, gallery, calendar, timeline; 19 property types, filters, sorts, group-by. Rows are pages, so a row opens as a full page.
Workspace — ⌘K quick find over titles and body text, drag-to-reorder and drag-to-nest, trash with restore and permanent delete, six starter templates on a blank page.
Design decisions worth reviewing
A page body is one JSONB document, not a row per block. A page loads in a single read, and the editor owns its own transaction model rather than reconciling one against the database. The cost is that the server cannot query into a document — hence
content_text, the flattened projection search runs against, rebuilt on every save.Database rows are pages (
workspace_pages.database_id), as in Notion. A row opening as a full page falls out of that for free instead of needing a second content model.Colour does not copy Notion's. Every value resolves through the app's own
--ink/--panel/--accenttokens, so the editor inherits the product theme and flips with it. Only three lightness knobs are editor-specific; they exist so one palette hue stays legible as both text and background in either theme.Suggestion menus are built by factories closing over a ref. An extension array is fixed for the lifetime of an editor instance, but the page tree the
@and/menus read is not. Rebuilding the array would drop editor state.All
@tiptap/*packages move 3.22.4 → 3.29.2. The new extensions peer-depend on an exact core version; leaving them split would have put two copies of@tiptap/corein the bundle and brokeninstanceofacross the schema. The two existing Tiptap consumers —WysiwygEditorandStickyNoteEditor— typecheck and their tests pass unchanged.Migration
One forward migration,
20260810020856_notion_workspace— five new tables, additive only, nothing touched on existing ones. Applied cleanly as part of a full baseline-onward sequence against a fresh Postgres.Verification
tsc --noEmitclean; ESLint clean on all new code; production build compiles.mainbefore this branch (confirmed by stashing and re-running).Not covered
The editor has not been clicked through in a real browser — the Chrome automation extension was unresponsive during this session. Schema, commands, input rules and the data layer are all verified; pointer behaviour and CSS are not. Worth a manual pass before merge.
Also unbuilt, and flagged rather than silently skipped: publish-to-web (the
public_slugcolumn exists, no UI hangs off it), and realtime multiplayer cursors (needs a Y.js server).🤖 Generated with Claude Code
Note
Medium Risk
Additive DB migration and a new authenticated URL-fetch endpoint (SSRF surface if session checks are wrong); otherwise mostly tests and build tooling with a coordinated TipTap version bump.
Overview
Adds regression coverage for the Notion-style workspace stack: a jsdom suite that mounts
NotionEditorand checks the ProseMirror schema (nodes/marks), Markdown input rules, block commands, block IDs, and read-only UI; plus unit tests for~/server/workspace/content(docToText,extractPageLinks,docToMarkdown/docToHtml,docWordCount).Introduces the
20260810020856_notion_workspaceDrizzle migration (pages, comments, databases, page links, versions) with indexes, and wires it into the migration journal/snapshot.Ships a authenticated
GET /api/workspace/bookmarkroute that fetches a URL (timeout, http/https only) and returns OG/title metadata with graceful fallbacks when scraping fails.Jest now allowlists more ESM-only packages (including pnpm paths and TipTap transitive deps like
uuid,lowlight,devlop).package.jsonbumps all@tiptap/*packages to 3.29.2 and adds extensions used by the block editor (tables, math, drag-handle, emoji, etc.).Reviewed by Cursor Bugbot for commit 710d88b. Bugbot is set up for automated code reviews on this repo. Configure here.