From 438b3441493ba67c2198317538d3091fca719ee5 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 14:54:07 -0700 Subject: [PATCH 01/42] fix(storybook): align vite config with app workspaces - exclude elkjs from Storybook dependency optimization - externalize optional web-worker and mermaid deps during bundling - set Storybook worker output to es to match existing app behavior --- .storybook/main.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.storybook/main.ts b/.storybook/main.ts index 7ea5f3502..bf19b998d 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -26,6 +26,19 @@ const config: StorybookConfig = { ], viteFinal: async (viteConfig) => ({ ...viteConfig, + build: { + ...viteConfig.build, + rollupOptions: { + ...viteConfig.build?.rollupOptions, + external: [ + ...((Array.isArray(viteConfig.build?.rollupOptions?.external) + ? viteConfig.build?.rollupOptions?.external + : []) as string[]), + 'mermaid', + 'web-worker' + ] + } + }, css: { ...viteConfig.css, postcss: { @@ -43,6 +56,14 @@ const config: StorybookConfig = { ...workspaceAliases, ...viteConfig.resolve?.alias } + }, + optimizeDeps: { + ...viteConfig.optimizeDeps, + exclude: [...(viteConfig.optimizeDeps?.exclude ?? []), 'elkjs', 'mermaid'] + }, + worker: { + ...viteConfig.worker, + format: 'es' } }) } From 2af2f7bd2c9079d889a739835f005fd4cbd66842 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 15:12:50 -0700 Subject: [PATCH 02/42] docs(exploration): add canvas v1 infinite whiteboard deep dive - document a node-backed scene graph model for pages, databases, URLs, media, shapes, and connectors - ground the recommendation in the current canvas, editor, data, and shell architecture - capture phased implementation, performance strategy, collaboration model, and validation checklist --- ...DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md | 906 ++++++++++++++++++ 1 file changed, 906 insertions(+) create mode 100644 docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md diff --git a/docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md b/docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md new file mode 100644 index 000000000..355fa5433 --- /dev/null +++ b/docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md @@ -0,0 +1,906 @@ +# 0108 - Canvas V1 Pages, Databases, Drops, and Infinite Canvas Deep Dive + +> **Status:** Exploration +> **Date:** 2026-03-09 +> **Author:** Codex +> **Tags:** canvas, infinite-canvas, affine, blocksuite, tldraw, editor, database, performance, collaboration + +## Problem Statement ✳️ + +xNet already has the beginnings of a strong infinite canvas, but today it still feels like a generic graph sandbox instead of the primary application surface: + +- canvas nodes are mostly placeholders +- linked pages and databases open well, but do not yet feel native on the canvas +- the drop model is narrow instead of universal +- the renderer exposes chunking and level-of-detail primitives, but the active shell still renders a much simpler scene than the architecture can support + +The goal of this exploration is to define a **broad whiteboard v1** for xNet: + +- live editable page cards on canvas +- database cards with live preview and focus/open behavior +- dropped URLs with rich preview fallback +- dropped images/files as first-class canvas objects +- shapes and connectors as native spatial primitives +- a scene model that scales to large, collaborative, virtualized infinite canvases + +## Exploration Status ✅ + +- [x] Audit the current repo state +- [x] Research external canvas systems and standards +- [x] Define the recommended architecture +- [x] Propose a phased implementation roadmap +- [x] Capture performance, collaboration, and testing requirements + +## Executive Summary 🎯 + +The right move is **not** to keep extending the current generic `card/embed/shape` canvas model with more ad hoc props, and **not** to replace xNet with AFFiNE, BlockSuite, or tldraw wholesale. + +The pragmatic direction is: + +1. Turn the canvas into a **scene graph of references and primitives**. +2. Keep rich content in the systems that already own it: + - `Page` nodes for rich text + - `Database` nodes for structured collections + - `ExternalReference` nodes for URLs + - a new reusable asset node for dropped images/files +3. Treat canvas objects as spatial views over those records, with zoom-aware rendering: + - far zoom: metadata cards + - mid zoom: previews + - near zoom: live editors where appropriate +4. Use the existing chunking, spatial indexing, and LOD work in `@xnetjs/canvas` as the base for a genuinely infinite surface. + +### Recommended product cut + +- **Pages:** fully editable inline on canvas in v1 +- **Databases:** live preview + focus/open in v1 +- **URLs:** dropped as node-backed preview cards with `oEmbed -> Open Graph -> generic link` fallback +- **Images/files:** dropped as node-backed media assets rendered directly on canvas +- **Shapes/connectors:** preserved and expanded, but treated as supporting primitives around node-backed content + +This gives xNet a canvas that is useful immediately, while keeping the data model aligned with the rest of the system. + +## Current State In The Repository 🔎 + +### Observed fact: the canvas package is stronger than the active canvas UX + +The core package already contains substantial infrastructure: + +- [`packages/canvas/src/spatial/index.ts`](../../packages/canvas/src/spatial/index.ts) + - R-tree spatial indexing + - viewport transforms + - point and range queries +- [`packages/canvas/src/nodes/CanvasNodeComponent.tsx`](../../packages/canvas/src/nodes/CanvasNodeComponent.tsx) + - zoom-based LOD (`placeholder`, `minimal`, `compact`, `full`) +- [`packages/canvas/src/chunks/chunked-canvas-store.ts`](../../packages/canvas/src/chunks/chunked-canvas-store.ts) + - chunked scene storage +- [`packages/canvas/src/chunks/chunk-manager.ts`](../../packages/canvas/src/chunks/chunk-manager.ts) + - viewport-driven chunk load/evict lifecycle +- [`packages/canvas/src/store.ts`](../../packages/canvas/src/store.ts) + - collaborative Yjs-backed node/edge scene storage + +But the public node model is still generic in [`packages/canvas/src/types.ts`](../../packages/canvas/src/types.ts): + +- `card` +- `frame` +- `shape` +- `image` +- `embed` +- `group` + +That is too generic for a canvas-first application. + +### Observed fact: the active Electron canvas shell still renders linked cards, not live content + +The current app shell has already shifted toward canvas-first in: + +- [`apps/electron/src/renderer/App.tsx`](../../apps/electron/src/renderer/App.tsx) +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../apps/electron/src/renderer/components/CanvasView.tsx) + +That shell already: + +- boots into a home canvas +- creates linked page/database nodes on the canvas +- uses zoom transitions into focused page/database views +- treats the canvas as the home surface + +However, the actual node rendering still relies on lightweight shell cards: + +- linked page/database cards +- canvas notes +- double-click to open focused page/database surfaces + +This is directionally right, but still one step removed from “the canvas is the app.” + +### Observed fact: pages and databases are already separate node-backed Yjs documents + +The data model already supports the split we want: + +- [`packages/data/src/schema/schemas/page.ts`](../../packages/data/src/schema/schemas/page.ts) + - `PageSchema` + - `document: 'yjs'` +- [`packages/data/src/schema/schemas/database.ts`](../../packages/data/src/schema/schemas/database.ts) + - `DatabaseSchema` + - `document: 'yjs'` +- [`packages/data/src/schema/schemas/canvas.ts`](../../packages/data/src/schema/schemas/canvas.ts) + - `CanvasSchema` + - `document: 'yjs'` + +That means xNet already has the correct separation of concerns: + +- the canvas owns spatial placement +- page/database docs own their content state + +This is a strong foundation for inline canvas rendering without duplicating content into the canvas doc. + +### Observed fact: upload and external reference primitives already exist + +There are already reusable building blocks for dropped content: + +- [`packages/data/src/blob/blob-service.ts`](../../packages/data/src/blob/blob-service.ts) + - blob-backed file upload and retrieval +- [`packages/editor/src/hooks/useImageUpload.ts`](../../packages/editor/src/hooks/useImageUpload.ts) + - image upload plumbing +- [`packages/editor/src/hooks/useFileUpload.ts`](../../packages/editor/src/hooks/useFileUpload.ts) + - file upload plumbing +- [`packages/data/src/schema/schemas/external-reference.ts`](../../packages/data/src/schema/schemas/external-reference.ts) + - a reusable, queryable URL/reference node shape + +The missing piece is not raw infrastructure. It is the canvas ingestion and scene model. + +### Current state map + +```mermaid +flowchart TD + App["Electron shell
canvas-first home"] --> CanvasView["CanvasView"] + CanvasView --> CanvasPkg["@xnetjs/canvas"] + CanvasView --> PageView["focused PageView"] + CanvasView --> DatabaseView["focused DatabaseView"] + + CanvasPkg --> Store["CanvasStore
Yjs scene state"] + CanvasPkg --> Spatial["Spatial index
R-tree"] + CanvasPkg --> Chunking["Chunk manager
not primary runtime yet"] + CanvasPkg --> LOD["Zoom-based LOD"] + + PageView --> PageNode["Page node
Yjs doc"] + DatabaseView --> DatabaseNode["Database node
Yjs doc"] + CanvasView --> LinkedCards["linked shell cards"] + + style LinkedCards fill:#ffe4e6 + style Chunking fill:#dcfce7 + style LOD fill:#dcfce7 + style PageNode fill:#dbeafe + style DatabaseNode fill:#dbeafe +``` + +## External Research 🌍 + +### AFFiNE: the best precedent for “canvas as the main workspace” + +AFFiNE’s official July 2024 update is especially relevant: + +- **Center Peek** allows previewing and editing linked content without a full context switch. +- **Synced Docs** support multiple live pages on one edgeless whiteboard. +- Edgeless text was reworked from simple canvas text toward richer note-like editing. + +Sources: + +- [AFFiNE July 2024 Update](https://affine.pro/blog/whats-new-affine-2024-07) + +Their November 2024 update is also directly relevant: + +- linked doc and database interactions became tighter +- docs can be linked or created from databases +- database properties sync into document metadata +- a floating sidebar reinforced the “show less chrome by default” direction + +Sources: + +- [AFFiNE November 2024 Update](https://affine.pro/blog/whats-new-affine-nov-update) + +### BlockSuite: useful as a headless UX reference, not as a replacement + +BlockSuite’s official positioning is consistent with the product direction here: + +- headless editor framework +- interoperable editor components +- collaboration as a first-class concern + +That validates xNet’s decision to preserve its own architecture while borrowing interaction patterns. + +Source: + +- [BlockSuite](https://blocksuite.io/) + +### tldraw: best-in-class reference for scene objects, bindings, and external content ingestion + +tldraw’s docs are valuable because they break the whiteboard problem into concrete systems: + +- shapes are records with type-specific props and behavior +- bindings persist relationships so arrows stay attached as objects move +- external content handling unifies pasted text, dropped files, and dropped URLs +- embed shapes distinguish embeddable URLs from bookmark-style fallbacks + +Sources: + +- [tldraw Shapes](https://tldraw.dev/docs/shapes) +- [tldraw Bindings](https://tldraw.dev/sdk-features/bindings) +- [tldraw External Content Handling](https://tldraw.dev/sdk-features/external-content) +- [tldraw Embed Shape](https://tldraw.dev/sdk-features/embed-shape) + +### Yjs subdocuments: useful future optimization, not the primary v1 strategy + +Yjs subdocuments support lazy-loaded nested documents within a root doc. That is interesting for canvas scenes that want document-like containment, but Yjs’s own docs also note that providers handle subdocuments differently and that they are typically treated as separate sync units. + +That makes subdocs a future optimization or composition mechanism, not the main v1 strategy for xNet, because xNet already has working per-node Yjs document ownership. + +Source: + +- [Yjs Subdocuments](https://docs.yjs.dev/api/subdocuments) + +### TanStack Virtual: the right philosophy for heavy embedded surfaces + +TanStack Virtual’s docs reinforce the correct posture for database and embedded surface rendering: + +- headless virtualization +- dynamic measurement support +- retain full control over markup and layout + +This matters because canvas-embedded surfaces will need bespoke markup and zoom-aware measurement, not a monolithic virtualization widget. + +Sources: + +- [TanStack Virtual](https://tanstack.com/virtual) +- [TanStack Virtualizer API](https://tanstack.com/virtual/latest/docs/api/virtualizer) + +### URL preview standards: use standards-first fallback, not scraper-first special cases + +For dropped URLs, the right resolution chain is standards-first: + +- use `oEmbed` when a provider exposes an endpoint +- otherwise use Open Graph metadata +- otherwise render a generic link card + +Why: + +- `oEmbed` explicitly exists to turn a URL into structured preview or embed data +- Open Graph provides a widely adopted metadata baseline (`og:title`, `og:image`, `og:url`, etc.) + +Sources: + +- [oEmbed](https://oembed.com/) +- [The Open Graph protocol](https://ogp.me/) + +## Key Findings 🧠 + +### 1. The canvas should become a scene graph, not a generic bag of node props + +Today’s `CanvasNodeType` is too weakly typed for the product ambition. + +The canvas should instead model a small number of intentional object kinds: + +- `page` +- `database` +- `external-reference` +- `media` +- `shape` +- `note` +- `group` + +Observed fact: + +- xNet already has strong node-backed primitives outside the canvas. + +Inference: + +- the canvas should spatially compose those primitives instead of pretending they are all just variations of a generic card. + +### 2. Inline page editing is the highest-leverage v1 capability + +Pages are already: + +- collaborative Yjs docs +- rendered via `RichTextEditor` +- familiar as the main writing primitive + +That makes “click to create a page on canvas and edit it there” the cleanest first major upgrade. + +This is the direct path from “canvas shell card” to “canvas-native useful object.” + +### 3. Databases should be live preview objects first, not full inline databases on day one + +Databases are heavier: + +- view-specific +- schema-driven +- more DOM-intensive +- more interaction-dense than pages + +The right default is: + +- render a live preview node on canvas +- let users open/focus the full database surface for deep editing + +That keeps the canvas useful without blowing up complexity immediately. + +### 4. Universal drop ingestion is a product requirement, not a stretch goal + +Your additional requirement changes the canvas definition materially: + +- the canvas must be the main drop target +- users should be able to drop internal nodes, external URLs, images, and files + +That means xNet needs a single ingestion pipeline for: + +- internal app drags +- OS file drops +- pasted URLs / dropped text +- embeddable URLs + +This should be treated as a core architecture layer, not scattered event handling. + +### 5. External links should become node-backed references + +The repo already has `ExternalReferenceSchema`. That is the correct reuse point. + +The missing work is: + +- URL normalization +- preview resolution +- canvas rendering for the reference + +This is much better than storing raw URLs only inside canvas-local props. + +### 6. Media needs a reusable asset node, not only blob refs inside the scene + +Blob storage already exists, but there is no reusable top-level “media asset” node schema in the built-in data model. + +If dropped images/files matter across surfaces, they should become first-class node-backed objects. + +That would let the same asset be: + +- shown on canvas +- referenced in pages +- queried later +- shared and permissioned consistently + +### 7. Connectors should behave like bindings, not just dumb lines + +The current edge model already binds source and target node IDs, which is directionally correct. The next step is to formalize richer anchor semantics and persistence rules so connectors remain stable as the scene grows more heterogeneous. + +This is where tldraw’s bindings model is useful as a conceptual reference. + +### 8. The performance work already exists; it just is not driving the primary canvas runtime yet + +The repo already contains: + +- spatial indexing +- LOD rendering +- chunked canvas storage +- chunk load/evict orchestration + +The exploration should therefore recommend **activating and integrating** this work, not inventing a brand new performance architecture. + +## Capability Breakdown 🧭 + +```mermaid +mindmap + root((Canvas V1)) + Content objects + Page cards + Database previews + URL references + Media assets + Notes + Native primitives + Shapes + Connectors + Groups + Input system + Internal drag + URL paste/drop + File drop + Image drop + Performance + Spatial culling + Chunk loading + LOD + Virtualization + Collaboration + Canvas presence + Object selection + Inline editing awareness + Undo boundaries +``` + +## Options And Tradeoffs ⚖️ + +### Option A. Keep the current generic canvas types and add more props + +What it means: + +- keep `card/embed/shape/image/group` +- add `linkedType`, `previewKind`, `assetRef`, and more ad hoc props + +Pros: + +- smallest initial refactor +- low migration effort + +Cons: + +- scene semantics stay blurry +- harder to optimize per object kind +- every new capability becomes another convention instead of a contract + +Verdict: + +- good for prototypes +- wrong for a canvas-first product + +### Option B. Refactor to explicit scene object kinds backed by existing xNet nodes + +What it means: + +- give the canvas an intentional object model +- keep rich content in Page/Database/ExternalReference/new MediaAsset nodes +- keep canvas docs focused on placement, sizing, grouping, connectors, and view state + +Pros: + +- clear contracts +- easier rendering and performance policies +- easier long-term extensibility +- matches xNet’s node-centric architecture + +Cons: + +- requires a more aggressive refactor now +- needs a migration or rewrite of existing canvas sample data and helpers + +Verdict: + +- **recommended** + +### Option C. Replace the current canvas runtime with tldraw or BlockSuite + +What it means: + +- delegate scene model and canvas UX to an external editor stack + +Pros: + +- strong off-the-shelf UX references +- proven patterns for shapes, embeds, and bindings + +Cons: + +- deep architectural mismatch with xNet data ownership +- large adapter surface +- weaker fit for current Page/Database node model + +Verdict: + +- useful for inspiration +- not the right implementation path + +## Recommendation ✅ + +### Recommended architecture + +Use a **node-backed scene graph**: + +- the `Canvas` doc stores scene objects, connectors, grouping, transforms, z-order, and canvas-local view state +- page/database/reference/media content remains in its own source node +- canvas objects point to source nodes through stable references + +Recommended object kinds: + +| Kind | Backed by | Default canvas behavior | +| --- | --- | --- | +| `page` | `Page` node | Card at distance, live editor when near/selected | +| `database` | `Database` node | Live preview card, open/focus for deep edit | +| `external-reference` | `ExternalReference` node | Bookmark/embed preview card | +| `media` | New `MediaAsset` node | Direct render with blob-backed preview | +| `shape` | Canvas-local primitive | Native canvas primitive | +| `note` | Either lightweight page or canvas-native note | Fast local note object | +| `group` | Canvas-local primitive | Selection/layout container | + +### Recommended product behavior + +#### Pages + +- create page directly from the canvas +- mount `RichTextEditor` inline when the object is near enough or explicitly entered +- preserve page identity outside the canvas as a regular xNet page + +#### Databases + +- create and place database from the canvas +- render title, schema/view metadata, and a live preview slice +- open/focus the full database surface for dense editing + +#### URLs + +- on drop/paste, normalize URL and create or reuse an `ExternalReference` +- attempt `oEmbed` +- fall back to Open Graph +- fall back again to a generic link card + +#### Images/files + +- on drop, upload with `BlobService` +- create a first-class asset node +- place a media object on the canvas immediately + +#### Shapes/connectors + +- keep them native to the canvas +- make connectors attach to object anchors robustly +- allow shapes to frame or annotate node-backed content + +## Proposed Runtime Shape 🏗️ + +```mermaid +flowchart TD + CanvasDoc["Canvas Y.Doc
scene objects + connectors + groups"] --> SceneObjects["Scene objects"] + CanvasDoc --> Connectors["Connectors / bindings"] + CanvasDoc --> ViewState["Viewport + selection + local scene metadata"] + + SceneObjects --> PageObj["page object
sourceNodeId -> Page"] + SceneObjects --> DbObj["database object
sourceNodeId -> Database"] + SceneObjects --> RefObj["external-reference object
sourceNodeId -> ExternalReference"] + SceneObjects --> MediaObj["media object
sourceNodeId -> MediaAsset"] + SceneObjects --> ShapeObj["shape object"] + SceneObjects --> NoteObj["note object"] + SceneObjects --> GroupObj["group object"] + + PageObj --> PageDoc["Page Y.Doc"] + DbObj --> DbDoc["Database Y.Doc"] + RefObj --> RefNode["ExternalReference node"] + MediaObj --> Blob["BlobService / file refs"] + + style CanvasDoc fill:#e0f2fe + style PageDoc fill:#dcfce7 + style DbDoc fill:#dcfce7 + style Blob fill:#fef3c7 +``` + +## Implementation Roadmap 🛠️ + +### Phase 1. Replace placeholder canvas semantics + +- introduce an explicit scene object type system +- replace generic linked shell cards with typed page/database/reference/media objects +- remove or de-emphasize mock embed flows and generic placeholder assumptions + +### Phase 2. Ship page cards and universal drop ingestion + +- create page on canvas +- inline page editing with zoom/selection gating +- add drop ingestion pipeline for: + - internal node drags + - URLs + - files/images + +### Phase 3. Ship database preview objects and robust connectors + +- add database preview cards +- wire focus/open flows +- upgrade connector anchoring and persistence + +### Phase 4. Activate true infinite runtime paths + +- route the main canvas runtime through chunked scene loading +- combine chunk visibility with per-object LOD +- mount heavy objects only when visible and close enough + +### Phase 5. Collaboration and polish + +- separate canvas awareness from inline content awareness +- refine undo/redo scope boundaries +- add accessibility and regression hardening + +## Drop And Edit Lifecycle + +```mermaid +sequenceDiagram + participant U as User + participant C as Canvas Surface + participant I as Ingestion Layer + participant R as Resolver Layer + participant N as NodeStore / Source Nodes + participant B as BlobService + + U->>C: Drop URL / image / file / internal item + C->>I: Normalize external content payload + I->>R: Resolve type and metadata + + alt URL + R->>N: Create or reuse ExternalReference node + else Image or file + R->>B: Upload file + R->>N: Create MediaAsset node + else Internal page/database + R->>N: Reuse existing source node + else New page/database + R->>N: Create source node + end + + R-->>C: Return typed scene object payload + C->>C: Insert scene object at drop point + C-->>U: Render card / preview / live object +``` + +## Performance And Collaboration Guidance 🚀 + +### Performance + +The performance strategy should be two-stage: + +1. **scene-level culling** + - use chunked loading and spatial queries to decide which objects are even in memory/render scope +2. **object-level LOD** + - only mount expensive content when the object is visible and close enough + +Practical policy: + +- far zoom: metadata rectangles only +- mid zoom: preview DOM +- near zoom: live editor/media/embed DOM + +For databases specifically: + +- preview cards should render a bounded slice +- full table/board editing stays in focused mode in v1 +- use headless virtualization for any embedded table preview that can grow + +### Collaboration + +Split collaboration into scopes: + +- **canvas scope** + - selection + - movement + - viewport presence + - object insertion/removal +- **content scope** + - page editing awareness + - database editing awareness + - comments and rich editing interactions + +This avoids conflating “I am moving the page object” with “I am editing the page document.” + +### Subdocuments + +Yjs subdocuments are worth tracking as a future path for: + +- nested scene composition +- explicit lazy loading inside a root doc + +But v1 should not depend on them. xNet already has a simpler, working architecture with separate per-node Yjs docs. + +## Example Code 💡 + +### 1. Recommended scene object union + +```ts +type CanvasObjectKind = + | 'page' + | 'database' + | 'external-reference' + | 'media' + | 'shape' + | 'note' + | 'group' + +type CanvasObjectFrame = { + x: number + y: number + width: number + height: number + rotation?: number + zIndex?: number +} + +type CanvasSceneObject = + | { + id: string + kind: 'page' + frame: CanvasObjectFrame + sourceNodeId: string + sourceSchemaId: 'xnet://xnet.fyi/Page@1.0.0' + display: { mode: 'card' | 'preview' | 'live' } + } + | { + id: string + kind: 'database' + frame: CanvasObjectFrame + sourceNodeId: string + sourceSchemaId: 'xnet://xnet.fyi/Database@1.0.0' + display: { mode: 'card' | 'preview' } + } + | { + id: string + kind: 'external-reference' + frame: CanvasObjectFrame + sourceNodeId: string + sourceSchemaId: 'xnet://xnet.fyi/ExternalReference@1.0.0' + display: { mode: 'bookmark' | 'embed' } + } + | { + id: string + kind: 'media' + frame: CanvasObjectFrame + sourceNodeId: string + sourceSchemaId: 'xnet://xnet.fyi/MediaAsset@1.0.0' + assetRef: { cid: string; mimeType: string; name: string; size: number } + } + | { + id: string + kind: 'shape' + frame: CanvasObjectFrame + shape: 'rectangle' | 'ellipse' | 'diamond' | 'triangle' + style: { fill?: string; stroke?: string } + } +``` + +### 2. Recommended drop-ingestion boundary + +```ts +type ExternalCanvasDrop = + | { type: 'internal-node'; nodeId: string; schemaId: string } + | { type: 'url'; url: string } + | { type: 'files'; files: File[] } + | { type: 'text'; text: string } + +type CanvasDropResult = + | { kind: 'page'; sourceNodeId: string } + | { kind: 'database'; sourceNodeId: string } + | { kind: 'external-reference'; sourceNodeId: string } + | { kind: 'media'; sourceNodeId: string } + | { kind: 'note'; sourceNodeId: string | null } + +async function ingestCanvasDrop( + input: ExternalCanvasDrop, + deps: { + createPage: () => Promise + createExternalReference: (url: string) => Promise + createMediaAsset: (file: File) => Promise + } +): Promise { + switch (input.type) { + case 'internal-node': + return input.schemaId.includes('/Page') + ? [{ kind: 'page', sourceNodeId: input.nodeId }] + : [{ kind: 'database', sourceNodeId: input.nodeId }] + + case 'url': + return [ + { + kind: 'external-reference', + sourceNodeId: await deps.createExternalReference(input.url) + } + ] + + case 'files': + return Promise.all( + input.files.map(async (file) => ({ + kind: 'media' as const, + sourceNodeId: await deps.createMediaAsset(file) + })) + ) + + case 'text': + return input.text.startsWith('http') + ? [ + { + kind: 'external-reference', + sourceNodeId: await deps.createExternalReference(input.text) + } + ] + : [{ kind: 'note', sourceNodeId: await deps.createPage() }] + } +} +``` + +### 3. Zoom-aware render policy + +```ts +function resolveObjectRenderMode( + kind: CanvasObjectKind, + zoom: number, + selected: boolean +): 'placeholder' | 'card' | 'preview' | 'live' { + if (zoom < 0.15) return 'placeholder' + if (kind === 'page') { + if (selected && zoom >= 0.75) return 'live' + if (zoom >= 0.4) return 'preview' + return 'card' + } + if (kind === 'database') { + if (zoom >= 0.5) return 'preview' + return 'card' + } + if (kind === 'external-reference' || kind === 'media') { + return zoom >= 0.35 ? 'preview' : 'card' + } + return 'card' +} +``` + +## Implementation Checklist 📋 + +- [ ] Replace the generic canvas object contract with explicit scene object kinds. +- [ ] Add a new reusable media/asset node schema for dropped images/files. +- [ ] Route page creation on canvas to real `Page` nodes. +- [ ] Render inline live page editors behind zoom/selection gating. +- [ ] Render database preview cards backed by real `Database` nodes. +- [ ] Add connector anchor/binding metadata that survives move/resize. +- [ ] Implement a unified canvas ingestion pipeline for internal drags, URLs, text, and files. +- [ ] Reuse `ExternalReferenceSchema` for dropped URLs. +- [ ] Implement `oEmbed -> Open Graph -> generic card` URL resolution. +- [ ] Reuse `BlobService` for dropped image/file persistence. +- [ ] Promote chunked canvas storage and chunk manager into the primary renderer path. +- [ ] Gate expensive DOM mounts behind visibility and LOD. +- [ ] Split canvas collaboration state from inline content collaboration state. +- [ ] Update Storybook workbenches to reflect the new typed scene model. + +## Validation Checklist 🧪 + +- [ ] Create a page directly on canvas and edit it inline with another collaborator connected. +- [ ] Create a database directly on canvas and verify preview freshness after row/schema changes. +- [ ] Drag an existing page onto the canvas and preserve identity rather than duplicating content. +- [ ] Drag an existing database onto the canvas and verify preview/open behavior. +- [ ] Drop a URL that supports `oEmbed` and verify embed-capable preview behavior. +- [ ] Drop a URL without `oEmbed` but with Open Graph metadata and verify bookmark rendering. +- [ ] Drop a URL without preview metadata and verify generic link fallback. +- [ ] Drop an image and verify upload, preview sizing, and persistence after reload. +- [ ] Drop a non-image file and verify media/file object rendering and retrieval. +- [ ] Draw shapes and connectors around page/database/media objects. +- [ ] Move or resize connected objects and confirm connectors stay attached correctly. +- [ ] Pan across a large scene and confirm bounded DOM count and stable frame times. +- [ ] Verify chunk load/evict behavior under large-scene navigation. +- [ ] Verify far-away objects do not mount inline editors. +- [ ] Verify undo/redo boundaries between scene changes and content edits. +- [ ] Verify keyboard selection, focus management, and accessible labels for canvas objects. + +## References 🔗 + +### Repository references + +- [`packages/canvas/src/types.ts`](../../packages/canvas/src/types.ts) +- [`packages/canvas/src/renderer/Canvas.tsx`](../../packages/canvas/src/renderer/Canvas.tsx) +- [`packages/canvas/src/nodes/CanvasNodeComponent.tsx`](../../packages/canvas/src/nodes/CanvasNodeComponent.tsx) +- [`packages/canvas/src/chunks/chunked-canvas-store.ts`](../../packages/canvas/src/chunks/chunked-canvas-store.ts) +- [`packages/canvas/src/chunks/chunk-manager.ts`](../../packages/canvas/src/chunks/chunk-manager.ts) +- [`packages/canvas/src/store.ts`](../../packages/canvas/src/store.ts) +- [`apps/electron/src/renderer/App.tsx`](../../apps/electron/src/renderer/App.tsx) +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../apps/electron/src/renderer/components/CanvasView.tsx) +- [`apps/electron/src/renderer/lib/canvas-shell.ts`](../../apps/electron/src/renderer/lib/canvas-shell.ts) +- [`packages/data/src/schema/schemas/page.ts`](../../packages/data/src/schema/schemas/page.ts) +- [`packages/data/src/schema/schemas/database.ts`](../../packages/data/src/schema/schemas/database.ts) +- [`packages/data/src/schema/schemas/canvas.ts`](../../packages/data/src/schema/schemas/canvas.ts) +- [`packages/data/src/schema/schemas/external-reference.ts`](../../packages/data/src/schema/schemas/external-reference.ts) +- [`packages/data/src/blob/blob-service.ts`](../../packages/data/src/blob/blob-service.ts) +- [`packages/editor/src/hooks/useImageUpload.ts`](../../packages/editor/src/hooks/useImageUpload.ts) +- [`packages/editor/src/hooks/useFileUpload.ts`](../../packages/editor/src/hooks/useFileUpload.ts) + +### Web research + +- [AFFiNE July 2024 Update](https://affine.pro/blog/whats-new-affine-2024-07) +- [AFFiNE November 2024 Update](https://affine.pro/blog/whats-new-affine-nov-update) +- [AFFiNE home](https://affine.pro/) +- [BlockSuite](https://blocksuite.io/) +- [tldraw Shapes](https://tldraw.dev/docs/shapes) +- [tldraw Bindings](https://tldraw.dev/sdk-features/bindings) +- [tldraw External Content Handling](https://tldraw.dev/sdk-features/external-content) +- [tldraw Embed Shape](https://tldraw.dev/sdk-features/embed-shape) +- [Yjs Subdocuments](https://docs.yjs.dev/api/subdocuments) +- [TanStack Virtual](https://tanstack.com/virtual) +- [TanStack Virtualizer API](https://tanstack.com/virtual/latest/docs/api/virtualizer) +- [oEmbed](https://oembed.com/) +- [The Open Graph protocol](https://ogp.me/) + +## Recommendation In One Sentence + +xNet should evolve the canvas into a **typed, node-backed, drop-first infinite scene graph** where pages are live editable on-canvas, databases default to preview/open behavior, URLs and media become first-class objects, and chunked + zoom-aware rendering make the surface truly scalable. From 463fd03c5e55af8a822480d23a75526ebba926e0 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 15:24:43 -0700 Subject: [PATCH 03/42] docs(exploration): expand canvas guidance with affine feature pulls - add a deeper AFFiNE release-note analysis across 2024-2025 canvas and cross-surface features - classify AFFiNE ideas into adopt-now, adopt-next, defer, and reinterpret buckets for xNet - thread center-peek, aliases, backlinks, drag/drop parity, and block references into the roadmap and checklists --- ...DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md | 590 +++++++++++++++++- 1 file changed, 588 insertions(+), 2 deletions(-) diff --git a/docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md b/docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md index 355fa5433..549108e81 100644 --- a/docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md +++ b/docs/explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md @@ -27,6 +27,7 @@ The goal of this exploration is to define a **broad whiteboard v1** for xNet: - [x] Audit the current repo state - [x] Research external canvas systems and standards +- [x] Deep-dive AFFiNE’s strongest canvas and cross-surface features - [x] Define the recommended architecture - [x] Propose a phased implementation roadmap - [x] Capture performance, collaboration, and testing requirements @@ -47,7 +48,20 @@ The pragmatic direction is: - far zoom: metadata cards - mid zoom: previews - near zoom: live editors where appropriate -4. Use the existing chunking, spatial indexing, and LOD work in `@xnetjs/canvas` as the base for a genuinely infinite surface. +4. Use a **hybrid renderer**: + - WebGL / Canvas for the infinite grid, minimap, and eventually far-field placeholders and batched edge work + - DOM only for the near-field, interactive objects that actually need React and browser focus semantics +5. Use the existing chunking, spatial indexing, and LOD work in `@xnetjs/canvas` as the base for a genuinely infinite surface. +6. Reuse `@xnetjs/react` hooks and query infrastructure rather than inventing a separate canvas data path: + - `useNode` for object-backed docs + - `useDatabase` for database previews/focus flows + - `useQuery` and future query descriptors for viewport-aware loading, pagination, and eventually spatial predicates +7. Borrow AFFiNE’s best interaction ideas without copying its exact storage or mode model: + - synced docs / transclusion + - center-peek editing + - cross-surface drag and drop + - block/deep-link references + - lock / frame / alignment polish ### Recommended product cut @@ -77,6 +91,12 @@ The core package already contains substantial infrastructure: - viewport-driven chunk load/evict lifecycle - [`packages/canvas/src/store.ts`](../../packages/canvas/src/store.ts) - collaborative Yjs-backed node/edge scene storage +- [`packages/canvas/src/layers/webgl-grid.ts`](../../packages/canvas/src/layers/webgl-grid.ts) + - procedural infinite WebGL grid +- [`packages/canvas/src/components/Minimap.tsx`](../../packages/canvas/src/components/Minimap.tsx) + - canvas-rendered minimap with viewport navigation +- [`packages/canvas/src/layers/index.ts`](../../packages/canvas/src/layers/index.ts) + - explicit multi-layer renderer contract (`grid`, `edge`, `node`, `overlay`) But the public node model is still generic in [`packages/canvas/src/types.ts`](../../packages/canvas/src/types.ts): @@ -147,6 +167,43 @@ There are already reusable building blocks for dropped content: The missing piece is not raw infrastructure. It is the canvas ingestion and scene model. +### Observed fact: the repo already points toward a hybrid Canvas + DOM stack + +`@xnetjs/canvas` is no longer a pure DOM renderer in spirit: + +- the infinite background grid is already procedural WebGL with CSS fallback +- the minimap is already rendered into a `` +- the layer package already documents a rendering split between: + - background grid + - edge rendering + - DOM nodes + - overlays + +That means the exploration should not propose hybrid rendering as a speculative future. It should promote it into the primary runtime architecture. + +### Observed fact: the current spatial index is an R-tree, not a quadtree + +The active canvas spatial index is [`rbush`](https://github.com/mourner/rbush)-style R-tree logic wrapped in [`packages/canvas/src/spatial/index.ts`](../../packages/canvas/src/spatial/index.ts). + +That matters because xNet’s scene objects are: + +- arbitrarily sized rectangles +- not uniformly distributed +- often large and overlapping + +So the existing R-tree is already a strong fit for the current workload. A quadtree may still become useful for some future workloads, but it should not be treated as an automatic upgrade. + +### Observed fact: React hook infrastructure is already off-main-thread friendly + +The current `useQuery` implementation in [`packages/react/src/hooks/useQuery.ts`](../../packages/react/src/hooks/useQuery.ts): + +- runs through DataBridge +- already supports list vs single-node access +- already supports limit/offset pagination primitives +- already has a descriptor pipeline that can evolve without changing every caller + +That is the right place to eventually add canvas-friendly spatial query helpers, not a bespoke canvas-only fetch system. + ### Current state map ```mermaid @@ -160,6 +217,8 @@ flowchart TD CanvasPkg --> Spatial["Spatial index
R-tree"] CanvasPkg --> Chunking["Chunk manager
not primary runtime yet"] CanvasPkg --> LOD["Zoom-based LOD"] + CanvasPkg --> Grid["WebGL grid layer"] + CanvasPkg --> Minimap["Canvas minimap"] PageView --> PageNode["Page node
Yjs doc"] DatabaseView --> DatabaseNode["Database node
Yjs doc"] @@ -168,6 +227,8 @@ flowchart TD style LinkedCards fill:#ffe4e6 style Chunking fill:#dcfce7 style LOD fill:#dcfce7 + style Grid fill:#dcfce7 + style Minimap fill:#dcfce7 style PageNode fill:#dbeafe style DatabaseNode fill:#dbeafe ``` @@ -197,6 +258,79 @@ Sources: - [AFFiNE November 2024 Update](https://affine.pro/blog/whats-new-affine-nov-update) +AFFiNE’s later releases make the transferable feature set much clearer: + +- **September 2024** + - frame/group interactions were tightened + - block references became linkable targets + - mind map interactions improved +- **December 2024** + - linked-doc aliases became editable without breaking references + - backlinks became more visible + - lock/selection and sidebar-to-editor drag-and-drop got tighter +- **February 2025** + - split view was added + - attachment and URL block handling improved + - page blocks in the broader editor/edgeless system were further polished +- **April 2025** + - edgeless alignment and highlighter tools improved + - iframe embed blocks shipped + - attachment-aware database properties were expanded +- **June 2025** + - embed-doc-with-alias continued the cross-surface identity story + - AI workspace/search features expanded, but as an upper-layer workflow feature rather than a canvas foundation + +Sources: + +- [AFFiNE September 2024 Update](https://affine.pro/blog/whats-new-affine-sep) +- [AFFiNE December 2024 Update](https://affine.pro/blog/whats-new-affine-dec-update) +- [AFFiNE February 2025 Update](https://affine.pro/blog/whats-new-feb-update) +- [AFFiNE April 2025 Update](https://affine.pro/blog/whats-new-april-update) +- [AFFiNE June 2025 Update](https://affine.pro/blog/whats-new-june-update) + +### AFFiNE feature inventory: what xNet should actually borrow + +The important takeaway is that AFFiNE’s best canvas features are mostly **interaction and identity patterns**, not a reason to copy AFFiNE’s exact internal doc/edgeless mode model. + +| AFFiNE feature | Why it is strong | xNet stance | +| --- | --- | --- | +| Synced Docs / transclusion | Makes a whiteboard immediately useful because real documents can live on it | **Adopt now** as page objects backed by `Page` nodes | +| Center Peek | Preserves context while reading/editing linked content | **Adopt now** as a page/database peek state before full focus/open | +| Sidebar/editor/whiteboard drag-and-drop | Makes the canvas feel like the primary workspace, not a side feature | **Adopt now** through one unified ingestion pipeline | +| Attachment, URL, and embed-style blocks | Broadens the whiteboard from “notes only” to “anything spatial” | **Adopt now** for URLs and media; defer generic iframe embeds | +| Linked-doc aliases and stronger backlinks | Preserves identity while letting users rename and navigate contextually | **Adopt next** with object aliases, backlinks, and source-node metadata | +| Block references / link-to-block | Enables precise cross-surface references below the page level | **Adopt next** for block anchors, comments, and connector endpoints | +| Frame / group / lock / selection / tidy-up / align | These are the whiteboard polish features that make large boards feel manageable | **Adopt next** after page/database/url/media objects are solid | +| Split view | Good for side-by-side canvas + focused content workflows | **Adopt next** once focus/open flows are stable | +| Mind map / presentation generation | Useful, but not the foundation of a useful canvas | **Defer** until the primitive content model is solid | +| AI workspace / AI search | Valuable, but it sits above the scene architecture rather than defining it | **Defer** until object identity, search, and embedding are mature | +| One-click page/edgeless mode switching | Good in AFFiNE’s architecture, but not the right literal clone for xNet | **Reinterpret** as one node rendered across multiple surfaces | + +### AFFiNE pull-forward map + +```mermaid +mindmap + root((AFFiNE Pull-Forwards)) + Adopt now + Synced docs + Center peek + Drag and drop + URL and media objects + Adopt next + Aliases and backlinks + Block references + Frame and group polish + Lock and alignment + Split view + Defer + Mind map + Presentation workflows + AI workspace + Reinterpret + "Doc <-> edgeless mode switch" + "Same node rendered in multiple xNet surfaces" +``` + ### BlockSuite: useful as a headless UX reference, not as a replacement BlockSuite’s official positioning is consistent with the product direction here: @@ -270,6 +404,42 @@ Sources: - [oEmbed](https://oembed.com/) - [The Open Graph protocol](https://ogp.me/) +### Browser and rendering guidance: layered canvases and off-main-thread rendering are the right direction + +Browser platform guidance aligns with the hybrid renderer direction: + +- MDN’s canvas optimization guidance explicitly recommends **multiple layered canvases** when different scene elements update at different rates. +- MDN’s `OffscreenCanvas` docs validate moving suitable canvas rendering work off the main thread. +- MDN’s `requestAnimationFrame` docs remain the right baseline for frame-synced rendering. +- web.dev’s canvas performance material reinforces pre-rendering, batching, and minimizing main-thread work. + +These are directly applicable to xNet’s canvas: + +- the infinite background grid should stay off the DOM path +- the minimap should stay canvas-based +- expensive, non-interactive far-field visuals should prefer canvas/WebGL +- the DOM layer should be reserved for the subset of objects that need rich interaction, focus, and editable semantics + +Sources: + +- [MDN: Optimizing canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas) +- [MDN: OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) +- [MDN: requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) +- [web.dev: Improving HTML5 Canvas performance](https://web.dev/articles/canvas-performance) +- [web.dev: Rendering performance](https://web.dev/articles/rendering-performance) + +### React hooks and query planning: canvas should reuse xNet’s main read path + +Existing xNet explorations already point in the right direction: + +- [0037](./0037_[_]_USEQUERY_PAGINATION.md) describes how `useQuery` should grow better pagination and loading semantics. +- [0106](./0106_[_]_JOIN_QUERIES_MULTI_TYPE_AGGREGATES_QUERY_PLANNING_API.md) describes how `useQuery` can evolve into richer query descriptors without losing the ergonomic root API. + +That means canvas-oriented data access should be framed as: + +- a new query shape under the same core hook model +- not a parallel canvas-only query stack + ## Key Findings 🧠 ### 1. The canvas should become a scene graph, not a generic bag of node props @@ -380,6 +550,99 @@ The repo already contains: The exploration should therefore recommend **activating and integrating** this work, not inventing a brand new performance architecture. +### 9. The renderer should be explicitly hybrid, not “DOM with a few canvas helpers” + +The correct rendering split is: + +- **WebGL / Canvas** + - infinite grid + - minimap + - far-field placeholders + - eventually large batches of edges/connectors +- **DOM** + - inline page editors + - database previews with real controls + - accessible focusable interactive surfaces +- **overlay** + - selection affordances + - context menus + - presence chips and editing UI + +This is the highest-value performance decision because it determines whether xNet can remain fluid as scenes get large. + +### 10. `rbush` should remain the primary spatial index unless profiling proves otherwise + +It is tempting to say “use a quadtree,” but for xNet’s current scene shape that is not yet the right default decision. + +Observed fact: + +- xNet already uses an R-tree (`rbush`) for arbitrary rectangular items. + +Inference: + +- keep the R-tree as the primary scene culling structure for v1 +- only explore quadtree or Morton-tile alternatives if profiling shows a real bottleneck for: + - highly uniform dense point-like objects + - GPU-assisted picking paths + - extreme minimap or heatmap workloads + +This avoids unnecessary index churn while the larger renderer refactor is still underway. + +### 11. Minimap and background grid should be promoted into the default shell architecture + +The minimap and procedural grid are not optional polish anymore: + +- they are part of how users orient themselves on a large infinite surface +- they are also part of the performance strategy, because they keep high-coverage visuals out of the DOM + +The minimap should become: + +- a simplified canvas-rendered scene overview +- driven by a lightweight display list, not the full interactive DOM tree +- optionally moved to `OffscreenCanvas` later if redraw volume justifies it + +### 12. Canvas data access should stay inside xNet’s React hook model + +The canvas should not reach into NodeStore manually for routine content loading. + +Instead: + +- `useNode` should own page/database/media object hydration where object identity is known +- `useDatabase` should own database preview/focus data flows +- `useQuery` should own side panels, searchable insert menus, recent object pickers, and future viewport-aware spatial queries + +Longer term, it would be valuable for `useQuery` to grow spatial predicates such as: + +- `withinRect` +- `intersectsRect` +- `nearPoint` +- potentially true geo-search semantics if xNet later treats location as a first-class indexed property + +That would keep the canvas aligned with the rest of the platform instead of becoming a special-case island. + +### 13. AFFiNE’s biggest lesson is cross-surface identity, not just visual richness + +The strongest AFFiNE ideas are the ones that make one object feel native in multiple contexts: + +- one doc can appear on the whiteboard and in document flows +- aliases can change display without breaking identity +- backlinks and block references make relationships inspectable +- split/peek interactions reduce costly navigation jumps + +That maps well to xNet because xNet already has a node identity model. The missing work is to render that identity spatially and interactively on the canvas. + +### 14. xNet should copy AFFiNE’s ergonomics, but not literally copy its mode system + +AFFiNE often talks in terms of docs mode, edgeless mode, and embedding between them. + +For xNet, the cleaner interpretation is: + +- keep pages/databases/media as stable source nodes +- let canvas objects be views over those nodes +- allow peek, focus, split, alias, and backlinks across surfaces + +This preserves xNet’s existing data boundaries while still capturing the best user experience ideas. + ## Capability Breakdown 🧭 ```mermaid @@ -412,6 +675,37 @@ mindmap Undo boundaries ``` +## AFFiNE Feature Translation Matrix 🧲 + +```mermaid +flowchart LR + subgraph Borrow["Borrow directly"] + A1["Synced docs"] + A2["Center peek"] + A3["Drag/drop across surfaces"] + A4["URL + media objects"] + end + + subgraph Next["Borrow next"] + B1["Aliases + backlinks"] + B2["Block references"] + B3["Frame/group polish"] + B4["Lock + align + tidy up"] + B5["Split view"] + end + + subgraph Defer["Defer"] + C1["Mind maps"] + C2["Presentation flows"] + C3["AI workspace/search"] + end + + subgraph Reinterpret["Reinterpret for xNet"] + D1["Doc/edgeless mode switch"] + D2["One node, many surfaces"] + end +``` + ## Options And Tradeoffs ⚖️ ### Option A. Keep the current generic canvas types and add more props @@ -505,6 +799,96 @@ Recommended object kinds: | `note` | Either lightweight page or canvas-native note | Fast local note object | | `group` | Canvas-local primitive | Selection/layout container | +### Recommended rendering stack + +Use an explicit **hybrid render pipeline**: + +- **Layer 0: WebGL background** + - infinite grid + - rulers/guides later + - no DOM participation +- **Layer 1: canvas / WebGL overview layer** + - minimap + - far-field placeholders + - simplified scene snapshots +- **Layer 2: DOM interaction layer** + - page editors + - database preview cards + - media/object shells that need browser interaction +- **Layer 3: overlay layer** + - selection boxes + - handles + - context UI + - presence and comments + +This is the right place to “toggle between Canvas and DOM”: + +- not by switching the whole app between two renderers +- but by giving each layer the cheapest rendering substrate for its job + +### Recommended minimap behavior + +The minimap should: + +- stay canvas-based +- render a simplified object display list, not real object components +- derive its data from the same scene object store and viewport state as the main surface +- support click-and-drag navigation +- optionally decimate or aggregate very dense scenes instead of rendering every object one-to-one + +### Recommended index and culling strategy + +For v1: + +- keep `rbush` / R-tree as the primary culling index +- use chunking for memory management and scene load/evict +- use per-object LOD for mount/no-mount decisions + +For future exploration: + +- evaluate quadtree or Morton-coded tile indexes only if profiling shows R-tree pressure in very dense point-like scenes +- evaluate GPU picking only if DOM hit-testing and R-tree queries become a measurable bottleneck + +### Recommended React hook strategy + +Reuse xNet’s existing React contracts aggressively: + +- `useNode(PageSchema, id)` for inline page cards +- `useNode(DatabaseSchema, id)` and `useDatabase(id)` for database preview/open behavior +- `useQuery(...)` for: + - insert/search panels + - recent objects + - object pickers + - future viewport-aware result windows + +Longer term, canvas should drive query evolution rather than bypass it: + +- add stable pagination helpers from the existing `useQuery` roadmap +- add spatial predicates as query descriptors +- treat geo/spatial search as part of the query engine, not an ad hoc canvas subsystem + +### Recommended AFFiNE-inspired pulls + +Pull the following into the product plan explicitly: + +- **Adopt now** + - synced-doc style page cards + - center-peek for page/database objects + - drag-and-drop parity between sidebar, focused views, and canvas + - URL/media objects that feel first-class on the board +- **Adopt next** + - aliases and backlinks for canvas-backed references + - block-level deep links and anchors + - lock, tidy-up, align, frame, and group polish + - split canvas + focused-content workflows +- **Defer** + - mind map as a special object family + - presentation/storytelling mode + - AI workspace orchestration on top of canvas objects +- **Do not clone literally** + - xNet should not hinge on a separate “doc mode vs edgeless mode” storage split + - xNet should instead render the same node identity across page, database, canvas, and future surfaces + ### Recommended product behavior #### Pages @@ -538,6 +922,17 @@ Recommended object kinds: - make connectors attach to object anchors robustly - allow shapes to frame or annotate node-backed content +### Recommended WebGL improvements + +In addition to the existing procedural grid, xNet should consider: + +- promoting the current WebGL grid to the default background path in the primary canvas shell +- moving minimap redraws to `OffscreenCanvas` later when supported and justified by profiling +- rendering far-field object placeholders as batched quads instead of DOM shells +- migrating high-cardinality edge rendering from per-edge DOM/SVG toward batched canvas/WebGL paths +- using dirty-rect or viewport-delta redraw strategies for minimap and overview layers +- keeping one clear frame-synced render scheduler so background layers redraw only when viewport or scene display lists actually change + ## Proposed Runtime Shape 🏗️ ```mermaid @@ -565,6 +960,32 @@ flowchart TD style Blob fill:#fef3c7 ``` +## Hybrid Layer Model 🎛️ + +```mermaid +flowchart TB + subgraph Shell["Viewport Shell"] + Background["Layer 0
WebGL grid / guides"] + Overview["Layer 1
Canvas minimap + far-field overview"] + DOM["Layer 2
Virtualized DOM objects"] + Overlay["Layer 3
Selection / presence / comments"] + end + + Background --> Viewport["Viewport changes"] + Overview --> SceneList["Simplified scene display list"] + DOM --> NearField["Near-field interactive objects"] + Overlay --> UIState["Selection / editing / presence state"] + + SceneIndex["R-tree + chunks"] --> SceneList + SceneIndex --> NearField + QueryLayer["useNode / useDatabase / useQuery"] --> NearField + + style Background fill:#dbeafe + style Overview fill:#dbeafe + style DOM fill:#dcfce7 + style Overlay fill:#fef3c7 +``` + ## Implementation Roadmap 🛠️ ### Phase 1. Replace placeholder canvas semantics @@ -577,6 +998,7 @@ flowchart TD - create page on canvas - inline page editing with zoom/selection gating +- add center-peek behavior for linked page/database objects - add drop ingestion pipeline for: - internal node drags - URLs @@ -587,18 +1009,27 @@ flowchart TD - add database preview cards - wire focus/open flows - upgrade connector anchoring and persistence +- make sidebar/focused-view drag-and-drop parity a first-class flow +- add aliases and backlinks to linked canvas objects ### Phase 4. Activate true infinite runtime paths - route the main canvas runtime through chunked scene loading - combine chunk visibility with per-object LOD - mount heavy objects only when visible and close enough +- make minimap and overview display lists derive from the same chunked scene source +- keep the background grid and minimap out of the DOM path by default +- define frame budgets for 60Hz and 120Hz targets +- add lock, align, tidy-up, frame, and group polish on top of the stabilized runtime ### Phase 5. Collaboration and polish - separate canvas awareness from inline content awareness - refine undo/redo scope boundaries - add accessibility and regression hardening +- evolve `useQuery` and descriptor tooling for viewport windows, pagination, and future spatial predicates +- add split-view workflows and block/deep-link anchors +- revisit mind map, presentation, and AI layers only after the core object model is solid ## Drop And Edit Lifecycle @@ -654,6 +1085,87 @@ For databases specifically: - full table/board editing stays in focused mode in v1 - use headless virtualization for any embedded table preview that can grow +### Hybrid Canvas + DOM policy + +Use the browser intentionally: + +- render coverage-heavy visuals on Canvas/WebGL +- render only interaction-heavy surfaces in DOM +- do not pay DOM costs for full-scene affordances like the grid or minimap + +Recommended toggling rules: + +- **background grid**: always WebGL/canvas +- **minimap**: always canvas +- **far-field objects**: batched canvas/WebGL placeholders +- **near-field objects**: DOM +- **editing state**: DOM only when selected/entered/near enough +- **connectors**: + - v1 can stay mixed + - long-term should trend toward a batched non-DOM layer + +### Frame budget targets + +The canvas should be designed against both common refresh classes: + +- **60Hz target**: stay comfortably within a `16.67ms` frame +- **120Hz target**: aim for a substantially tighter working budget, roughly `8.33ms` per frame + +Practical implication: + +- keep scene culling and display-list computation cheap +- only mount a bounded number of interactive DOM nodes +- make background and overview layers cheap enough to redraw every viewport tick + +### Indexing recommendation + +For v1: + +- keep the current R-tree (`rbush`) as the main arbitrary-rectangle scene index +- layer chunking on top for memory locality and eviction +- treat quadtree as a future experiment, not a default replacement + +Why: + +- xNet’s scene objects are large, variable-sized rectangles rather than uniform points +- the existing code and tests already assume the R-tree path +- the bigger win right now is hybrid rendering, not index churn + +### React hook integration + +The canvas should leverage existing hook boundaries aggressively: + +- `useNode` for object-backed page/database/media/reference hydration +- `useDatabase` for paginated preview slices and full database focus surfaces +- `useQuery` for lists, sidebars, search, insert menus, relation pickers, and future viewport windows + +Longer term, it is reasonable for `useQuery` to grow canvas-aware query descriptors such as: + +- `withinRect` +- `intersectsRect` +- `near` +- `orderByDistance` + +And even later, if xNet adds first-class location data: + +- geo-aware index support in the query planner +- a hook-level geosearch surface that still compiles through the same descriptor pipeline + +That keeps the canvas integrated with xNet’s main platform abstractions. + +### AFFiNE-derived UX details worth pulling forward + +Once the core scene model lands, the highest-value UX refinements from AFFiNE are: + +- context-preserving peek before full open +- drag objects from navigation surfaces directly into the canvas +- editable aliases on linked cards without breaking identity +- visible backlinks from page/database objects to the canvases that reference them +- lock and selection states that reduce accidental edits on dense boards +- alignment / tidy-up actions that can operate on large selections efficiently + +These should be treated as product requirements for the second pass, not miscellaneous polish. + ### Collaboration Split collaboration into scopes: @@ -828,6 +1340,46 @@ function resolveObjectRenderMode( } ``` +### 4. Future viewport-aware query descriptor + +```ts +const { data: visiblePages } = useQuery(PageSchema, { + where: { canvasId }, + limit: 200, + // Future descriptor-level extension, not current API + withinRect: { + x: viewportRect.x, + y: viewportRect.y, + width: viewportRect.width, + height: viewportRect.height + }, + orderByDistance: { + x: viewportCenter.x, + y: viewportCenter.y + } +}) +``` + +### 5. Layer assignment policy + +```ts +type RenderLayer = 'webgl-background' | 'canvas-overview' | 'dom-interactive' | 'overlay' + +function resolveRenderLayer( + kind: CanvasObjectKind, + zoom: number, + selected: boolean +): RenderLayer { + if (kind === 'shape' && zoom < 0.2) return 'canvas-overview' + if (kind === 'page' && selected && zoom >= 0.75) return 'dom-interactive' + if (kind === 'database' && zoom >= 0.5) return 'dom-interactive' + if (kind === 'external-reference' || kind === 'media') { + return zoom >= 0.35 ? 'dom-interactive' : 'canvas-overview' + } + return 'canvas-overview' +} +``` + ## Implementation Checklist 📋 - [ ] Replace the generic canvas object contract with explicit scene object kinds. @@ -841,8 +1393,18 @@ function resolveObjectRenderMode( - [ ] Implement `oEmbed -> Open Graph -> generic card` URL resolution. - [ ] Reuse `BlobService` for dropped image/file persistence. - [ ] Promote chunked canvas storage and chunk manager into the primary renderer path. +- [ ] Promote the procedural WebGL grid into the default background renderer path. +- [ ] Promote the existing canvas minimap into the default navigation architecture. +- [ ] Define explicit Canvas/WebGL/DOM layer responsibilities and mount rules. - [ ] Gate expensive DOM mounts behind visibility and LOD. +- [ ] Keep `rbush` as the primary scene index unless profiling proves it is the bottleneck. - [ ] Split canvas collaboration state from inline content collaboration state. +- [ ] Reuse `useNode`, `useDatabase`, and `useQuery` for canvas object hydration and side-panel data access. +- [ ] Extend query descriptors later for viewport windows, pagination helpers, and future spatial predicates. +- [ ] Add center-peek state for page/database objects before full focus/open transitions. +- [ ] Add sidebar/focused-surface drag-and-drop parity into the canvas ingestion flow. +- [ ] Add aliases, backlinks, and block/deep-link anchors for linked canvas objects. +- [ ] Add lock, align, tidy-up, frame, and group actions once the base runtime is stable. - [ ] Update Storybook workbenches to reflect the new typed scene model. ## Validation Checklist 🧪 @@ -858,9 +1420,16 @@ function resolveObjectRenderMode( - [ ] Drop a non-image file and verify media/file object rendering and retrieval. - [ ] Draw shapes and connectors around page/database/media objects. - [ ] Move or resize connected objects and confirm connectors stay attached correctly. +- [ ] Verify the minimap stays canvas-based and remains responsive while panning large scenes. +- [ ] Verify the background grid remains non-DOM and scales infinitely without DOM growth. - [ ] Pan across a large scene and confirm bounded DOM count and stable frame times. - [ ] Verify chunk load/evict behavior under large-scene navigation. - [ ] Verify far-away objects do not mount inline editors. +- [ ] Verify 60Hz and 120Hz devices both remain smooth under viewport movement. +- [ ] Verify hook-driven side panels and pickers do not fall back to manual store scans. +- [ ] Verify center-peek preserves spatial context and transitions cleanly into full focus/open. +- [ ] Verify linked-object aliases do not break identity, backlinks, or connector targets. +- [ ] Verify lock/select/alignment tools behave predictably on large multi-object selections. - [ ] Verify undo/redo boundaries between scene changes and content edits. - [ ] Verify keyboard selection, focus management, and accessible labels for canvas objects. @@ -874,6 +1443,9 @@ function resolveObjectRenderMode( - [`packages/canvas/src/chunks/chunked-canvas-store.ts`](../../packages/canvas/src/chunks/chunked-canvas-store.ts) - [`packages/canvas/src/chunks/chunk-manager.ts`](../../packages/canvas/src/chunks/chunk-manager.ts) - [`packages/canvas/src/store.ts`](../../packages/canvas/src/store.ts) +- [`packages/canvas/src/components/Minimap.tsx`](../../packages/canvas/src/components/Minimap.tsx) +- [`packages/canvas/src/layers/index.ts`](../../packages/canvas/src/layers/index.ts) +- [`packages/canvas/src/layers/webgl-grid.ts`](../../packages/canvas/src/layers/webgl-grid.ts) - [`apps/electron/src/renderer/App.tsx`](../../apps/electron/src/renderer/App.tsx) - [`apps/electron/src/renderer/components/CanvasView.tsx`](../../apps/electron/src/renderer/components/CanvasView.tsx) - [`apps/electron/src/renderer/lib/canvas-shell.ts`](../../apps/electron/src/renderer/lib/canvas-shell.ts) @@ -884,11 +1456,20 @@ function resolveObjectRenderMode( - [`packages/data/src/blob/blob-service.ts`](../../packages/data/src/blob/blob-service.ts) - [`packages/editor/src/hooks/useImageUpload.ts`](../../packages/editor/src/hooks/useImageUpload.ts) - [`packages/editor/src/hooks/useFileUpload.ts`](../../packages/editor/src/hooks/useFileUpload.ts) +- [`packages/react/src/hooks/useQuery.ts`](../../packages/react/src/hooks/useQuery.ts) +- [`docs/explorations/0037_[_]_USEQUERY_PAGINATION.md`](./0037_[_]_USEQUERY_PAGINATION.md) +- [`docs/explorations/0068_[-]_CANVAS_OPTIMIZATION.md`](./0068_[-]_CANVAS_OPTIMIZATION.md) +- [`docs/explorations/0106_[_]_JOIN_QUERIES_MULTI_TYPE_AGGREGATES_QUERY_PLANNING_API.md`](./0106_[_]_JOIN_QUERIES_MULTI_TYPE_AGGREGATES_QUERY_PLANNING_API.md) ### Web research - [AFFiNE July 2024 Update](https://affine.pro/blog/whats-new-affine-2024-07) +- [AFFiNE September 2024 Update](https://affine.pro/blog/whats-new-affine-sep) - [AFFiNE November 2024 Update](https://affine.pro/blog/whats-new-affine-nov-update) +- [AFFiNE December 2024 Update](https://affine.pro/blog/whats-new-affine-dec-update) +- [AFFiNE February 2025 Update](https://affine.pro/blog/whats-new-feb-update) +- [AFFiNE April 2025 Update](https://affine.pro/blog/whats-new-april-update) +- [AFFiNE June 2025 Update](https://affine.pro/blog/whats-new-june-update) - [AFFiNE home](https://affine.pro/) - [BlockSuite](https://blocksuite.io/) - [tldraw Shapes](https://tldraw.dev/docs/shapes) @@ -900,7 +1481,12 @@ function resolveObjectRenderMode( - [TanStack Virtualizer API](https://tanstack.com/virtual/latest/docs/api/virtualizer) - [oEmbed](https://oembed.com/) - [The Open Graph protocol](https://ogp.me/) +- [MDN: Optimizing canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas) +- [MDN: OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) +- [MDN: requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) +- [web.dev: Improving HTML5 Canvas performance](https://web.dev/articles/canvas-performance) +- [web.dev: Rendering performance](https://web.dev/articles/rendering-performance) ## Recommendation In One Sentence -xNet should evolve the canvas into a **typed, node-backed, drop-first infinite scene graph** where pages are live editable on-canvas, databases default to preview/open behavior, URLs and media become first-class objects, and chunked + zoom-aware rendering make the surface truly scalable. +xNet should evolve the canvas into a **typed, node-backed, drop-first infinite scene graph** with a deliberate **hybrid WebGL/Canvas/DOM renderer**, where pages are live editable on-canvas, databases default to preview/open behavior, URLs and media become first-class objects, AFFiNE-style peek/alias/backlink ergonomics are layered on top, and chunked + zoom-aware rendering keep the surface fluid at scale. From aecfd1ba2132ca1fc55f6201f9bee30b520af007 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 15:34:50 -0700 Subject: [PATCH 04/42] docs(plan): add canvas v2 implementation roadmap - add a strategic README for Canvas V2 with phase overview, goals, budgets, and release gates - add ten sequential step files covering scene model, renderer runtime, drop ingestion, content objects, UX, collaboration, and rollout - keep the plan content-first, Electron-first, hook-driven, and explicitly non-backward-compatible --- .../01-scene-graph-and-node-primitives.md | 220 ++++++++++++ .../02-hybrid-shell-and-renderer-runtime.md | 159 +++++++++ .../03-spatial-runtime-and-query-evolution.md | 157 +++++++++ ...op-ingestion-and-source-object-creation.md | 147 ++++++++ .../05-page-cards-inline-editing-and-peek.md | 139 ++++++++ ...-database-cards-preview-focus-and-split.md | 124 +++++++ .../07-connectors-shapes-groups-and-polish.md | 145 ++++++++ .../08-navigation-shortcuts-and-minimal-ux.md | 164 +++++++++ ...oration-undo-accessibility-and-comments.md | 140 ++++++++ ...n-rollout-workbenches-and-release-gates.md | 153 +++++++++ docs/plans/plan03_9_83CanvasV2/README.md | 315 ++++++++++++++++++ 11 files changed, 1863 insertions(+) create mode 100644 docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md create mode 100644 docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md create mode 100644 docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md create mode 100644 docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md create mode 100644 docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md create mode 100644 docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md create mode 100644 docs/plans/plan03_9_83CanvasV2/07-connectors-shapes-groups-and-polish.md create mode 100644 docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md create mode 100644 docs/plans/plan03_9_83CanvasV2/09-collaboration-undo-accessibility-and-comments.md create mode 100644 docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md create mode 100644 docs/plans/plan03_9_83CanvasV2/README.md diff --git a/docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md b/docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md new file mode 100644 index 000000000..3dd990eee --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md @@ -0,0 +1,220 @@ +# 01: Scene Graph and Node Primitives + +> Replace the generic canvas object model with an explicit, node-backed scene graph and lock in the no-backward-compatibility cutover. + +**Objective:** establish the canonical Canvas V2 data model before touching the renderer. + +**Dependencies:** none + +## Scope and Dependencies + +This step defines the core contracts that every later step depends on: + +- the scene object union, +- connector/binding records, +- source-node references, +- canvas-local view metadata, +- the new media schema, +- cutover rules for old canvas docs. + +This step must complete before the hybrid renderer, drop pipeline, and inline object rendering work can proceed safely. + +## Relevant Codebase Touchpoints + +- [`packages/canvas/src/types.ts`](../../../packages/canvas/src/types.ts) +- [`packages/canvas/src/store.ts`](../../../packages/canvas/src/store.ts) +- [`packages/canvas/src/index.ts`](../../../packages/canvas/src/index.ts) +- [`packages/data/src/schema/schemas/canvas.ts`](../../../packages/data/src/schema/schemas/canvas.ts) +- [`packages/data/src/schema/schemas/page.ts`](../../../packages/data/src/schema/schemas/page.ts) +- [`packages/data/src/schema/schemas/database.ts`](../../../packages/data/src/schema/schemas/database.ts) +- [`packages/data/src/schema/schemas/external-reference.ts`](../../../packages/data/src/schema/schemas/external-reference.ts) + +## Design Overview + +```mermaid +flowchart LR + CanvasDoc["Canvas Y.Doc"] --> Objects["objects map"] + CanvasDoc --> Connectors["connectors map"] + CanvasDoc --> Groups["groups / frames / locks"] + CanvasDoc --> Meta["viewport + scene metadata"] + + Objects --> PageObj["page"] + Objects --> DbObj["database"] + Objects --> RefObj["external-reference"] + Objects --> MediaObj["media"] + Objects --> ShapeObj["shape"] + Objects --> NoteObj["note"] + Objects --> GroupObj["group"] + + PageObj --> PageNode["Page node"] + DbObj --> DbNode["Database node"] + RefObj --> RefNode["ExternalReference node"] + MediaObj --> MediaNode["MediaAsset node"] +``` + +## Proposed Design and API Changes + +### 1. Replace `CanvasNodeType` with Canvas V2 scene records + +The current `CanvasNodeType` contract should be removed from the active product path. + +Introduce a new scene-level model with: + +- `CanvasObjectKind` +- `CanvasSceneObject` +- `CanvasConnector` +- `CanvasGroupRecord` +- `CanvasDisplayState` + +Key design rule: + +- **every rich content object points at a source node** +- **the canvas doc owns only spatial/layout/view state** + +### 2. Canonical object kinds + +Use the following first-class kinds: + +- `page` +- `database` +- `external-reference` +- `media` +- `shape` +- `note` +- `group` + +Implementation note: + +- keep `note` as a scene kind for UX clarity, +- but back it with a `Page` node by default so xNet reuses Yjs-rich content rather than introducing another editing primitive. + +### 3. Add a reusable media schema + +Create a `MediaAsset`-style schema under `packages/data/src/schema/schemas/` with stable fields such as: + +- `title` +- `mimeType` +- `blobId` or equivalent blob reference +- `width` +- `height` +- `sizeBytes` +- `previewUrl` or preview metadata if needed + +This allows media to be: + +- rendered on canvas, +- referenced in pages, +- queried later, +- shared and permissioned like any other node. + +### 4. Formalize source-node references + +Each source-backed scene object should carry: + +- `sourceNodeId` +- `sourceSchemaId` +- `alias` (optional display alias) +- `display` metadata such as style variant, preview density, and collapsed state + +### 5. Formalize connectors as bindings + +Connectors should become first-class records that reference object IDs and anchors rather than purely visual lines. + +They should support: + +- object-to-object binding, +- stable anchors under resize/move, +- future block-level anchors, +- labels and style metadata, +- comment references if needed later. + +### 6. Clean cutover rule + +Backward compatibility is intentionally out of scope. + +That means: + +- do not attempt to preserve old `card/embed/image` semantics, +- do not write migration adapters into the new runtime, +- do not keep dual renderers alive. + +Recommended cutover: + +- treat Canvas V2 as the canonical runtime, +- if legacy dev data exists, recreate or reset canvases during development rather than preserving the old doc contract. + +## Suggested Type Shape + +```ts +type CanvasObjectKind = + | 'page' + | 'database' + | 'external-reference' + | 'media' + | 'shape' + | 'note' + | 'group' + +type CanvasSceneObject = { + id: string + kind: CanvasObjectKind + rect: { x: number; y: number; width: number; height: number; rotation?: number; zIndex?: number } + sourceNodeId?: string + sourceSchemaId?: string + alias?: string + locked?: boolean + display?: { + variant?: string + collapsed?: boolean + previewDensity?: 'far' | 'mid' | 'near' + } + props: Record +} + +type CanvasConnector = { + id: string + from: { objectId: string; anchor: string } + to: { objectId: string; anchor: string } + label?: string + style?: Record +} +``` + +## Implementation Notes + +- Keep the Yjs canvas doc storage simple: + - `objects` + - `connectors` + - `groups` + - `metadata` +- Avoid embedding full source-node content into scene object payloads. +- Export the new types from `@xnetjs/canvas` and move any old generic types behind internal-only compatibility if they must temporarily survive compilation. +- Keep `CanvasSchema` itself as the node schema for the overall canvas document; change the **document contract**, not the schema identity. + +## Testing and Validation Approach + +- Add focused type/store tests in `packages/canvas`. +- Add schema tests in `packages/data` for the new media schema. +- Verify source-backed object creation and connector persistence with unit tests before renderer work begins. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/canvas test +pnpm --filter @xnetjs/data test +``` + +## Risks and Edge Cases + +- A page-backed `note` needs a clear “lightweight note” display preset so it does not feel like a second-class page. +- Connector anchor identity must be stable enough to support later block/comment anchors. +- Resetting dev canvases is acceptable; silently interpreting old docs incorrectly is not. + +## Step Checklist + +- [ ] Replace the current public canvas object union with Canvas V2 scene types. +- [ ] Introduce a `MediaAsset`-style schema in `@xnetjs/data`. +- [ ] Add stable `sourceNodeId` and `sourceSchemaId` references for source-backed objects. +- [ ] Add connector/binding record types and storage. +- [ ] Define the canvas Y.Doc layout for objects, connectors, groups, and metadata. +- [ ] Remove Canvas V2 dependencies on the old generic `card/embed/image` semantics. diff --git a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md new file mode 100644 index 000000000..b82804d81 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md @@ -0,0 +1,159 @@ +# 02: Hybrid Shell and Renderer Runtime + +> Promote the existing grid/minimap/layering work into the default Canvas V2 shell so the renderer stops behaving like a DOM-first placeholder surface. + +**Objective:** establish the primary runtime shell and layer ownership model. + +**Dependencies:** [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md) + +## Scope and Dependencies + +This step replaces the current shell assumptions in the app and canvas renderer: + +- new canvas runtime host, +- explicit layer responsibilities, +- minimal-chrome shell structure, +- renderer scheduling and redraw rules. + +## Relevant Codebase Touchpoints + +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) +- [`apps/electron/src/renderer/App.tsx`](../../../apps/electron/src/renderer/App.tsx) +- [`packages/canvas/src/renderer/Canvas.tsx`](../../../packages/canvas/src/renderer/Canvas.tsx) +- [`packages/canvas/src/layers/index.ts`](../../../packages/canvas/src/layers/index.ts) +- [`packages/canvas/src/layers/webgl-grid.ts`](../../../packages/canvas/src/layers/webgl-grid.ts) +- [`packages/canvas/src/components/Minimap.tsx`](../../../packages/canvas/src/components/Minimap.tsx) +- [`packages/canvas/src/index.ts`](../../../packages/canvas/src/index.ts) + +## Runtime Shape + +```mermaid +flowchart TB + subgraph Shell["Canvas V2 shell"] + Grid["Layer 0
WebGL grid"] + Overview["Layer 1
overview canvas + minimap"] + DOM["Layer 2
virtualized DOM islands"] + Overlay["Layer 3
selection / presence / comments"] + end + + Viewport["Viewport state"] --> Grid + Viewport --> Overview + Viewport --> DOM + Viewport --> Overlay +``` + +## Proposed Design and API Changes + +### 1. Introduce a `CanvasRuntime` host + +Create a runtime-focused host component inside `@xnetjs/canvas` or the Electron renderer that owns: + +- viewport state, +- layer mounting, +- display-list inputs, +- pointer/keyboard interaction mode, +- selection/editing state, +- redraw scheduling. + +`CanvasView` should become a thin app-shell wrapper around that runtime, not a custom card renderer. + +### 2. Layer responsibilities + +Use the following explicit split: + +- **Layer 0: WebGL background** + - infinite grid + - later rulers/guides +- **Layer 1: overview canvas** + - minimap + - far-field placeholders + - aggregated scene previews +- **Layer 2: DOM interactive islands** + - page cards + - database cards + - media/link shells with focus semantics +- **Layer 3: overlay** + - selection + - handles + - presence + - contextual HUD + - comments + +### 3. Minimal shell chrome + +The shell should keep always-visible UI small: + +- canvas title chip or breadcrumb +- collapsible minimap/navigation cluster +- optional selection HUD only when something is selected +- command palette trigger via shortcut rather than a large toolbar + +Do not add a permanent inspector-first layout in the initial cut. + +### 4. Render scheduler + +The runtime should use one clear animation scheduler: + +- frame-synced viewport redraws, +- no unnecessary redraws when viewport and display lists are unchanged, +- background and overview layers redraw on viewport/display-list changes, +- DOM layer rerenders only for visible object changes. + +### 5. Keep WebGL/canvas as the default for wide-coverage visuals + +This step should formally adopt the existing grid/minimap direction: + +- the grid is not optional DOM decoration, +- the minimap is not a future enhancement, +- the renderer is hybrid by design. + +## Suggested Runtime Skeleton + +```ts +function CanvasRuntime(props: CanvasRuntimeProps): React.ReactElement { + return ( +
+ + + + +
+ ) +} +``` + +## Implementation Notes + +- Reuse `createGridLayer()` and current minimap components where possible. +- Replace ad hoc object-card rendering in the Electron shell with a runtime-fed object renderer. +- Keep the minimap collapsible, but make it part of the default shell. +- Avoid dual renderer ownership between app code and package code; define one runtime boundary. +- Preserve `CanvasHandle`-style imperative helpers where they still help app routing (`fitToRect`, `setViewportSnapshot`, `focusObject`). + +## Testing and Validation Approach + +- Add renderer-layer tests where feasible in `packages/canvas`. +- Verify that minimap, grid, DOM objects, and overlays continue to stack correctly. +- Manually verify viewport updates and overlay alignment in Electron. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/canvas test +pnpm dev:stories +``` + +## Risks and Edge Cases + +- Layer ownership confusion will create duplicate redraw logic if not settled early. +- DOM/object transforms and overlay transforms must share one coordinate conversion contract. +- The shell can easily drift back toward heavy chrome if selection tools or inspectors become permanent. + +## Step Checklist + +- [ ] Introduce the Canvas V2 runtime host and make it the primary render entry. +- [ ] Move the grid and minimap into the default shell path. +- [ ] Define explicit responsibilities for background, overview, DOM, and overlay layers. +- [ ] Replace the current custom linked-card shell rendering with runtime-fed object rendering. +- [ ] Keep persistent shell chrome minimal and contextual. +- [ ] Centralize frame scheduling and redraw ownership. diff --git a/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md b/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md new file mode 100644 index 000000000..b05c75c50 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md @@ -0,0 +1,157 @@ +# 03: Spatial Runtime and Query Evolution + +> Activate chunking, culling, display lists, and hook-driven viewport loading so Canvas V2 can scale without inventing a parallel data-access system. + +**Objective:** make the performance architecture real in the primary runtime. + +**Dependencies:** [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md), [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md) + +## Scope and Dependencies + +This step owns the scaling path: + +- chunk manager activation, +- viewport-driven display lists, +- R-tree search in the main path, +- DOM mount gating, +- query descriptor evolution for future spatial windows. + +## Relevant Codebase Touchpoints + +- [`packages/canvas/src/spatial/index.ts`](../../../packages/canvas/src/spatial/index.ts) +- [`packages/canvas/src/chunks/chunk-manager.ts`](../../../packages/canvas/src/chunks/chunk-manager.ts) +- [`packages/canvas/src/chunks/chunked-canvas-store.ts`](../../../packages/canvas/src/chunks/chunked-canvas-store.ts) +- [`packages/canvas/src/store.ts`](../../../packages/canvas/src/store.ts) +- [`packages/react/src/hooks/useQuery.ts`](../../../packages/react/src/hooks/useQuery.ts) +- [`packages/data-bridge/src/types.ts`](../../../packages/data-bridge/src/types.ts) +- [`packages/devtools/src/panels/QueryDebugger/QueryDebugger.tsx`](../../../packages/devtools/src/panels/QueryDebugger/QueryDebugger.tsx) + +## Data and Render Flow + +```mermaid +sequenceDiagram + participant V as Viewport + participant C as ChunkManager + participant S as SpatialIndex + participant D as DisplayListBuilder + participant R as Runtime + participant Q as Hooks/DataBridge + + V->>C: visible chunks + V->>S: search(expandedRect) + C-->>D: loaded scene records + S-->>D: visible object ids + Q-->>D: hydrated source-node metadata + D-->>R: overview + DOM display lists + R-->>R: mount only near-field interactive objects +``` + +## Proposed Design and API Changes + +### 1. Activate chunking in the primary path + +Chunking should stop being “future infrastructure” and become part of the main runtime contract. + +Use it for: + +- load/evict boundaries, +- memory locality, +- large-scene initialization, +- overview/minimap display-list generation. + +### 2. Keep `rbush` as the default index + +Do **not** switch to quadtree by default. + +Canvas V2 should keep the existing R-tree for: + +- arbitrary rectangle culling, +- hit testing, +- directional navigation, +- object visibility queries. + +Only revisit this after profiling proves an index bottleneck. + +### 3. Two-stage display-list building + +Build separate outputs from one visibility pass: + +- **overview display list** + - simplified objects + - aggregated shapes + - minimap rectangles +- **interactive DOM display list** + - near-field objects only + - rich editors/previews only when allowed by zoom and selection state + +### 4. Query evolution through `useQuery`, not around it + +Canvas V2 should drive the next query/runtime improvements without bypassing hooks. + +The likely future descriptor shape should support: + +- `withinRect` +- `intersectsRect` +- `nearPoint` +- `orderByDistance` + +Longer-term location support can become a hook/query concern too, but only after the basic viewport window semantics are stable. + +### 5. Telemetry and frame budgets + +Use existing devtools and telemetry surfaces to measure: + +- active queries, +- query churn, +- display-list rebuild timing, +- DOM object count, +- frame timing and dropped-frame scenarios. + +## Suggested Descriptor Extension + +```ts +type SpatialQueryDescriptor = QueryDescriptor & { + withinRect?: { x: number; y: number; width: number; height: number } + intersectsRect?: { x: number; y: number; width: number; height: number } + nearPoint?: { x: number; y: number; radius: number } + orderByDistance?: { x: number; y: number } +} +``` + +## Implementation Notes + +- Prefer one display-list builder over multiple ad hoc visibility filters. +- Let chunk membership decide what data must be loaded; let the spatial index decide what is visible now. +- Keep database/page object hydration hook-driven so the same objects can be reused in side panels, pickers, and future search surfaces. +- Treat geosearch as a future extension of the query planner, not a prerequisite for Canvas V2. + +## Testing and Validation Approach + +- Add unit coverage for visible-object queries and chunk/display-list behavior. +- Add benchmark fixtures for: + - 1,000 objects + - 5,000 objects + - 10,000 objects +- Verify that DOM object count stays bounded while panning. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/canvas test +pnpm --filter @xnetjs/react test +``` + +## Risks and Edge Cases + +- Query descriptor changes can spread widely if introduced too early without tight scope. +- Chunk and index invalidation need one authoritative update path to avoid stale display lists. +- Overview display lists should not accidentally require full rich-object hydration. + +## Step Checklist + +- [ ] Promote chunk loading/eviction into the primary Canvas V2 runtime. +- [ ] Route visible-object selection through the existing R-tree search path. +- [ ] Build overview and interactive display lists from a shared visibility pipeline. +- [ ] Gate DOM mounts behind visibility, zoom, and interaction state. +- [ ] Extend `useQuery`/`QueryDescriptor` only where Canvas V2 genuinely benefits. +- [ ] Add telemetry and benchmark coverage for display-list and query behavior. diff --git a/docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md b/docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md new file mode 100644 index 000000000..e9fb179f2 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md @@ -0,0 +1,147 @@ +# 04: Drop Ingestion and Source Object Creation + +> Make the canvas a universal spatial drop target by normalizing internal drags, URLs, files, and plain text into one creation pipeline. + +**Objective:** unify creation flows so the canvas can accept “almost anything” without bespoke handlers scattered across the app. + +**Dependencies:** [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md), [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md), [03-spatial-runtime-and-query-evolution.md](./03-spatial-runtime-and-query-evolution.md) + +## Scope and Dependencies + +This step covers: + +- internal app drags, +- URL/text drops, +- file/image drops, +- command-driven object creation at pointer/viewport center, +- source-node creation and placement. + +## Relevant Codebase Touchpoints + +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) +- [`apps/electron/src/renderer/App.tsx`](../../../apps/electron/src/renderer/App.tsx) +- [`packages/data/src/blob/blob-service.ts`](../../../packages/data/src/blob/blob-service.ts) +- [`packages/data/src/schema/schemas/external-reference.ts`](../../../packages/data/src/schema/schemas/external-reference.ts) +- [`packages/editor/src/hooks/useImageUpload.ts`](../../../packages/editor/src/hooks/useImageUpload.ts) +- [`packages/editor/src/hooks/useFileUpload.ts`](../../../packages/editor/src/hooks/useFileUpload.ts) + +## Creation Pipeline + +```mermaid +flowchart TD + Input["Drop / paste / command"] --> Normalize["Normalize payload"] + Normalize --> Internal["Internal xNet object drag"] + Normalize --> Url["URL or URL-like text"] + Normalize --> File["Image / file"] + Normalize --> Plain["Plain text"] + + Internal --> Reuse["Reuse existing source node"] + Url --> ExternalRef["Create or reuse ExternalReference"] + File --> Media["Upload blob + create MediaAsset"] + Plain --> Page["Create page/note when appropriate"] + + Reuse --> Place["Place scene object"] + ExternalRef --> Place + Media --> Place + Page --> Place +``` + +## Proposed Design and API Changes + +### 1. Introduce a unified ingestion boundary + +Create a single canvas ingestion service that accepts: + +- drag payloads from sidebar/search/recent lists, +- OS file drops, +- pasted URLs, +- dropped text, +- command-palette and shortcut-driven “create object” actions. + +### 2. Internal drags should preserve identity + +Dragging a page or database from another app surface should: + +- reuse the existing source node, +- not duplicate content, +- create only a new scene object reference. + +### 3. URLs should reuse `ExternalReferenceSchema` + +For dropped URLs: + +- normalize the URL, +- derive provider/kind when possible, +- create or reuse an `ExternalReference` node, +- resolve preview metadata via: + - `oEmbed` + - Open Graph + - generic fallback card + +### 4. Files and images should create `MediaAsset` nodes + +Use: + +- `BlobService` for persistence, +- upload hooks where useful, +- source-node creation before scene placement. + +### 5. Text drops should be intentional + +For plain text: + +- if it parses as a URL, treat it as URL drop, +- otherwise create a page-backed note only when the user explicitly chooses or the command context makes that obvious. + +## Suggested Ingestion Contract + +```ts +type CanvasIngressPayload = + | { kind: 'internal-node'; nodeId: string; schemaId: string } + | { kind: 'url'; url: string } + | { kind: 'file'; file: File } + | { kind: 'text'; text: string } + | { kind: 'create'; objectKind: 'page' | 'database' | 'shape' } + +async function ingestCanvasPayload( + payload: CanvasIngressPayload, + at: { x: number; y: number } +): Promise<{ objectId: string; sourceNodeId?: string }> { + // normalize -> create/reuse source -> create scene object +} +``` + +## Implementation Notes + +- Keep placement logic reusable so command creation and drag/drop use the same path. +- Normalize internal drag payloads early so app surfaces don’t each invent their own drop contract. +- Make URL/media placement optimistic: place an object shell quickly, then resolve preview metadata asynchronously. +- Size media objects from natural dimensions when known; use conservative defaults while loading. + +## Testing and Validation Approach + +- Unit test normalization and dispatch logic. +- Validate file/media persistence and URL preview fallback ordering. +- Manually verify drag/drop from sidebar and other document lists inside Electron. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/data test +pnpm --filter @xnetjs/react test +``` + +## Risks and Edge Cases + +- Provider preview lookup must degrade cleanly when metadata is unavailable. +- Large file drops need upload-progress and failure handling without blocking the scene. +- Dedupe rules for reused URLs/media should be explicit; otherwise repeated drops may create noisy duplicates. + +## Step Checklist + +- [ ] Build a unified canvas ingestion boundary for drags, drops, paste, and create commands. +- [ ] Reuse source-node identity for internal page/database drags. +- [ ] Create or reuse `ExternalReference` nodes for URL drops. +- [ ] Upload files/images through `BlobService` and create `MediaAsset` nodes. +- [ ] Share one placement pipeline between command creation and drop-based creation. +- [ ] Add optimistic placement with async preview/media resolution. diff --git a/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md b/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md new file mode 100644 index 000000000..397996cc6 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md @@ -0,0 +1,139 @@ +# 05: Page Cards, Inline Editing, and Peek + +> Make pages the first truly native canvas object by letting users create, edit, peek, and reopen the same page identity without leaving the canvas unnecessarily. + +**Objective:** ship the highest-leverage Canvas V2 object flow first. + +**Dependencies:** [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md), [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md), [03-spatial-runtime-and-query-evolution.md](./03-spatial-runtime-and-query-evolution.md), [04-drop-ingestion-and-source-object-creation.md](./04-drop-ingestion-and-source-object-creation.md) + +## Scope and Dependencies + +This step covers: + +- page creation on canvas, +- page-backed note creation, +- zoom-aware page rendering, +- inline editing, +- center-peek behavior, +- transition to full focused page view when needed. + +## Relevant Codebase Touchpoints + +- [`apps/electron/src/renderer/components/PageView.tsx`](../../../apps/electron/src/renderer/components/PageView.tsx) +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) +- [`packages/editor/src/components/RichTextEditor.tsx`](../../../packages/editor/src/components/RichTextEditor.tsx) +- [`packages/react/src/hooks/useNode.ts`](../../../packages/react/src/hooks/useNode.ts) +- [`packages/data/src/schema/schemas/page.ts`](../../../packages/data/src/schema/schemas/page.ts) + +## Page Object Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Far + Far --> Compact: zoom in + Compact --> Preview: selected or near-field + Preview --> Editing: enter edit + Preview --> Peek: quick open + Peek --> Editing: continue editing + Peek --> Focused: full open + Editing --> Preview: escape or blur + Focused --> Preview: return to canvas +``` + +## Proposed Design and API Changes + +### 1. Page object render modes + +Pages should have explicit render modes: + +- **far** + - lightweight placeholder/card +- **compact** + - title + light metadata +- **preview** + - richer excerpt / first content lines +- **editing** + - mounted `RichTextEditor` +- **peek** + - enlarged in-context editing/read surface without full route transition + +### 2. Inline editing rules + +Inline editing should mount only when: + +- the object is visible, +- zoom is high enough, +- the user explicitly entered edit mode or the object is selected and the intent is clear. + +Do not auto-mount editors on broad visibility alone. + +### 3. Center-peek behavior + +Canvas V2 should adopt AFFiNE’s strongest “stay in context” idea: + +- peek a page in-place first, +- allow the user to continue editing there, +- escalate to full focus/open only when they want the dedicated surface. + +### 4. Page-backed notes + +`note` objects should be page-backed presets: + +- smaller default size, +- different display variant, +- same source-node/Yjs model, +- same editing capabilities. + +That keeps xNet’s primitive set smaller and stronger. + +## Suggested Render Policy + +```ts +function resolvePageRenderMode(input: { + zoom: number + selected: boolean + editing: boolean + peeking: boolean +}): 'far' | 'compact' | 'preview' | 'editing' | 'peek' { + if (input.editing) return 'editing' + if (input.peeking) return 'peek' + if (input.zoom < 0.2) return 'far' + if (input.zoom < 0.55) return 'compact' + return input.selected ? 'preview' : 'compact' +} +``` + +## Implementation Notes + +- Reuse `PageSchema` and the existing editor stack; do not create a second canvas text engine for page content. +- Keep peek transitions subtle and fast; they should feel like spatial expansion, not a modal detour. +- Preserve the ability to open the existing focused `PageView` for full-page workflows. +- Ensure page cards can render meaningfully even before their rich editor is hydrated. + +## Testing and Validation Approach + +- Unit test render-mode decisions. +- Verify editor mount/unmount thresholds manually in Electron. +- Validate that creating a page on the canvas immediately creates a real source node. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/editor test +pnpm --filter @xnetjs/react test +``` + +## Risks and Edge Cases + +- Editor selection/focus can easily fight with canvas drag/select behavior if intent handoff is unclear. +- Peek state and full focus/open state must not create duplicate editing sessions. +- Large pasted content should not lock the canvas runtime while the editor hydrates. + +## Step Checklist + +- [ ] Add page creation directly on the canvas using real `Page` nodes. +- [ ] Implement page-backed note objects as a display preset, not a separate editor primitive. +- [ ] Define page render modes and mount gates for preview/editing. +- [ ] Add center-peek behavior before full route transitions. +- [ ] Preserve full `PageView` open/focus behavior for deep work. +- [ ] Validate smooth transitions between preview, peek, editing, and full focus. diff --git a/docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md b/docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md new file mode 100644 index 000000000..c8db02096 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md @@ -0,0 +1,124 @@ +# 06: Database Cards, Preview, Focus, and Split + +> Keep databases first-class on the canvas without trying to cram the full database application into every scene object. + +**Objective:** make databases useful on canvas while respecting their higher interaction and DOM cost. + +**Dependencies:** [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md), [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md), [03-spatial-runtime-and-query-evolution.md](./03-spatial-runtime-and-query-evolution.md), [04-drop-ingestion-and-source-object-creation.md](./04-drop-ingestion-and-source-object-creation.md), [05-page-cards-inline-editing-and-peek.md](./05-page-cards-inline-editing-and-peek.md) + +## Scope and Dependencies + +This step covers: + +- database object creation, +- live preview cards, +- bounded preview virtualization, +- focus/open workflows, +- split-view workflows for canvas + focused database work. + +## Relevant Codebase Touchpoints + +- [`apps/electron/src/renderer/components/DatabaseView.tsx`](../../../apps/electron/src/renderer/components/DatabaseView.tsx) +- [`packages/react/src/hooks/useDatabase.ts`](../../../packages/react/src/hooks/useDatabase.ts) +- [`packages/react/src/hooks/useDatabaseDoc.ts`](../../../packages/react/src/hooks/useDatabaseDoc.ts) +- [`packages/views/src/table/VirtualizedTableView.tsx`](../../../packages/views/src/table/VirtualizedTableView.tsx) +- [`packages/data/src/schema/schemas/database.ts`](../../../packages/data/src/schema/schemas/database.ts) + +## Interaction Flow + +```mermaid +flowchart LR + Create["Create / drop database"] --> Preview["Canvas preview card"] + Preview --> Peek["Peek metadata + row slice"] + Peek --> Focus["Open focused database"] + Focus --> Split["Optional split view with canvas"] + Split --> Preview["Return to canvas context"] +``` + +## Proposed Design and API Changes + +### 1. Live preview by default + +Database objects should render a lightweight, live preview containing: + +- title, +- view metadata, +- a bounded row slice, +- key schema hints, +- an affordance to open or split. + +### 2. Use existing database hooks, not ad hoc store reads + +Canvas V2 should reuse: + +- `useDatabaseDoc()` for columns/views, +- `useDatabase()` for rows and pagination, +- any existing table virtualization primitives where they fit. + +### 3. Bounded preview policy + +Database preview cards should be intentionally constrained: + +- preview a small row count, +- avoid full-board/full-table control density, +- virtualize preview rows when the card can grow, +- do not attempt full inline database editing in the first Canvas V2 cut. + +### 4. Focus and split workflows + +Users should be able to: + +- open the full database surface, +- return to the canvas with preserved viewport, +- optionally keep the canvas visible in a split layout for cross-reference work. + +### 5. Aliases and backlinks + +Once the base preview flow is stable, database cards should support: + +- display aliasing without identity changes, +- visible backlinks from the database to canvases that reference it. + +## Suggested Preview Model + +```ts +const preview = useDatabase(databaseId, { + pageSize: 20 +}) + +const rows = preview.rows.slice(0, 8) +``` + +## Implementation Notes + +- Keep preview density consistent with the content-first goal; avoid miniature full apps inside cards. +- Preview updates should react to row/schema changes without forcing full card rerenders when not visible. +- Split view should be a shell concern, not a database card concern. + +## Testing and Validation Approach + +- Validate preview freshness after row edits, sort/view changes, and schema changes. +- Validate that preview DOM stays bounded. +- Verify focused and split workflows manually in Electron. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/react test +pnpm --filter @xnetjs/views test +``` + +## Risks and Edge Cases + +- Preview cards can become too heavy if they try to support in-place dense editing too early. +- Split view can easily add too much persistent chrome if it becomes the default instead of an opt-in mode. +- Querying too many rows for previews will erase most of the benefit of a bounded card model. + +## Step Checklist + +- [ ] Add database object creation and placement using real `Database` nodes. +- [ ] Render bounded live preview cards backed by `useDatabase` and `useDatabaseDoc`. +- [ ] Limit preview density and virtualize heavy preview surfaces when needed. +- [ ] Preserve focused full-database workflows. +- [ ] Add optional split canvas + database workflows after focus/open is stable. +- [ ] Add alias/backlink support once preview/focus behavior is solid. diff --git a/docs/plans/plan03_9_83CanvasV2/07-connectors-shapes-groups-and-polish.md b/docs/plans/plan03_9_83CanvasV2/07-connectors-shapes-groups-and-polish.md new file mode 100644 index 000000000..ecafc915f --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/07-connectors-shapes-groups-and-polish.md @@ -0,0 +1,145 @@ +# 07: Connectors, Shapes, Groups, and Polish + +> Turn Canvas V2 from a loose collection of cards into a real whiteboard by adding durable bindings, framing/grouping tools, and dense-board management affordances. + +**Objective:** ship the native whiteboard primitives that support content, rather than overshadowing it. + +**Dependencies:** [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md), [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md), [03-spatial-runtime-and-query-evolution.md](./03-spatial-runtime-and-query-evolution.md), [05-page-cards-inline-editing-and-peek.md](./05-page-cards-inline-editing-and-peek.md), [06-database-cards-preview-focus-and-split.md](./06-database-cards-preview-focus-and-split.md) + +## Scope and Dependencies + +This step covers: + +- connector bindings, +- shape objects, +- frame/group behavior, +- lock/select behavior, +- tidy-up, align, and distribute operations, +- alias/backlink polish, +- future-ready block/deep-link anchors. + +## Relevant Codebase Touchpoints + +- [`packages/canvas/src/store.ts`](../../../packages/canvas/src/store.ts) +- [`packages/canvas/src/edges/CanvasEdgeComponent.tsx`](../../../packages/canvas/src/edges/CanvasEdgeComponent.tsx) +- [`packages/canvas/src/nodes/shape-node`](../../../packages/canvas/src/nodes/shape-node) +- [`packages/canvas/src/presence/selection-lock.ts`](../../../packages/canvas/src/presence/selection-lock.ts) +- [`packages/canvas/src/comments/CommentPin.tsx`](../../../packages/canvas/src/comments/CommentPin.tsx) + +## Object Relationship Model + +```mermaid +flowchart TD + Shape["Shape object"] --> Frame["Frame / group context"] + Page["Page object"] --> Connector["Connector binding"] + Database["Database object"] --> Connector + Media["Media object"] --> Connector + Connector --> Anchor["Stable object anchor"] + Comment["Comment anchor"] --> Anchor + Alias["Alias / backlink metadata"] --> Page + Alias --> Database +``` + +## Proposed Design and API Changes + +### 1. Connectors as bindings, not just lines + +Connector records should: + +- bind to object IDs, +- remember anchor metadata, +- survive move/resize, +- support object/object endpoints consistently, +- leave room for future block-level anchors. + +### 2. Shapes and frames remain canvas-native + +Shapes should stay canvas-native primitives: + +- rectangle +- ellipse +- diamond +- line/arrow +- frame + +They should not require backing source nodes. + +### 3. Groups and frames should help organize content + +Frames/groups should support: + +- drag-in feedback, +- group move/resize, +- selection scoping, +- optional title labels, +- future template/layout behavior if needed. + +### 4. Locking, alignment, and tidy-up + +Borrow the best AFFiNE-style board-management affordances: + +- lock/unlock, +- align left/right/top/bottom, +- distribute horizontally/vertically, +- tidy-up for large selections, +- send forward/backward. + +### 5. Alias, backlink, and deep-link groundwork + +This step should formalize: + +- object aliases, +- backlink recording from source node to referencing canvas, +- stable anchor IDs that comments and future block links can reuse. + +## Suggested Connector Shape + +```ts +type CanvasAnchorRef = { + objectId: string + anchor: 'top' | 'right' | 'bottom' | 'left' | 'center' | string + blockAnchorId?: string +} + +type CanvasConnector = { + id: string + from: CanvasAnchorRef + to: CanvasAnchorRef + label?: string + style?: { curved?: boolean; stroke?: string; width?: number } +} +``` + +## Implementation Notes + +- Keep group/frame UI lightweight and contextual. +- Use locks to protect both content objects and shapes. +- Alignment/tidy-up should work on scene selections without requiring the user to open a side inspector. +- Backlink metadata can start simple and become richer later. + +## Testing and Validation Approach + +- Unit test anchor persistence on move/resize. +- Validate alignment/tidy-up determinism on selected sets. +- Verify lock behavior and selection visuals manually in Electron. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/canvas test +``` + +## Risks and Edge Cases + +- Object-anchor stability can break silently if resize/rotation math is not centralized. +- Tidy-up can feel destructive if it ignores user grouping or manual layout intent. +- Backlinks must not create noisy write churn for every trivial canvas move. + +## Step Checklist + +- [ ] Convert connectors to durable binding records with stable anchors. +- [ ] Promote shape/frame/group tools into the Canvas V2 object model. +- [ ] Add lock/unlock behavior for dense-board safety. +- [ ] Add align/distribute/tidy-up operations for multi-object selections. +- [ ] Add alias and backlink support for source-backed objects. +- [ ] Reserve stable anchor IDs for comments and future block/deep-link support. diff --git a/docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md b/docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md new file mode 100644 index 000000000..213634880 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md @@ -0,0 +1,164 @@ +# 08: Navigation, Shortcuts, and Minimal UX + +> Keep the interface visually quiet while making the canvas fast to drive through direct manipulation, command search, and a disciplined shortcut system. + +**Objective:** make Canvas V2 feel powerful without adding heavy persistent chrome. + +**Dependencies:** [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md), [05-page-cards-inline-editing-and-peek.md](./05-page-cards-inline-editing-and-peek.md), [06-database-cards-preview-focus-and-split.md](./06-database-cards-preview-focus-and-split.md), [07-connectors-shapes-groups-and-polish.md](./07-connectors-shapes-groups-and-polish.md) + +## Scope and Dependencies + +This step defines: + +- the minimal shell UX, +- navigation affordances, +- command palette integration, +- hotkeys and shortcuts, +- discoverability and shortcut help, +- selection HUD behavior. + +## Relevant Codebase Touchpoints + +- [`packages/canvas/src/hooks/useCanvasKeyboard.ts`](../../../packages/canvas/src/hooks/useCanvasKeyboard.ts) +- [`packages/canvas/src/accessibility/keyboard-navigation.ts`](../../../packages/canvas/src/accessibility/keyboard-navigation.ts) +- [`packages/ui/src/composed/CommandPalette.tsx`](../../../packages/ui/src/composed/CommandPalette.tsx) +- [`apps/electron/src/renderer/App.tsx`](../../../apps/electron/src/renderer/App.tsx) +- [`packages/canvas/src/components/Minimap.tsx`](../../../packages/canvas/src/components/Minimap.tsx) + +## UX Model + +```mermaid +flowchart LR + Canvas["Canvas surface"] --> Direct["Direct manipulation"] + Canvas --> Palette["Command palette"] + Canvas --> Shortcuts["Keyboard shortcuts"] + Canvas --> HUD["Contextual selection HUD"] + Canvas --> Minimap["Collapsible minimap/nav cluster"] +``` + +## Proposed Design and API Changes + +### 1. Minimal persistent chrome + +By default show only: + +- title/breadcrumb, +- minimap/navigation cluster, +- contextual selection HUD when relevant, +- transient insertion affordances. + +Do not add a permanent left tool rail and permanent right inspector as the default Canvas V2 posture. + +### 2. Command-first object creation + +Integrate the existing command palette into the canvas shell so users can: + +- create page +- create database +- insert shape +- open search/recent objects +- lock/group/align/tidy +- open focused view + +without hunting through visible UI. + +### 3. Hotkey set + +Recommended default hotkeys: + +| Action | Shortcut | +| --- | --- | +| Open command palette | `Cmd/Ctrl+Shift+P` | +| Shortcut help | `?` | +| Zoom in | `Cmd/Ctrl+=` | +| Zoom out | `Cmd/Ctrl+-` | +| Reset view | `Cmd/Ctrl+0` | +| Fit content | `Cmd/Ctrl+1` | +| Pan with keyboard | Arrow keys | +| Pan temporarily | `Space` + drag | +| Create page | `P` | +| Create database | `D` | +| Rectangle | `R` | +| Ellipse | `O` | +| Connector tool | `L` | +| Frame/group tool | `F` | +| Enter peek/edit | `Enter` | +| Open focused surface | `Cmd/Ctrl+Enter` | +| Exit edit/peek | `Escape` | +| Group | `G` | +| Ungroup | `Shift+G` | +| Lock/unlock | `Cmd/Ctrl+Shift+L` | +| Nudge | Arrow keys with selection | +| Large nudge | `Shift` + arrow keys | +| Bring forward/back | `]` / `[` | + +Implementation note: + +- single-key shortcuts should be active only when the user is not typing in an editor/input. + +### 4. Selection HUD + +When a selection exists, show a compact contextual HUD with only the actions that matter: + +- edit / peek / open, +- group / ungroup, +- lock, +- align / distribute / tidy, +- duplicate / delete, +- comment. + +### 5. Discoverability + +Use: + +- shortcut hints in menus/HUD, +- a shortcut help overlay, +- command palette search keywords, +- lightweight onboarding in Storybook/workbenches instead of permanent tutorial chrome. + +## Suggested Command Registry Shape + +```ts +type CanvasCommand = { + id: string + name: string + shortcut?: string + when?: () => boolean + execute: () => void | Promise +} +``` + +## Implementation Notes + +- Extend `useCanvasKeyboard` rather than replacing it. +- Reuse the current `CommandPalette` component and feed it a canvas-scoped command registry. +- Maintain one “are we typing?” guard for single-key shortcuts so canvas shortcuts never steal editor input. +- Keep minimap controls and zoom controls compact and collapsible. + +## Testing and Validation Approach + +- Add unit coverage for shortcut dispatch and “typing guard” behavior. +- Verify that shortcuts remain discoverable via palette/HUD/help overlay. +- Manually verify keyboard-first flows in Electron. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/canvas test +pnpm --filter @xnetjs/ui test +``` + +## Risks and Edge Cases + +- Single-key shortcuts are valuable but dangerous; typing guards must be reliable. +- Too many shortcuts can undermine the “minimal UX” goal if they are not grouped coherently. +- A contextual HUD can become a floating toolbar monster if it accumulates too many actions. + +## Step Checklist + +- [ ] Keep persistent canvas chrome minimal and contextual. +- [ ] Integrate a canvas-scoped command registry into the existing command palette. +- [ ] Expand `useCanvasKeyboard` into a full Canvas V2 shortcut layer. +- [ ] Add a discoverable shortcut help overlay. +- [ ] Implement the selection HUD with only context-relevant actions. +- [ ] Ensure keyboard-first creation/edit/navigation flows work without interfering with editor typing. diff --git a/docs/plans/plan03_9_83CanvasV2/09-collaboration-undo-accessibility-and-comments.md b/docs/plans/plan03_9_83CanvasV2/09-collaboration-undo-accessibility-and-comments.md new file mode 100644 index 000000000..2cdd37531 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/09-collaboration-undo-accessibility-and-comments.md @@ -0,0 +1,140 @@ +# 09: Collaboration, Undo, Accessibility, and Comments + +> Make Canvas V2 collaborative and keyboard-accessible without collapsing canvas actions and content editing into one noisy coordination model. + +**Objective:** harden the interaction model so real collaborative work remains stable and understandable. + +**Dependencies:** all prior steps + +## Scope and Dependencies + +This step covers: + +- presence scopes, +- selection/move/edit coordination, +- undo boundaries, +- comment anchoring, +- keyboard accessibility, +- screen-reader and focus behavior. + +## Relevant Codebase Touchpoints + +- [`packages/canvas/src/presence/canvas-presence.ts`](../../../packages/canvas/src/presence/canvas-presence.ts) +- [`packages/canvas/src/presence/selection-lock.ts`](../../../packages/canvas/src/presence/selection-lock.ts) +- [`packages/canvas/src/accessibility/keyboard-navigation.ts`](../../../packages/canvas/src/accessibility/keyboard-navigation.ts) +- [`packages/react/src/hooks/useUndo.ts`](../../../packages/react/src/hooks/useUndo.ts) +- [`packages/react/src/hooks/useUndoScope.ts`](../../../packages/react/src/hooks/useUndoScope.ts) +- [`packages/canvas/src/comments/CommentPin.tsx`](../../../packages/canvas/src/comments/CommentPin.tsx) +- [`packages/editor/src/components/EditorComments.tsx`](../../../packages/editor/src/components/EditorComments.tsx) + +## Collaboration Scopes + +```mermaid +flowchart TD + CanvasScope["Canvas scope"] --> Move["move / resize / select / viewport"] + ContentScope["Content scope"] --> Edit["page/database editing"] + CommentScope["Comment scope"] --> Anchor["object / block anchors"] + UndoScope["Undo scope"] --> SceneUndo["scene operations"] + UndoScope --> ContentUndo["content operations"] +``` + +## Proposed Design and API Changes + +### 1. Separate canvas awareness from content awareness + +Canvas presence should describe: + +- viewport, +- cursor, +- selection, +- drag/move state. + +Content presence should continue to live with the page/database doc editors themselves. + +### 2. Explicit intent transitions + +The runtime should make it clear when a user is: + +- moving an object, +- resizing an object, +- editing object content, +- commenting on an object, +- peeking at an object. + +This is especially important for collaboration and lock behavior. + +### 3. Use scoped undo boundaries + +Reuse: + +- `useUndo()` for single-node domains when appropriate, +- `useUndoScope()` for composite domains such as: + - canvas object placement + canvas connector changes, + - database preview object + associated scene metadata, + - object transform operations over multi-selection. + +### 4. Comment anchors + +Comment anchoring should support: + +- object-level anchors for every scene object, +- future block-level anchors for page/database content, +- graceful orphan handling when anchors disappear. + +### 5. Keyboard accessibility + +Canvas V2 should extend current keyboard navigation support to cover: + +- focus traversal across visible objects, +- activate/edit/open actions, +- selection state announcements, +- locked-object and grouped-object semantics, +- visible focus indicators. + +## Suggested Undo Model + +```ts +const sceneUndo = useUndoScope([canvasId], { + localDID: did ?? null +}) + +const contentUndo = useUndo(pageNodeId, { + localDID: did!, + options: { mergeInterval: 750 } +}) +``` + +## Implementation Notes + +- Keep selection locks lightweight; they should prevent accidental collision, not create hard multi-user deadlocks. +- Use comments as anchors over scene objects rather than trying to make the canvas itself a text-threading engine. +- Ensure assistive announcements respect the minimal-chrome philosophy; information should be available without adding visible clutter. + +## Testing and Validation Approach + +- Add unit coverage for keyboard navigation and anchor orphaning. +- Validate undo boundaries manually across scene moves and inline edits. +- Verify multi-user behavior with two Electron instances. + +Suggested commands: + +```bash +pnpm --filter @xnetjs/canvas test +pnpm --filter @xnetjs/react test +cd apps/electron && pnpm dev:both +``` + +## Risks and Edge Cases + +- Ambiguous transitions between editing and moving are the fastest path to collaborative frustration. +- Undo can become confusing if scene and content operations merge into one stack unintentionally. +- Comment anchors must fail visibly and recoverably when underlying source anchors disappear. + +## Step Checklist + +- [ ] Separate canvas presence state from source-content presence state. +- [ ] Define explicit transitions between move/resize/peek/edit/comment intents. +- [ ] Use `useUndo` and `useUndoScope` to enforce clear undo boundaries. +- [ ] Add object-level comment anchors and graceful orphan handling. +- [ ] Extend keyboard accessibility to the full Canvas V2 object model. +- [ ] Validate collaborative behavior with multi-user Electron testing. diff --git a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md new file mode 100644 index 000000000..80b850e4a --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md @@ -0,0 +1,153 @@ +# 10: Electron Rollout, Workbenches, and Release Gates + +> Prove Canvas V2 in Electron first with realistic workbench scenes, measurable budgets, and a clean release gate before any parity work expands the surface area. + +**Objective:** convert the implementation sequence into a disciplined shipping process. + +**Dependencies:** all prior steps + +## Scope and Dependencies + +This step covers: + +- Electron-first rollout, +- Storybook/dev workbench coverage, +- benchmark scenes, +- validation commands, +- web follow-up only after core gates pass. + +## Relevant Codebase Touchpoints + +- [`apps/electron/src/renderer/App.tsx`](../../../apps/electron/src/renderer/App.tsx) +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) +- [`.storybook/main.ts`](../../../.storybook/main.ts) +- [`packages/devtools/src/panels/QueryDebugger/QueryDebugger.tsx`](../../../packages/devtools/src/panels/QueryDebugger/QueryDebugger.tsx) +- [`packages/canvas/src/performance/frame-monitor.ts`](../../../packages/canvas/src/performance/frame-monitor.ts) +- [`packages/canvas/src/performance/memory-profile.ts`](../../../packages/canvas/src/performance/memory-profile.ts) + +## Rollout Sequence + +```mermaid +flowchart LR + Build["Implement Canvas V2 in Electron"] --> Storybook["Create realistic workbench stories"] + Storybook --> Perf["Run large-scene performance passes"] + Perf --> UX["Manual keyboard/mouse/editing validation"] + UX --> Gate{"Release gates pass?"} + Gate -->|Yes| Web["Start web adoption"] + Gate -->|No| Fix["Fix runtime / UX regressions"] + Fix --> Storybook +``` + +## Proposed Release Strategy + +### 1. Electron first + +Canvas V2 should replace the active Electron canvas path first. + +Why: + +- it is already the primary product shell, +- it provides the richest local testing surface, +- it avoids diluting effort across two UI platforms while the runtime is still settling. + +### 2. Storybook/dev workbench coverage + +Build dedicated Canvas V2 stories that cover: + +- empty canvas, +- page-heavy canvas, +- database-preview canvas, +- mixed URL/media canvas, +- shape/connector dense canvas, +- very large synthetic scene for performance testing. + +### 3. Performance harnesses + +Create repeatable scenes for: + +- 1,000 objects, +- 5,000 objects, +- 10,000 objects, +- mixed object densities, +- high connector counts, +- heavy preview cards. + +Track: + +- frame timing, +- DOM count, +- minimap responsiveness, +- query counts/churn, +- memory profile. + +### 4. Manual validation gates + +Because this is a rich interactive surface, manual Electron validation is required for: + +- drag/drop, +- pointer + keyboard interplay, +- inline editing, +- peek/focus transitions, +- multi-user presence, +- comment anchoring, +- split workflows. + +### 5. Web parity later + +Only after Electron passes the gates should the team adapt the new shell/runtime to the web app. + +## Suggested Validation Matrix + +| Area | Gate | +| --- | --- | +| Scene model | only Canvas V2 object kinds are used in the active path | +| Performance | large-scene pan/zoom stays smooth and DOM remains bounded | +| Content | page editing and database preview/open flows are stable | +| UX | hotkeys, command palette, minimap, and selection HUD are coherent | +| Collaboration | presence and undo boundaries behave predictably | +| Accessibility | keyboard traversal and focus treatment are complete | + +## Implementation Notes + +- Update Storybook workbenches as the scene model changes; do not leave stories wired to the old generic object contract. +- Use frame and query devtools during manual validation rather than relying on subjective feel alone. +- Record benchmark scenes and release gates in the plan/PR notes so performance claims remain traceable. + +## Testing and Validation Approach + +Suggested commands: + +```bash +pnpm --filter @xnetjs/canvas test +pnpm --filter @xnetjs/react test +pnpm --filter @xnetjs/data test +pnpm dev:stories +cd apps/electron && pnpm dev +cd apps/electron && pnpm dev:both +``` + +Manual validation should include: + +- create page/database from shortcut and command palette, +- drop URL/image/file/internal object, +- pan and zoom across dense scenes, +- edit page inline and in focused mode, +- preview/open database and return, +- use minimap and fit/reset shortcuts, +- test lock/group/align/tidy on dense selections, +- verify collaboration and undo boundaries. + +## Risks and Edge Cases + +- Storybook scenes can drift from the real app if the runtime shell is forked across package and app code. +- Performance gates will be misleading if the synthetic scenes are too simple. +- Web parity should not begin until the Electron shell stops changing at the architecture level. + +## Step Checklist + +- [ ] Replace the active Electron canvas path with Canvas V2. +- [ ] Build realistic Storybook/workbench scenes for every major object family and density class. +- [ ] Add repeatable performance scenes and capture frame/DOM/query metrics. +- [ ] Run manual Electron validation for editing, navigation, collaboration, and shortcuts. +- [ ] Document and enforce release gates before web rollout. +- [ ] Start web adaptation only after Electron passes the full gate set. diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md new file mode 100644 index 000000000..788607f64 --- /dev/null +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -0,0 +1,315 @@ +# xNet Implementation Plan - Step 03.983: Canvas V2 + +> Replace the current placeholder canvas with a content-first, node-backed infinite workspace that reuses xNet nodes, Yjs, React hooks, and the existing canvas runtime primitives while keeping the UI minimal and the frame budget stable. + +## Title and Short Summary + +This plan turns [exploration 0108](../../explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md) into an execution sequence for a **clean Canvas V2 cutover**. + +The plan assumes these constraints from the outset: + +- **No backward compatibility requirement** for the current generic canvas object model. +- **Electron-first** implementation and validation, with web adoption only after the runtime is stable. +- **Reuse internal primitives aggressively**: + - xNet nodes remain the identity layer + - Yjs remains the document/collaboration layer + - `@xnetjs/react` hooks remain the primary read/write surface + - `@xnetjs/canvas` spatial, chunking, minimap, grid, and accessibility work remain the runtime foundation + +The product target is a **content-first infinite workspace**: + +- pages can be created and edited directly on the canvas, +- databases can be created and previewed directly on the canvas, +- URLs and media can be dropped onto the canvas as first-class objects, +- shapes/connectors/groups remain native whiteboard primitives, +- the renderer keeps DOM count bounded and maintains smooth pan/zoom/edit behavior at scale. + +## Problem Statement + +The current canvas already has serious infrastructure, but the active UX is still one layer too generic. + +As of **March 9, 2026**: + +- the active Electron shell boots into a home canvas and already treats the canvas as the primary landing surface, +- the `@xnetjs/canvas` package already contains a WebGL grid, minimap, chunking, spatial indexing, accessibility helpers, and navigation hooks, +- pages and databases already exist as node-backed Yjs documents, +- `useQuery`, `useNode`, and `useDatabase` already provide the right abstraction boundaries for data access, +- but the actual canvas objects are still mostly placeholder cards and generic embeds. + +That gap matters because it produces the worst of both worlds: + +- the canvas has the complexity of a whiteboard surface, +- but not the utility of a real workspace. + +Canvas V2 should fix that by making the canvas useful for real work immediately while preserving strict performance and UX discipline: + +- **content-first** rather than chrome-first, +- **minimal** rather than inspector-heavy, +- **bounded DOM** rather than “render everything,” +- **typed scene objects** rather than generic card props, +- **xNet-native primitives** rather than a parallel canvas-specific data stack. + +## Current State in the Repository + +### What is already strong + +- [`packages/canvas/src/store.ts`](../../../packages/canvas/src/store.ts) already stores canvas state in a Yjs document with node/edge maps and an `rbush`-backed spatial index. +- [`packages/canvas/src/spatial/index.ts`](../../../packages/canvas/src/spatial/index.ts) already provides viewport math, spatial search, and hit-testing against arbitrary rectangles. +- [`packages/canvas/src/chunks/chunked-canvas-store.ts`](../../../packages/canvas/src/chunks/chunked-canvas-store.ts) and [`packages/canvas/src/chunks/chunk-manager.ts`](../../../packages/canvas/src/chunks/chunk-manager.ts) already provide chunk-oriented infinite-canvas machinery. +- [`packages/canvas/src/layers/webgl-grid.ts`](../../../packages/canvas/src/layers/webgl-grid.ts) already provides a procedural WebGL grid. +- [`packages/canvas/src/components/Minimap.tsx`](../../../packages/canvas/src/components/Minimap.tsx) already provides a canvas-rendered minimap. +- [`packages/canvas/src/accessibility/keyboard-navigation.ts`](../../../packages/canvas/src/accessibility/keyboard-navigation.ts) and [`packages/canvas/src/hooks/useCanvasKeyboard.ts`](../../../packages/canvas/src/hooks/useCanvasKeyboard.ts) already provide meaningful keyboard/navigation primitives. +- [`packages/react/src/hooks/useQuery.ts`](../../../packages/react/src/hooks/useQuery.ts) already runs through the DataBridge and gives the plan a stable place to evolve viewport-aware query descriptors later. +- [`packages/react/src/hooks/useDatabase.ts`](../../../packages/react/src/hooks/useDatabase.ts) and [`packages/react/src/hooks/useDatabaseDoc.ts`](../../../packages/react/src/hooks/useDatabaseDoc.ts) already provide the right database preview/focus primitives. +- [`apps/electron/src/renderer/App.tsx`](../../../apps/electron/src/renderer/App.tsx) already uses `CommandPalette` and a canvas-first shell state. + +### Where the current product is still split + +| Area | Observed repository state | Why Canvas V2 must change it | +| --- | --- | --- | +| Scene model | [`packages/canvas/src/types.ts`](../../../packages/canvas/src/types.ts) still defines `card`, `frame`, `shape`, `image`, `embed`, `group` | too generic for content-first rendering and object-specific policies | +| App shell | [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) still renders linked cards rather than live page/database surfaces | the canvas still feels like a launcher, not a workspace | +| Drop model | URLs/files/internal drags are not unified into one ingestion pipeline | the canvas cannot yet behave like a universal spatial drop target | +| Renderer contract | the package exposes layer/chunk/LOD primitives, but the active app path does not fully route through them | performance work exists but is not yet the primary runtime | +| Data access | the app already has `useNode`, `useDatabase`, and `useQuery`, but the canvas does not yet drive their next evolution | the canvas should strengthen the hook/runtime platform, not bypass it | + +### Product-quality observations to carry forward + +- Keep the current **R-tree (`rbush`)** as the primary spatial index for v2. Do not switch to quadtree by default. +- Keep the current **node/Yjs split**: canvas docs own spatial placement; source nodes own rich content. +- Keep the current **WebGL grid + canvas minimap** direction and promote it into the default runtime path. +- Reuse the current **command palette, keyboard hooks, undo hooks, and comments infrastructure** instead of inventing parallel stacks. + +## Goals and Non-Goals + +### Goals + +- Replace the generic canvas object contract with a typed scene graph centered on: + - `page` + - `database` + - `external-reference` + - `media` + - `shape` + - `note` + - `group` +- Make **page cards** the first fully editable inline canvas object. +- Make **database cards** live preview objects with focus/open and split-view workflows. +- Make **URL and media drops** first-class creation paths. +- Promote the **hybrid renderer** into the default runtime: + - WebGL/canvas for coverage-heavy layers + - DOM for interaction-heavy objects +- Keep the UX **minimal and content-first**: + - very little persistent chrome + - contextual controls + - strong keyboard/command affordances +- Reuse xNet’s main primitives: + - nodes + - Yjs + - DataBridge-backed hooks + - existing canvas runtime utilities +- Define measurable frame, memory, DOM-count, and interaction budgets for 60Hz and 120Hz targets. + +### Non-Goals + +- Do not preserve the old `CanvasNodeType` semantics for compatibility. +- Do not maintain the current placeholder-card model in parallel with Canvas V2. +- Do not ship AFFiNE-style mind map, presentation, or AI workspace features in the first Canvas V2 cut. +- Do not embed full database editing into the base canvas object in the initial release. +- Do not optimize for mobile parity in the first pass. +- Do not create a canvas-only data-access stack that bypasses `useNode`, `useDatabase`, or `useQuery`. + +## Product Principles + +### 1. Content first + +Persistent chrome should be minimal. The default view should emphasize the canvas content, not sidebars, inspectors, or toolbars. + +### 2. Reuse internal primitives + +Canvas V2 should strengthen xNet’s platform rather than fragment it: + +- object identity stays node-backed, +- rich content stays Yjs-backed, +- list/search/picker loading stays hook-driven, +- comments/undo/presence build on current infrastructure. + +### 3. Cheap layers, expensive objects only when needed + +- Infinite background visuals belong on WebGL/canvas. +- The minimap belongs on canvas. +- Far-field object representations should be batched and simplified. +- Rich DOM editors should mount only when visible and warranted by zoom/selection/edit state. + +### 4. Minimal UX, strong keyboard + +The canvas should not require a large fixed toolbar to be usable. Most actions should be reachable through: + +- direct manipulation, +- a compact contextual selection HUD, +- command palette, +- a discoverable shortcut set. + +### 5. Performance is part of the product + +Canvas V2 is not complete unless panning, zooming, selecting, editing, dropping, and opening content all feel smooth under realistic workloads. + +## Architecture and Phase Overview + +```mermaid +flowchart TD + subgraph Source["Source nodes and docs"] + Page["Page node + Y.Doc"] + Database["Database node + Y.Doc"] + ExternalRef["ExternalReference node"] + Media["MediaAsset node"] + end + + subgraph CanvasDoc["Canvas Y.Doc"] + Objects["Scene objects"] + Connectors["Connector records"] + Groups["Groups / frames / locks"] + ViewState["Viewport + local scene metadata"] + end + + subgraph Runtime["Canvas runtime"] + Grid["Layer 0: WebGL grid"] + Overview["Layer 1: Minimap + overview canvas"] + DOM["Layer 2: Virtualized DOM islands"] + Overlay["Layer 3: Selection / presence / comments"] + Spatial["rbush + chunk manager"] + Query["useNode / useDatabase / useQuery"] + end + + Source --> Query + CanvasDoc --> Spatial + Spatial --> Grid + Spatial --> Overview + Spatial --> DOM + Query --> DOM + Query --> Overlay +``` + +### Phase logic + +1. **Cut over the scene model** so the runtime has correct semantics. +2. **Promote the hybrid renderer** so the shell uses the right layers by default. +3. **Activate chunking, culling, and query-aware display lists** before adding more content types. +4. **Ship universal object creation flows** for drops and commands. +5. **Make pages and databases feel native on the canvas.** +6. **Add the whiteboard-management primitives** that keep dense boards usable. +7. **Harden shortcuts, accessibility, collaboration, undo, and validation** before broad rollout. + +### Performance targets + +| Area | Target | +| --- | --- | +| Pan/zoom on 60Hz displays | stay comfortably within `16.67ms` frame budget | +| Pan/zoom on 120Hz displays | aim for `8.33ms` effective frame budget | +| Interactive DOM count | keep near-field DOM objects bounded and measurable | +| Far-field rendering | no inline editor mounts outside the near-field window | +| Large-scene navigation | chunk load/evict and display-list recompute must not cause visible hitching | +| Database preview | bounded preview rows/cells with virtualization for heavy previews | + +## Step Index + +| Step | File | Outcome | +| --- | --- | --- | +| 1 | [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md) | typed Canvas V2 scene model, source-node contracts, and clean cutover rules | +| 2 | [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md) | primary hybrid runtime shell with explicit layer responsibilities | +| 3 | [03-spatial-runtime-and-query-evolution.md](./03-spatial-runtime-and-query-evolution.md) | chunked/cullable display lists plus hook/query evolution for viewport-driven loading | +| 4 | [04-drop-ingestion-and-source-object-creation.md](./04-drop-ingestion-and-source-object-creation.md) | universal drop pipeline and node-backed URL/media creation flows | +| 5 | [05-page-cards-inline-editing-and-peek.md](./05-page-cards-inline-editing-and-peek.md) | live page cards with inline editing, LOD, and center-peek flows | +| 6 | [06-database-cards-preview-focus-and-split.md](./06-database-cards-preview-focus-and-split.md) | database preview cards with focus/open/split workflows | +| 7 | [07-connectors-shapes-groups-and-polish.md](./07-connectors-shapes-groups-and-polish.md) | bindings, shapes, groups, locks, tidy-up, aliases, and backlink polish | +| 8 | [08-navigation-shortcuts-and-minimal-ux.md](./08-navigation-shortcuts-and-minimal-ux.md) | minimal chrome, hotkeys, command palette integration, and navigation UX | +| 9 | [09-collaboration-undo-accessibility-and-comments.md](./09-collaboration-undo-accessibility-and-comments.md) | collaboration scopes, undo boundaries, accessibility, and comment anchoring | +| 10 | [10-electron-rollout-workbenches-and-release-gates.md](./10-electron-rollout-workbenches-and-release-gates.md) | Electron-first rollout, Storybook workbenches, benchmarks, and release gates | + +## Risks and Open Questions + +- **Scene model reset:** because backward compatibility is out of scope, existing canvas docs may need explicit invalidation or one-time replacement behavior during development. +- **Editor mount churn:** inline rich editors can still become the main performance risk if zoom/selection gates are too permissive. +- **Database preview scope:** a preview card that tries to do too much will recreate the current “heavy embed” problem. +- **Query evolution timing:** extending `QueryDescriptor` for viewport/spatial predicates is valuable, but it should not block the initial Canvas V2 runtime if a local display-list cache is enough for the first cut. +- **Split-view complexity:** split canvas + focused surface workflows are useful, but they must not add permanent chrome or route complexity too early. +- **Comment/block anchors:** page-level comments are already real; block-level canvas anchors need a precise ownership model before they are productized. +- **Web parity:** the web app should follow only after the Electron shell proves the runtime and UX choices. + +## Implementation Checklist + +- [ ] Replace the current generic canvas object contract with a typed scene graph. +- [ ] Add a reusable `MediaAsset`-style node schema for dropped images/files. +- [ ] Replace the current linked-card shell with a hybrid renderer shell. +- [ ] Route the main runtime through chunking, culling, and explicit layer display lists. +- [ ] Add universal drop ingestion for internal drags, URLs, text, images, and files. +- [ ] Ship live page cards with inline editing and peek behavior. +- [ ] Ship database preview cards with focus/open and split workflows. +- [ ] Add connector bindings, shapes, groups, locks, align/tidy operations, and aliases/backlinks. +- [ ] Define and implement the full shortcut/command surface for Canvas V2. +- [ ] Integrate collaboration, undo, comments, and accessibility into the new scene/runtime model. +- [ ] Build Storybook and manual validation scenes that reflect the real Canvas V2 object model. +- [ ] Validate Electron-first performance and interaction budgets before web rollout. + +## Validation Checklist + +- [ ] Creating a page on the canvas immediately creates a real `Page` node and supports inline editing. +- [ ] Creating a database on the canvas immediately creates a real `Database` node and shows a bounded live preview. +- [ ] Dropping a URL creates or reuses an `ExternalReference` node and renders the correct fallback chain. +- [ ] Dropping an image or file creates a reusable media node and preserves it after reload. +- [ ] Pan/zoom remains smooth on large scenes with chunk load/evict active. +- [ ] The background grid and minimap remain outside the main DOM path. +- [ ] Far-field objects do not mount rich editors or oversized DOM subtrees. +- [ ] Shortcut-driven flows let a keyboard user create, select, group, lock, align, peek, edit, and open objects without excessive pointer travel. +- [ ] Undo/redo behaves correctly across canvas-object moves and inline content edits. +- [ ] Collaboration keeps canvas movement/selection awareness separate from page/database editing awareness. +- [ ] Comment anchors survive object moves/resizes and degrade cleanly when underlying anchors disappear. +- [ ] The Electron shell is stable before any parity work starts in the web app. + +## References + +### Local references + +- [Exploration 0108](../../explorations/0108_[_]_CANVAS_V1_PAGES_DATABASES_AND_INFINITE_CANVAS_DEEP_DIVE.md) +- [Canvas optimization exploration 0068](../../explorations/0068_[-]_CANVAS_OPTIMIZATION.md) +- [Canvas optimizations plan 03.9.4](../plan03_9_4CanvasOptimizations/README.md) +- [`apps/electron/src/renderer/App.tsx`](../../../apps/electron/src/renderer/App.tsx) +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) +- [`packages/canvas/src/types.ts`](../../../packages/canvas/src/types.ts) +- [`packages/canvas/src/store.ts`](../../../packages/canvas/src/store.ts) +- [`packages/canvas/src/spatial/index.ts`](../../../packages/canvas/src/spatial/index.ts) +- [`packages/canvas/src/chunks/chunk-manager.ts`](../../../packages/canvas/src/chunks/chunk-manager.ts) +- [`packages/canvas/src/chunks/chunked-canvas-store.ts`](../../../packages/canvas/src/chunks/chunked-canvas-store.ts) +- [`packages/canvas/src/layers/index.ts`](../../../packages/canvas/src/layers/index.ts) +- [`packages/canvas/src/layers/webgl-grid.ts`](../../../packages/canvas/src/layers/webgl-grid.ts) +- [`packages/canvas/src/components/Minimap.tsx`](../../../packages/canvas/src/components/Minimap.tsx) +- [`packages/canvas/src/accessibility/keyboard-navigation.ts`](../../../packages/canvas/src/accessibility/keyboard-navigation.ts) +- [`packages/canvas/src/hooks/useCanvasKeyboard.ts`](../../../packages/canvas/src/hooks/useCanvasKeyboard.ts) +- [`packages/data/src/schema/schemas/canvas.ts`](../../../packages/data/src/schema/schemas/canvas.ts) +- [`packages/data/src/schema/schemas/page.ts`](../../../packages/data/src/schema/schemas/page.ts) +- [`packages/data/src/schema/schemas/database.ts`](../../../packages/data/src/schema/schemas/database.ts) +- [`packages/data/src/schema/schemas/external-reference.ts`](../../../packages/data/src/schema/schemas/external-reference.ts) +- [`packages/data/src/blob/blob-service.ts`](../../../packages/data/src/blob/blob-service.ts) +- [`packages/react/src/hooks/useQuery.ts`](../../../packages/react/src/hooks/useQuery.ts) +- [`packages/react/src/hooks/useDatabase.ts`](../../../packages/react/src/hooks/useDatabase.ts) +- [`packages/react/src/hooks/useUndo.ts`](../../../packages/react/src/hooks/useUndo.ts) +- [`packages/react/src/hooks/useUndoScope.ts`](../../../packages/react/src/hooks/useUndoScope.ts) +- [`packages/ui/src/composed/CommandPalette.tsx`](../../../packages/ui/src/composed/CommandPalette.tsx) + +### External references + +- [AFFiNE July 2024 Update](https://affine.pro/blog/whats-new-affine-2024-07) +- [AFFiNE September 2024 Update](https://affine.pro/blog/whats-new-affine-sep) +- [AFFiNE November 2024 Update](https://affine.pro/blog/whats-new-affine-nov-update) +- [AFFiNE December 2024 Update](https://affine.pro/blog/whats-new-affine-dec-update) +- [AFFiNE February 2025 Update](https://affine.pro/blog/whats-new-feb-update) +- [AFFiNE April 2025 Update](https://affine.pro/blog/whats-new-april-update) +- [AFFiNE June 2025 Update](https://affine.pro/blog/whats-new-june-update) +- [tldraw external content handling](https://tldraw.dev/sdk-features/external-content) +- [tldraw bindings](https://tldraw.dev/sdk-features/bindings) +- [TanStack Virtualizer API](https://tanstack.com/virtual/latest/docs/api/virtualizer) +- [Yjs subdocuments](https://docs.yjs.dev/api/subdocuments) +- [MDN: Optimizing canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas) +- [MDN: OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) +- [MDN: Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) +- [oEmbed](https://oembed.com/) +- [The Open Graph protocol](https://ogp.me/) From d9d0c07b5a40ecc04b179020536d456d786e4a48 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 16:12:26 -0700 Subject: [PATCH 05/42] feat(canvas): advance canvas v2 shell primitives - add source-backed canvas object kinds, a media asset schema, and shell helpers for page, database, and note objects - mount the shared minimap in the canvas runtime and align the Electron shell with page-backed canvas creation flows - expand the Canvas V2 plan with explicit Electron E2E and performance gates plus progress checklist updates - add initial Electron CDP smoke coverage for the canvas shell, minimap, and command flows --- apps/electron/src/renderer/App.tsx | 54 +++- .../src/renderer/components/CanvasView.tsx | 156 ++++++++---- .../src/renderer/lib/canvas-shell.test.ts | 73 +++++- .../electron/src/renderer/lib/canvas-shell.ts | 57 ++++- .../01-scene-graph-and-node-primitives.md | 4 +- .../02-hybrid-shell-and-renderer-runtime.md | 7 +- .../03-spatial-runtime-and-query-evolution.md | 11 + .../08-navigation-shortcuts-and-minimal-ux.md | 56 +++-- ...n-rollout-workbenches-and-release-gates.md | 39 ++- docs/plans/plan03_9_83CanvasV2/README.md | 84 ++++--- .../canvas-navigation-shell.test.tsx | 28 +++ packages/canvas/src/components/Minimap.tsx | 10 + .../canvas/src/components/NavigationTools.tsx | 11 +- packages/canvas/src/index.ts | 2 + .../canvas/src/nodes/CanvasNodeComponent.tsx | 16 +- packages/canvas/src/renderer/Canvas.tsx | 36 +++ packages/canvas/src/store.ts | 42 +++- packages/canvas/src/types.ts | 32 ++- packages/data/src/index.ts | 2 + packages/data/src/schema/index.ts | 1 + packages/data/src/schema/schemas/index.ts | 3 + .../src/schema/schemas/media-asset.test.ts | 50 ++++ .../data/src/schema/schemas/media-asset.ts | 43 ++++ tests/e2e/src/electron-canvas.spec.ts | 237 ++++++++++++++++++ 24 files changed, 899 insertions(+), 155 deletions(-) create mode 100644 packages/data/src/schema/schemas/media-asset.test.ts create mode 100644 packages/data/src/schema/schemas/media-asset.ts create mode 100644 tests/e2e/src/electron-canvas.spec.ts diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index df93ff898..47566372b 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -2,6 +2,7 @@ * Electron App - Main component */ +import type { LinkedDocumentItem } from './lib/canvas-shell' import type { PaletteCommand } from '@xnetjs/ui' import { PageSchema, DatabaseSchema, CanvasSchema } from '@xnetjs/data' import { useDevTools } from '@xnetjs/devtools' @@ -52,6 +53,10 @@ export function App(): React.ReactElement { const [homeCanvasId, setHomeCanvasId] = useState(null) const [homeCanvasBootstrapError, setHomeCanvasBootstrapError] = useState(null) const [shellState, setShellState] = useState({ kind: 'canvas-home' }) + const [pendingCanvasInsert, setPendingCanvasInsert] = useState<{ + requestId: string + document: LinkedDocumentItem + } | null>(null) const [showAddSharedDialog, setShowAddSharedDialog] = useState(false) const [prefilledShareValue, setPrefilledShareValue] = useState('') const { setActiveNodeId } = useDevTools() @@ -237,10 +242,13 @@ export function App(): React.ReactElement { const newDocument = await create(schema, { title }) if (!newDocument) return - canvasViewRef.current?.addLinkedDocumentNode({ - id: newDocument.id, - title, - type + setPendingCanvasInsert({ + requestId: `${type}-${newDocument.id}-${Date.now()}`, + document: { + id: newDocument.id, + title, + type + } }) setShellState({ kind: 'canvas-home' }) setActiveNodeId(homeCanvasId) @@ -252,11 +260,31 @@ export function App(): React.ReactElement { ) const handleCreateCanvasNote = useCallback(() => { - clearTransitionTimer() - canvasViewRef.current?.addCanvasNote() - setShellState({ kind: 'canvas-home' }) - setActiveNodeId(homeCanvasId) - }, [clearTransitionTimer, homeCanvasId, setActiveNodeId]) + const createCanvasNote = async () => { + clearTransitionTimer() + + try { + const note = await create(PageSchema, { title: 'Untitled Note' }) + if (!note) return + + setPendingCanvasInsert({ + requestId: `note-${note.id}-${Date.now()}`, + document: { + id: note.id, + title: note.title || 'Untitled Note', + type: 'page', + canvasKind: 'note' + } + }) + setShellState({ kind: 'canvas-home' }) + setActiveNodeId(homeCanvasId) + } catch (error) { + console.error('Failed to create canvas note', toError(error)) + } + } + + void createCanvasNote() + }, [clearTransitionTimer, create, homeCanvasId, setActiveNodeId]) const handleReturnHome = useCallback(() => { clearTransitionTimer() @@ -337,7 +365,7 @@ export function App(): React.ReactElement { { id: 'create-note', name: 'Create Canvas Note', - description: 'Add a lightweight note card to the workspace', + description: 'Create a page-backed note and place it on the canvas', icon: 'sparkles', execute: () => handleCreateCanvasNote() }, @@ -500,6 +528,12 @@ export function App(): React.ReactElement { ref={canvasViewRef} docId={homeCanvasId} documents={documents} + pendingInsert={pendingCanvasInsert} + onPendingInsertConsumed={(requestId) => { + setPendingCanvasInsert((current) => + current?.requestId === requestId ? null : current + ) + }} onOpenDocument={(docId, docType) => focusDocument(docId, docType, true)} /> diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index ea2a0fe0a..6b06b0805 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -4,7 +4,7 @@ import type { CanvasHandle, CanvasNode, Rect } from '@xnetjs/canvas' import { Canvas, createNode } from '@xnetjs/canvas' -import { CanvasSchema } from '@xnetjs/data' +import { CanvasSchema, DatabaseSchema, PageSchema } from '@xnetjs/data' import { useNode, useIdentity } from '@xnetjs/react' import { Database, FileText, StickyNote } from 'lucide-react' import React, { @@ -18,9 +18,11 @@ import React, { } from 'react' import { createCanvasShellNoteProperties, + getCanvasShellDisplayType, + getCanvasShellSourceId, + getCanvasShellSourceType, getCanvasShellNotePlacement, getLinkedDocumentPlacement, - isCanvasShellNote, shouldRenderCanvasShellCard, type LinkedDocType, type LinkedDocumentItem @@ -35,23 +37,19 @@ type ViewportSnapshot = { type CanvasViewProps = { docId: string documents?: LinkedDocumentItem[] + pendingInsert?: { + requestId: string + document: LinkedDocumentItem + } | null + onPendingInsertConsumed?: (requestId: string) => void onOpenDocument?: (docId: string, docType: Exclude) => void } export type CanvasViewHandle = { - addLinkedDocumentNode: (document: LinkedDocumentItem) => void - addCanvasNote: () => void focusLinkedDocument: (docId: string) => ViewportSnapshot | null restoreViewport: (snapshot: ViewportSnapshot) => void } -function getLinkedType(node: CanvasNode): LinkedDocType | null { - const linkedType = node.properties.linkedType - return linkedType === 'page' || linkedType === 'database' || linkedType === 'canvas' - ? linkedType - : null -} - function getNodeRect(node: CanvasNode): Rect { return { x: node.position.x, @@ -62,18 +60,24 @@ function getNodeRect(node: CanvasNode): Rect { } function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React.ReactElement { - const linkedType = document?.type ?? getLinkedType(node) ?? 'canvas' - const linkedTitle = document?.title ?? (node.properties.title as string) ?? 'Untitled' + const displayType = getCanvasShellDisplayType(node, document) + const sourceId = getCanvasShellSourceId(node) + const linkedTitle = + node.alias ?? document?.title ?? (node.properties.title as string) ?? 'Untitled' const subtitle = - linkedType === 'page' + displayType === 'page' ? 'Document' - : linkedType === 'database' + : displayType === 'database' ? 'Database' - : isCanvasShellNote(node) + : displayType === 'note' ? 'Canvas note' : 'Canvas' - const Icon = linkedType === 'page' ? FileText : linkedType === 'database' ? Database : StickyNote + const Icon = + displayType === 'page' ? FileText : displayType === 'database' ? Database : StickyNote + const isOpenable = Boolean( + sourceId && (displayType === 'page' || displayType === 'database' || displayType === 'note') + ) return (
@@ -82,7 +86,7 @@ function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React. {subtitle} - {node.linkedNodeId && linkedType !== 'canvas' ? ( + {isOpenable ? ( Open @@ -92,9 +96,9 @@ function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React.
{linkedTitle}

- {linkedType === 'database' + {displayType === 'database' ? 'Open a focused database surface from the canvas.' - : linkedType === 'page' + : displayType === 'page' ? 'Open a focused writing surface from the canvas.' : 'A lightweight note pinned directly to the workspace.'}

@@ -104,7 +108,13 @@ function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React. } export const CanvasView = forwardRef(function CanvasView( - { docId, documents = [], onOpenDocument }: CanvasViewProps, + { + docId, + documents = [], + pendingInsert, + onPendingInsertConsumed, + onOpenDocument + }: CanvasViewProps, ref ): React.ReactElement { const { did } = useIdentity() @@ -120,6 +130,12 @@ export const CanvasView = forwardRef(function }) const canvasRef = useRef(null) + const handledInsertIdsRef = useRef>(new Set()) + const lastViewportSnapshotRef = useRef({ + x: 0, + y: 0, + zoom: 1 + }) const [canvasReady, setCanvasReady] = useState(false) const [hasNodes, setHasNodes] = useState(false) const documentMap = useMemo( @@ -132,6 +148,11 @@ export const CanvasView = forwardRef(function setCanvasReady(true) }, [doc]) + useEffect(() => { + if (!canvasReady || !canvasRef.current) return + lastViewportSnapshotRef.current = canvasRef.current.getViewportSnapshot() + }, [canvasReady]) + useEffect(() => { if (!doc) return @@ -148,47 +169,74 @@ export const CanvasView = forwardRef(function } }, [doc]) - const addCanvasNote = useCallback(() => { - if (!doc || !canvasRef.current) return - - const viewport = canvasRef.current.getViewportSnapshot() - const nodesMap = doc.getMap('nodes') - const noteNode = createNode( - 'card', - getCanvasShellNotePlacement(viewport), - createCanvasShellNoteProperties() - ) - - nodesMap.set(noteNode.id, noteNode) - }, [doc]) - const addLinkedDocumentNode = useCallback( - (document: LinkedDocumentItem) => { - if (!doc || !canvasRef.current) return + (document: LinkedDocumentItem): boolean => { + if (!doc || document.type === 'canvas') return false - const viewport = canvasRef.current.getViewportSnapshot() + const viewport = canvasRef.current?.getViewportSnapshot() ?? lastViewportSnapshotRef.current const nodesMap = doc.getMap('nodes') - const linkedNode = createNode('embed', getLinkedDocumentPlacement(viewport, document.type), { - title: document.title, - linkedType: document.type - }) - linkedNode.linkedNodeId = document.id + const canvasKind = document.canvasKind ?? document.type + const properties = + canvasKind === 'note' + ? { + ...createCanvasShellNoteProperties(), + title: document.title + } + : { title: document.title } + const placement = + canvasKind === 'note' + ? getCanvasShellNotePlacement(viewport) + : getLinkedDocumentPlacement(viewport, document.type) + const linkedNode = createNode(canvasKind, placement, properties) + + linkedNode.sourceNodeId = document.id + linkedNode.sourceSchemaId = + document.type === 'page' ? PageSchema._schemaId : DatabaseSchema._schemaId + nodesMap.set(linkedNode.id, linkedNode) + return true }, [doc] ) + const addCanvasNote = useCallback( + (document: LinkedDocumentItem): boolean => { + if (document.type !== 'page') return false + return addLinkedDocumentNode({ ...document, canvasKind: 'note' }) + }, + [addLinkedDocumentNode] + ) + + useEffect(() => { + if (!pendingInsert || handledInsertIdsRef.current.has(pendingInsert.requestId)) { + return + } + + const inserted = + pendingInsert.document.canvasKind === 'note' + ? addCanvasNote(pendingInsert.document) + : addLinkedDocumentNode(pendingInsert.document) + + if (!inserted) { + return + } + + handledInsertIdsRef.current.add(pendingInsert.requestId) + onPendingInsertConsumed?.(pendingInsert.requestId) + }, [addCanvasNote, addLinkedDocumentNode, onPendingInsertConsumed, pendingInsert]) + const focusLinkedDocument = useCallback( (linkedDocumentId: string): ViewportSnapshot | null => { if (!doc || !canvasRef.current) return null const nodesMap = doc.getMap('nodes') const targetNode = Array.from(nodesMap.values()).find( - (node) => node.linkedNodeId === linkedDocumentId + (node) => getCanvasShellSourceId(node) === linkedDocumentId ) if (!targetNode) return null const snapshot = canvasRef.current.getViewportSnapshot() + lastViewportSnapshotRef.current = snapshot canvasRef.current.fitToRect(getNodeRect(targetNode), 140) return snapshot }, @@ -196,18 +244,17 @@ export const CanvasView = forwardRef(function ) const restoreViewport = useCallback((snapshot: ViewportSnapshot) => { + lastViewportSnapshotRef.current = snapshot canvasRef.current?.setViewportSnapshot(snapshot) }, []) useImperativeHandle( ref, () => ({ - addLinkedDocumentNode, - addCanvasNote, focusLinkedDocument, restoreViewport }), - [addCanvasNote, addLinkedDocumentNode, focusLinkedDocument, restoreViewport] + [focusLinkedDocument, restoreViewport] ) if (loading || !doc) { @@ -227,7 +274,7 @@ export const CanvasView = forwardRef(function } return ( -
+
{canvas?.title || 'Workspace Canvas'}
@@ -255,6 +302,7 @@ export const CanvasView = forwardRef(function minZoom: 0.1, maxZoom: 4 }} + showMinimap showNavigationTools navigationToolsPosition="bottom-right" navigationToolsShowZoomLabel={false} @@ -268,9 +316,8 @@ export const CanvasView = forwardRef(function border: '1px solid rgba(148, 163, 184, 0.28)' }} renderNode={(node) => { - const linkedDocument = node.linkedNodeId - ? documentMap.get(node.linkedNodeId) - : undefined + const sourceNodeId = getCanvasShellSourceId(node) + const linkedDocument = sourceNodeId ? documentMap.get(sourceNodeId) : undefined if (shouldRenderCanvasShellCard(node, linkedDocument)) { return renderNodeCard(node, linkedDocument) } @@ -279,9 +326,10 @@ export const CanvasView = forwardRef(function onNodeDoubleClick={(id) => { const nodesMap = doc.getMap('nodes') const targetNode = nodesMap.get(id) - const linkedType = targetNode ? getLinkedType(targetNode) : null - if (targetNode?.linkedNodeId && linkedType && linkedType !== 'canvas') { - onOpenDocument?.(targetNode.linkedNodeId, linkedType) + const sourceId = targetNode ? getCanvasShellSourceId(targetNode) : undefined + const sourceType = targetNode ? getCanvasShellSourceType(targetNode) : null + if (sourceId && sourceType) { + onOpenDocument?.(sourceId, sourceType) } }} /> diff --git a/apps/electron/src/renderer/lib/canvas-shell.test.ts b/apps/electron/src/renderer/lib/canvas-shell.test.ts index 67f5e42bc..da6323d31 100644 --- a/apps/electron/src/renderer/lib/canvas-shell.test.ts +++ b/apps/electron/src/renderer/lib/canvas-shell.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { createCanvasShellNoteProperties, + getCanvasShellDisplayType, + getCanvasShellSourceId, + getCanvasShellSourceType, getCanvasShellNotePlacement, getLinkedDocumentPlacement, isCanvasShellNote, @@ -9,27 +12,27 @@ import { describe('canvas-shell', () => { describe('isCanvasShellNote', () => { - it('returns true for shell-created note cards', () => { + it('returns true for shell-created note nodes', () => { const node = { - type: 'card', + type: 'note', properties: createCanvasShellNoteProperties() } expect(isCanvasShellNote(node)).toBe(true) }) - it('returns false for generic card nodes', () => { + it('returns false for generic page nodes', () => { const node = { - type: 'card', + type: 'page', properties: { title: 'Generic card' } } expect(isCanvasShellNote(node)).toBe(false) }) - it('returns false for non-card nodes', () => { + it('returns false for non-note nodes', () => { const node = { - type: 'embed', + type: 'database', properties: { title: 'Linked page' } } @@ -38,11 +41,11 @@ describe('canvas-shell', () => { }) describe('shouldRenderCanvasShellCard', () => { - it('renders linked documents with shell cards', () => { + it('renders page objects with shell chrome', () => { const node = { - type: 'embed', + type: 'page', properties: { title: 'Linked page' }, - linkedNodeId: 'page-1' + sourceNodeId: 'page-1' } expect(shouldRenderCanvasShellCard(node, { id: 'page-1', title: 'Page', type: 'page' })).toBe( @@ -50,16 +53,62 @@ describe('canvas-shell', () => { ) }) - it('does not render generic cards with shell chrome', () => { + it('renders note objects even without a loaded linked document', () => { const node = { - type: 'card', - properties: { title: 'Generic card' } + type: 'note', + properties: createCanvasShellNoteProperties() + } + + expect(shouldRenderCanvasShellCard(node)).toBe(true) + }) + + it('does not render unrelated shape objects with shell chrome', () => { + const node = { + type: 'shape', + properties: { title: 'Rectangle' } } expect(shouldRenderCanvasShellCard(node)).toBe(false) }) }) + describe('source helpers', () => { + it('derives note display type separately from its page source', () => { + const node = { + type: 'note', + sourceNodeId: 'page-1', + sourceSchemaId: 'xnet://xnet.fyi/Page@1.0.0', + properties: createCanvasShellNoteProperties() + } + + expect(getCanvasShellDisplayType(node)).toBe('note') + expect(getCanvasShellSourceType(node)).toBe('page') + expect(getCanvasShellSourceId(node)).toBe('page-1') + }) + + it('uses source schema ids to resolve document types', () => { + const node = { + type: 'media', + sourceNodeId: 'db-1', + sourceSchemaId: 'xnet://xnet.fyi/Database@1.0.0', + properties: {} + } + + expect(getCanvasShellSourceType(node)).toBe('database') + }) + + it('falls back to the legacy linked node id', () => { + const node = { + type: 'embed', + linkedNodeId: 'page-legacy', + properties: { linkedType: 'page' } + } + + expect(getCanvasShellSourceId(node)).toBe('page-legacy') + expect(getCanvasShellSourceType(node)).toBe('page') + }) + }) + describe('placement helpers', () => { const viewport = { x: 240, diff --git a/apps/electron/src/renderer/lib/canvas-shell.ts b/apps/electron/src/renderer/lib/canvas-shell.ts index a2c9a3481..23f149fe4 100644 --- a/apps/electron/src/renderer/lib/canvas-shell.ts +++ b/apps/electron/src/renderer/lib/canvas-shell.ts @@ -1,4 +1,5 @@ -import type { CanvasNode } from '@xnetjs/canvas' +import type { CanvasNode, CanvasObjectKind } from '@xnetjs/canvas' +import { DatabaseSchema, PageSchema } from '@xnetjs/data' export type LinkedDocType = 'page' | 'database' | 'canvas' @@ -6,6 +7,7 @@ export type LinkedDocumentItem = { id: string title: string type: LinkedDocType + canvasKind?: Extract } export type CanvasViewportSnapshot = { @@ -43,14 +45,63 @@ export function createCanvasShellNoteProperties(): Record { } export function isCanvasShellNote(node: CanvasNode): boolean { - return node.type === 'card' && node.properties.shellRole === SHELL_NOTE_ROLE + return node.type === 'note' && node.properties.shellRole === SHELL_NOTE_ROLE +} + +export function getCanvasShellSourceType( + node: CanvasNode, + linkedDocument?: LinkedDocumentItem +): Exclude | null { + if (linkedDocument?.type === 'page' || linkedDocument?.type === 'database') { + return linkedDocument.type + } + + if (node.type === 'database') { + return 'database' + } + + if (node.type === 'page' || node.type === 'note') { + return 'page' + } + + if (node.sourceSchemaId === DatabaseSchema._schemaId) { + return 'database' + } + + if (node.sourceSchemaId === PageSchema._schemaId) { + return 'page' + } + + const linkedType = node.properties.linkedType + return linkedType === 'page' || linkedType === 'database' ? linkedType : null +} + +export function getCanvasShellDisplayType( + node: CanvasNode, + linkedDocument?: LinkedDocumentItem +): LinkedDocType | 'note' { + if (isCanvasShellNote(node)) { + return 'note' + } + + const sourceType = getCanvasShellSourceType(node, linkedDocument) + if (sourceType) { + return sourceType + } + + return 'canvas' +} + +export function getCanvasShellSourceId(node: CanvasNode): string | undefined { + return node.sourceNodeId ?? node.linkedNodeId } export function shouldRenderCanvasShellCard( node: CanvasNode, linkedDocument?: LinkedDocumentItem ): boolean { - return Boolean(linkedDocument) || isCanvasShellNote(node) + const displayType = getCanvasShellDisplayType(node, linkedDocument) + return displayType === 'page' || displayType === 'database' || displayType === 'note' } export function getCanvasShellNotePlacement(viewport: CanvasViewportSnapshot): { diff --git a/docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md b/docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md index 3dd990eee..6428dbeb3 100644 --- a/docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md +++ b/docs/plans/plan03_9_83CanvasV2/01-scene-graph-and-node-primitives.md @@ -213,8 +213,8 @@ pnpm --filter @xnetjs/data test ## Step Checklist - [ ] Replace the current public canvas object union with Canvas V2 scene types. -- [ ] Introduce a `MediaAsset`-style schema in `@xnetjs/data`. -- [ ] Add stable `sourceNodeId` and `sourceSchemaId` references for source-backed objects. +- [x] Introduce a `MediaAsset`-style schema in `@xnetjs/data`. +- [x] Add stable `sourceNodeId` and `sourceSchemaId` references for source-backed objects. - [ ] Add connector/binding record types and storage. - [ ] Define the canvas Y.Doc layout for objects, connectors, groups, and metadata. - [ ] Remove Canvas V2 dependencies on the old generic `card/embed/image` semantics. diff --git a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md index b82804d81..5ccd60064 100644 --- a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md +++ b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md @@ -133,6 +133,11 @@ function CanvasRuntime(props: CanvasRuntimeProps): React.ReactElement { ## Testing and Validation Approach - Add renderer-layer tests where feasible in `packages/canvas`. +- Add Electron CDP tests for: + - shell boot + - minimap visibility/toggle + - dock + command-palette creation flows + - hybrid-layer smoke assertions (`canvas` overview layers + DOM object cards) - Verify that minimap, grid, DOM objects, and overlays continue to stack correctly. - Manually verify viewport updates and overlay alignment in Electron. @@ -152,7 +157,7 @@ pnpm dev:stories ## Step Checklist - [ ] Introduce the Canvas V2 runtime host and make it the primary render entry. -- [ ] Move the grid and minimap into the default shell path. +- [x] Move the grid and minimap into the default shell path. - [ ] Define explicit responsibilities for background, overview, DOM, and overlay layers. - [ ] Replace the current custom linked-card shell rendering with runtime-fed object rendering. - [ ] Keep persistent shell chrome minimal and contextual. diff --git a/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md b/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md index b05c75c50..517f93435 100644 --- a/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md +++ b/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md @@ -95,6 +95,11 @@ The likely future descriptor shape should support: - `nearPoint` - `orderByDistance` +Canvas V2 should use this step to harden `useQuery` for **two-dimensional search semantics** without bypassing the hook stack: + +- first for viewport/windowed object lookup, +- later for scene-aware `GeoSearch` or coordinate-aware ranking when xNet adds reusable spatial indexes outside the canvas. + Longer-term location support can become a hook/query concern too, but only after the basic viewport window semantics are stable. ### 5. Telemetry and frame budgets @@ -128,6 +133,11 @@ type SpatialQueryDescriptor = QueryDescriptor & { ## Testing and Validation Approach - Add unit coverage for visible-object queries and chunk/display-list behavior. +- Add Electron CDP large-scene tests that seed dense canvases and verify: + - bounded DOM object count + - no unexpected `contenteditable` or table mounts on the home surface + - stable minimap interaction while panning + - no query explosion during viewport movement - Add benchmark fixtures for: - 1,000 objects - 5,000 objects @@ -154,4 +164,5 @@ pnpm --filter @xnetjs/react test - [ ] Build overview and interactive display lists from a shared visibility pipeline. - [ ] Gate DOM mounts behind visibility, zoom, and interaction state. - [ ] Extend `useQuery`/`QueryDescriptor` only where Canvas V2 genuinely benefits. +- [ ] Add viewport-window and future geospatial query coverage around `useQuery`. - [ ] Add telemetry and benchmark coverage for display-list and query behavior. diff --git a/docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md b/docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md index 213634880..fde19ae1b 100644 --- a/docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md +++ b/docs/plans/plan03_9_83CanvasV2/08-navigation-shortcuts-and-minimal-ux.md @@ -66,31 +66,31 @@ without hunting through visible UI. Recommended default hotkeys: -| Action | Shortcut | -| --- | --- | -| Open command palette | `Cmd/Ctrl+Shift+P` | -| Shortcut help | `?` | -| Zoom in | `Cmd/Ctrl+=` | -| Zoom out | `Cmd/Ctrl+-` | -| Reset view | `Cmd/Ctrl+0` | -| Fit content | `Cmd/Ctrl+1` | -| Pan with keyboard | Arrow keys | -| Pan temporarily | `Space` + drag | -| Create page | `P` | -| Create database | `D` | -| Rectangle | `R` | -| Ellipse | `O` | -| Connector tool | `L` | -| Frame/group tool | `F` | -| Enter peek/edit | `Enter` | -| Open focused surface | `Cmd/Ctrl+Enter` | -| Exit edit/peek | `Escape` | -| Group | `G` | -| Ungroup | `Shift+G` | -| Lock/unlock | `Cmd/Ctrl+Shift+L` | -| Nudge | Arrow keys with selection | -| Large nudge | `Shift` + arrow keys | -| Bring forward/back | `]` / `[` | +| Action | Shortcut | +| -------------------- | ------------------------- | +| Open command palette | `Cmd/Ctrl+Shift+P` | +| Shortcut help | `?` | +| Zoom in | `Cmd/Ctrl+=` | +| Zoom out | `Cmd/Ctrl+-` | +| Reset view | `Cmd/Ctrl+0` | +| Fit content | `Cmd/Ctrl+1` | +| Pan with keyboard | Arrow keys | +| Pan temporarily | `Space` + drag | +| Create page | `P` | +| Create database | `D` | +| Rectangle | `R` | +| Ellipse | `O` | +| Connector tool | `L` | +| Frame/group tool | `F` | +| Enter peek/edit | `Enter` | +| Open focused surface | `Cmd/Ctrl+Enter` | +| Exit edit/peek | `Escape` | +| Group | `G` | +| Ungroup | `Shift+G` | +| Lock/unlock | `Cmd/Ctrl+Shift+L` | +| Nudge | Arrow keys with selection | +| Large nudge | `Shift` + arrow keys | +| Bring forward/back | `]` / `[` | Implementation note: @@ -138,6 +138,11 @@ type CanvasCommand = { ## Testing and Validation Approach - Add unit coverage for shortcut dispatch and “typing guard” behavior. +- Add Electron CDP e2e coverage for: + - `Cmd/Ctrl+Shift+P` palette open + - page/database/note creation from shortcuts or palette + - minimap hide/show + - Escape/Enter focused-surface transitions - Verify that shortcuts remain discoverable via palette/HUD/help overlay. - Manually verify keyboard-first flows in Electron. @@ -162,3 +167,4 @@ pnpm --filter @xnetjs/ui test - [ ] Add a discoverable shortcut help overlay. - [ ] Implement the selection HUD with only context-relevant actions. - [ ] Ensure keyboard-first creation/edit/navigation flows work without interfering with editor typing. +- [ ] Back the shortcut layer with Electron CDP e2e coverage for hotkeys and typing guards. diff --git a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md index 80b850e4a..ba6bbb294 100644 --- a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md +++ b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md @@ -80,6 +80,11 @@ Track: - query counts/churn, - memory profile. +The release process should include both: + +- **Electron CDP e2e flows** for real shell behavior and shortcut ergonomics. +- **Large-scene perf suites** for seeded canvases that stress the hybrid renderer without opening focused editors. + ### 4. Manual validation gates Because this is a rich interactive surface, manual Electron validation is required for: @@ -98,14 +103,14 @@ Only after Electron passes the gates should the team adapt the new shell/runtime ## Suggested Validation Matrix -| Area | Gate | -| --- | --- | -| Scene model | only Canvas V2 object kinds are used in the active path | -| Performance | large-scene pan/zoom stays smooth and DOM remains bounded | -| Content | page editing and database preview/open flows are stable | -| UX | hotkeys, command palette, minimap, and selection HUD are coherent | -| Collaboration | presence and undo boundaries behave predictably | -| Accessibility | keyboard traversal and focus treatment are complete | +| Area | Gate | +| ------------- | ----------------------------------------------------------------- | +| Scene model | only Canvas V2 object kinds are used in the active path | +| Performance | large-scene pan/zoom stays smooth and DOM remains bounded | +| Content | page editing and database preview/open flows are stable | +| UX | hotkeys, command palette, minimap, and selection HUD are coherent | +| Collaboration | presence and undo boundaries behave predictably | +| Accessibility | keyboard traversal and focus treatment are complete | ## Implementation Notes @@ -121,6 +126,7 @@ Suggested commands: pnpm --filter @xnetjs/canvas test pnpm --filter @xnetjs/react test pnpm --filter @xnetjs/data test +cd tests/e2e && pnpm exec playwright test src/electron-canvas.spec.ts --project=chromium pnpm dev:stories cd apps/electron && pnpm dev cd apps/electron && pnpm dev:both @@ -137,6 +143,21 @@ Manual validation should include: - test lock/group/align/tidy on dense selections, - verify collaboration and undo boundaries. +Automated validation should include: + +- Electron CDP smoke coverage for: + - shell boot + - dock creation + - command-palette creation + - minimap toggle + - page/database focus-return flows +- Electron CDP performance coverage for: + - dense seeded scenes + - bounded DOM node counts + - no editor/table mounts on the home surface + - minimap interaction under load + - query/frame telemetry capture + ## Risks and Edge Cases - Storybook scenes can drift from the real app if the runtime shell is forked across package and app code. @@ -148,6 +169,8 @@ Manual validation should include: - [ ] Replace the active Electron canvas path with Canvas V2. - [ ] Build realistic Storybook/workbench scenes for every major object family and density class. - [ ] Add repeatable performance scenes and capture frame/DOM/query metrics. +- [ ] Add Electron CDP e2e coverage for canvas-home workflows and shortcuts. +- [ ] Add Electron CDP large-scene performance coverage and record thresholds. - [ ] Run manual Electron validation for editing, navigation, collaboration, and shortcuts. - [ ] Document and enforce release gates before web rollout. - [ ] Start web adaptation only after Electron passes the full gate set. diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md index 788607f64..d563afa7c 100644 --- a/docs/plans/plan03_9_83CanvasV2/README.md +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -65,13 +65,13 @@ Canvas V2 should fix that by making the canvas useful for real work immediately ### Where the current product is still split -| Area | Observed repository state | Why Canvas V2 must change it | -| --- | --- | --- | -| Scene model | [`packages/canvas/src/types.ts`](../../../packages/canvas/src/types.ts) still defines `card`, `frame`, `shape`, `image`, `embed`, `group` | too generic for content-first rendering and object-specific policies | -| App shell | [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) still renders linked cards rather than live page/database surfaces | the canvas still feels like a launcher, not a workspace | -| Drop model | URLs/files/internal drags are not unified into one ingestion pipeline | the canvas cannot yet behave like a universal spatial drop target | -| Renderer contract | the package exposes layer/chunk/LOD primitives, but the active app path does not fully route through them | performance work exists but is not yet the primary runtime | -| Data access | the app already has `useNode`, `useDatabase`, and `useQuery`, but the canvas does not yet drive their next evolution | the canvas should strengthen the hook/runtime platform, not bypass it | +| Area | Observed repository state | Why Canvas V2 must change it | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| Scene model | [`packages/canvas/src/types.ts`](../../../packages/canvas/src/types.ts) still defines `card`, `frame`, `shape`, `image`, `embed`, `group` | too generic for content-first rendering and object-specific policies | +| App shell | [`apps/electron/src/renderer/components/CanvasView.tsx`](../../../apps/electron/src/renderer/components/CanvasView.tsx) still renders linked cards rather than live page/database surfaces | the canvas still feels like a launcher, not a workspace | +| Drop model | URLs/files/internal drags are not unified into one ingestion pipeline | the canvas cannot yet behave like a universal spatial drop target | +| Renderer contract | the package exposes layer/chunk/LOD primitives, but the active app path does not fully route through them | performance work exists but is not yet the primary runtime | +| Data access | the app already has `useNode`, `useDatabase`, and `useQuery`, but the canvas does not yet drive their next evolution | the canvas should strengthen the hook/runtime platform, not bypass it | ### Product-quality observations to carry forward @@ -201,29 +201,53 @@ flowchart TD ### Performance targets -| Area | Target | -| --- | --- | -| Pan/zoom on 60Hz displays | stay comfortably within `16.67ms` frame budget | -| Pan/zoom on 120Hz displays | aim for `8.33ms` effective frame budget | -| Interactive DOM count | keep near-field DOM objects bounded and measurable | -| Far-field rendering | no inline editor mounts outside the near-field window | -| Large-scene navigation | chunk load/evict and display-list recompute must not cause visible hitching | -| Database preview | bounded preview rows/cells with virtualization for heavy previews | +| Area | Target | +| -------------------------- | --------------------------------------------------------------------------- | +| Pan/zoom on 60Hz displays | stay comfortably within `16.67ms` frame budget | +| Pan/zoom on 120Hz displays | aim for `8.33ms` effective frame budget | +| Interactive DOM count | keep near-field DOM objects bounded and measurable | +| Far-field rendering | no inline editor mounts outside the near-field window | +| Large-scene navigation | chunk load/evict and display-list recompute must not cause visible hitching | +| Database preview | bounded preview rows/cells with virtualization for heavy previews | + +## Test Strategy + +```mermaid +flowchart LR + Unit["Unit and renderer tests"] --> E2E["Electron CDP e2e flows"] + E2E --> Perf["Large-scene perf harnesses"] + Perf --> Gates["Release gates"] +``` + +- Use **package-level unit and renderer tests** for scene-model contracts, minimap/grid behavior, shortcut dispatch, and store/query math. +- Use **Electron CDP Playwright tests** for real shell behavior: + - canvas boot + - dock + command-palette creation flows + - minimap visibility/toggle + - focus/return transitions + - drag/drop smoke flows + - shortcut and typing-guard behavior +- Use **large-scene performance harnesses** for: + - bounded DOM count + - no unexpected editor/table mounts on the home canvas + - frame/query telemetry capture + - chunk load/evict timing and minimap responsiveness +- Keep performance gates tied to reproducible synthetic scenes and explicit thresholds recorded in PR notes. ## Step Index -| Step | File | Outcome | -| --- | --- | --- | -| 1 | [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md) | typed Canvas V2 scene model, source-node contracts, and clean cutover rules | -| 2 | [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md) | primary hybrid runtime shell with explicit layer responsibilities | -| 3 | [03-spatial-runtime-and-query-evolution.md](./03-spatial-runtime-and-query-evolution.md) | chunked/cullable display lists plus hook/query evolution for viewport-driven loading | -| 4 | [04-drop-ingestion-and-source-object-creation.md](./04-drop-ingestion-and-source-object-creation.md) | universal drop pipeline and node-backed URL/media creation flows | -| 5 | [05-page-cards-inline-editing-and-peek.md](./05-page-cards-inline-editing-and-peek.md) | live page cards with inline editing, LOD, and center-peek flows | -| 6 | [06-database-cards-preview-focus-and-split.md](./06-database-cards-preview-focus-and-split.md) | database preview cards with focus/open/split workflows | -| 7 | [07-connectors-shapes-groups-and-polish.md](./07-connectors-shapes-groups-and-polish.md) | bindings, shapes, groups, locks, tidy-up, aliases, and backlink polish | -| 8 | [08-navigation-shortcuts-and-minimal-ux.md](./08-navigation-shortcuts-and-minimal-ux.md) | minimal chrome, hotkeys, command palette integration, and navigation UX | -| 9 | [09-collaboration-undo-accessibility-and-comments.md](./09-collaboration-undo-accessibility-and-comments.md) | collaboration scopes, undo boundaries, accessibility, and comment anchoring | -| 10 | [10-electron-rollout-workbenches-and-release-gates.md](./10-electron-rollout-workbenches-and-release-gates.md) | Electron-first rollout, Storybook workbenches, benchmarks, and release gates | +| Step | File | Outcome | +| ---- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| 1 | [01-scene-graph-and-node-primitives.md](./01-scene-graph-and-node-primitives.md) | typed Canvas V2 scene model, source-node contracts, and clean cutover rules | +| 2 | [02-hybrid-shell-and-renderer-runtime.md](./02-hybrid-shell-and-renderer-runtime.md) | primary hybrid runtime shell with explicit layer responsibilities | +| 3 | [03-spatial-runtime-and-query-evolution.md](./03-spatial-runtime-and-query-evolution.md) | chunked/cullable display lists plus hook/query evolution for viewport-driven loading | +| 4 | [04-drop-ingestion-and-source-object-creation.md](./04-drop-ingestion-and-source-object-creation.md) | universal drop pipeline and node-backed URL/media creation flows | +| 5 | [05-page-cards-inline-editing-and-peek.md](./05-page-cards-inline-editing-and-peek.md) | live page cards with inline editing, LOD, and center-peek flows | +| 6 | [06-database-cards-preview-focus-and-split.md](./06-database-cards-preview-focus-and-split.md) | database preview cards with focus/open/split workflows | +| 7 | [07-connectors-shapes-groups-and-polish.md](./07-connectors-shapes-groups-and-polish.md) | bindings, shapes, groups, locks, tidy-up, aliases, and backlink polish | +| 8 | [08-navigation-shortcuts-and-minimal-ux.md](./08-navigation-shortcuts-and-minimal-ux.md) | minimal chrome, hotkeys, command palette integration, and navigation UX | +| 9 | [09-collaboration-undo-accessibility-and-comments.md](./09-collaboration-undo-accessibility-and-comments.md) | collaboration scopes, undo boundaries, accessibility, and comment anchoring | +| 10 | [10-electron-rollout-workbenches-and-release-gates.md](./10-electron-rollout-workbenches-and-release-gates.md) | Electron-first rollout, Storybook workbenches, benchmarks, and release gates | ## Risks and Open Questions @@ -238,7 +262,7 @@ flowchart TD ## Implementation Checklist - [ ] Replace the current generic canvas object contract with a typed scene graph. -- [ ] Add a reusable `MediaAsset`-style node schema for dropped images/files. +- [x] Add a reusable `MediaAsset`-style node schema for dropped images/files. - [ ] Replace the current linked-card shell with a hybrid renderer shell. - [ ] Route the main runtime through chunking, culling, and explicit layer display lists. - [ ] Add universal drop ingestion for internal drags, URLs, text, images, and files. @@ -248,6 +272,8 @@ flowchart TD - [ ] Define and implement the full shortcut/command surface for Canvas V2. - [ ] Integrate collaboration, undo, comments, and accessibility into the new scene/runtime model. - [ ] Build Storybook and manual validation scenes that reflect the real Canvas V2 object model. +- [ ] Add Electron CDP e2e coverage for canvas creation, minimap, command palette, drag/drop, and focused-surface transitions. +- [ ] Add large-scene performance harnesses with DOM-count, query-churn, and frame-budget assertions. - [ ] Validate Electron-first performance and interaction budgets before web rollout. ## Validation Checklist @@ -259,6 +285,8 @@ flowchart TD - [ ] Pan/zoom remains smooth on large scenes with chunk load/evict active. - [ ] The background grid and minimap remain outside the main DOM path. - [ ] Far-field objects do not mount rich editors or oversized DOM subtrees. +- [ ] Electron CDP tests cover dock creation, command-palette creation, minimap toggling, and focused-surface transitions. +- [ ] Large-scene performance runs capture bounded DOM count, frame timing, minimap responsiveness, and query churn. - [ ] Shortcut-driven flows let a keyboard user create, select, group, lock, align, peek, edit, and open objects without excessive pointer travel. - [ ] Undo/redo behaves correctly across canvas-object moves and inline content edits. - [ ] Collaboration keeps canvas movement/selection awareness separate from page/database editing awareness. diff --git a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx index 79fab5b01..65d9e7f90 100644 --- a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx +++ b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx @@ -39,6 +39,18 @@ beforeAll(() => { } vi.stubGlobal('ResizeObserver', ResizeObserverMock) + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => ({ + scale: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + strokeRect: vi.fn() + })) + }) Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get() { @@ -121,6 +133,22 @@ describe('Canvas navigation shell', () => { expect(screen.queryByText('100%')).toBeNull() }) + it('renders the shared minimap when requested', () => { + mockUseCanvas.mockReturnValue(createCanvasMock()) + + render( + + ) + + expect(screen.getByRole('button', { name: /hide minimap/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /zoom in/i })).toBeTruthy() + }) + it('routes navigation tool actions through the viewport snapshot API', () => { const canvasMock = createCanvasMock() mockUseCanvas.mockReturnValue(canvasMock) diff --git a/packages/canvas/src/components/Minimap.tsx b/packages/canvas/src/components/Minimap.tsx index ea44b457b..e395b8b73 100644 --- a/packages/canvas/src/components/Minimap.tsx +++ b/packages/canvas/src/components/Minimap.tsx @@ -36,6 +36,16 @@ export interface MinimapProps { function getNodeMinimapColor(node: CanvasNode): string { switch (node.type) { + case 'page': + return 'rgba(59, 130, 246, 0.7)' + case 'database': + return 'rgba(16, 185, 129, 0.7)' + case 'external-reference': + return 'rgba(236, 72, 153, 0.7)' + case 'media': + return 'rgba(139, 92, 246, 0.7)' + case 'note': + return 'rgba(245, 158, 11, 0.7)' case 'card': return 'rgba(59, 130, 246, 0.7)' // Blue case 'frame': diff --git a/packages/canvas/src/components/NavigationTools.tsx b/packages/canvas/src/components/NavigationTools.tsx index 80a607688..276835b7a 100644 --- a/packages/canvas/src/components/NavigationTools.tsx +++ b/packages/canvas/src/components/NavigationTools.tsx @@ -25,6 +25,8 @@ export interface NavigationToolsProps { className?: string /** Optional style overrides for the toolbar container */ style?: React.CSSProperties + /** Right inset used for bottom-right positioning */ + insetRight?: number } // ─── Navigation Tools Component ─────────────────────────────────────────────── @@ -36,7 +38,8 @@ export function NavigationTools({ position = 'bottom-left', showZoomLabel = true, className, - style + style, + insetRight = 16 }: NavigationToolsProps) { const zoomIn = useCallback(() => { const newZoom = Math.min(viewport.zoom * 1.5, 4) @@ -78,7 +81,7 @@ export function NavigationTools({ const zoomPercent = Math.round(viewport.zoom * 100) const positionStyles = { - ...getPositionStyles(position), + ...getPositionStyles(position, insetRight), ...style } @@ -148,7 +151,7 @@ export function NavigationTools({ // ─── Styles ─────────────────────────────────────────────────────────────────── -function getPositionStyles(position: string): React.CSSProperties { +function getPositionStyles(position: string, insetRight: number): React.CSSProperties { const base: React.CSSProperties = { position: 'absolute', display: 'flex', @@ -166,7 +169,7 @@ function getPositionStyles(position: string): React.CSSProperties { case 'bottom-left': return { ...base, bottom: 16, left: 16 } case 'bottom-right': - return { ...base, bottom: 16, right: 240 } // Offset for minimap + return { ...base, bottom: 16, right: insetRight } case 'top-left': return { ...base, top: 16, left: 16 } case 'top-right': diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index 76964c642..d48410497 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -29,6 +29,8 @@ export type { Point, Rect, CanvasNodePosition, + CanvasObjectKind, + LegacyCanvasNodeType, CanvasNodeType, CanvasNode, EdgeAnchor, diff --git a/packages/canvas/src/nodes/CanvasNodeComponent.tsx b/packages/canvas/src/nodes/CanvasNodeComponent.tsx index 4d4bba8c8..778bdb1a4 100644 --- a/packages/canvas/src/nodes/CanvasNodeComponent.tsx +++ b/packages/canvas/src/nodes/CanvasNodeComponent.tsx @@ -127,6 +127,11 @@ function getHandleStyle(handle: ResizeHandle): React.CSSProperties { */ function getNodeColor(node: CanvasNode): string { const colors: Record = { + page: '#e3f2fd', + database: '#e8f5e9', + 'external-reference': '#fce7f3', + media: '#ede9fe', + note: '#fff7ed', card: '#e3f2fd', embed: '#f3e5f5', mermaid: '#e8f5e9', @@ -140,7 +145,7 @@ function getNodeColor(node: CanvasNode): string { * Get node title for display */ function getNodeTitle(node: CanvasNode): string { - return (node.properties.title as string) ?? node.type ?? 'Untitled' + return node.alias ?? (node.properties.title as string) ?? node.type ?? 'Untitled' } /** @@ -148,6 +153,11 @@ function getNodeTitle(node: CanvasNode): string { */ function NodeIcon({ type }: { type: string }) { const icons: Record = { + page: '📄', + database: '🗃️', + 'external-reference': '🔗', + media: '🖼️', + note: '📝', card: '📄', embed: '🔗', mermaid: '📊', @@ -183,9 +193,9 @@ function DefaultNodeContent({ node }: { node: CanvasNode }) { > {title}
- {node.linkedNodeId && ( + {(node.sourceNodeId ?? node.linkedNodeId) && (
- Linked: {node.linkedNodeId.slice(0, 8)}... + Source: {(node.sourceNodeId ?? node.linkedNodeId)?.slice(0, 8)}...
)}
diff --git a/packages/canvas/src/renderer/Canvas.tsx b/packages/canvas/src/renderer/Canvas.tsx index 07feaaa58..162ad1550 100644 --- a/packages/canvas/src/renderer/Canvas.tsx +++ b/packages/canvas/src/renderer/Canvas.tsx @@ -16,6 +16,7 @@ import React, { } from 'react' import * as Y from 'yjs' import { CommentOverlay } from '../comments/CommentOverlay' +import { CollapsibleMinimap } from '../components/Minimap' import { NavigationTools } from '../components/NavigationTools' import { CanvasEdgeComponent } from '../edges/CanvasEdgeComponent' import { useCanvas } from '../hooks/useCanvas' @@ -84,6 +85,18 @@ export interface CanvasProps { canvasSchema?: string /** Render built-in canvas navigation tools */ showNavigationTools?: boolean + /** Render the built-in canvas minimap */ + showMinimap?: boolean + /** Whether the minimap starts expanded */ + minimapDefaultExpanded?: boolean + /** Minimap width in pixels */ + minimapWidth?: number + /** Minimap height in pixels */ + minimapHeight?: number + /** Show edge lines in the minimap */ + minimapShowEdges?: boolean + /** Optional class name for the built-in minimap */ + minimapClassName?: string /** Position for the built-in navigation tools */ navigationToolsPosition?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' /** Show the zoom percentage inside the built-in navigation tools */ @@ -182,6 +195,12 @@ export const Canvas = forwardRef(function Canvas( canvasNodeId, canvasSchema, showNavigationTools = false, + showMinimap = false, + minimapDefaultExpanded = true, + minimapWidth = 200, + minimapHeight = 150, + minimapShowEdges = true, + minimapClassName, navigationToolsPosition = 'bottom-left', navigationToolsShowZoomLabel = true, navigationToolsClassName, @@ -575,6 +594,8 @@ export const Canvas = forwardRef(function Canvas( ) const canvasBounds = useMemo(() => canvas.store.getBounds(), [canvas.store, nodes]) + const navigationToolsInsetRight = + showMinimap && navigationToolsPosition === 'bottom-right' ? minimapWidth + 40 : 16 const handleNavigationViewportChange = useCallback( (changes: { x?: number; y?: number; zoom?: number }) => { @@ -694,6 +715,21 @@ export const Canvas = forwardRef(function Canvas( showZoomLabel={navigationToolsShowZoomLabel} className={navigationToolsClassName} style={navigationToolsStyle} + insetRight={navigationToolsInsetRight} + /> + )} + + {showMinimap && ( + )}
diff --git a/packages/canvas/src/store.ts b/packages/canvas/src/store.ts index bb2a15534..676b2bb47 100644 --- a/packages/canvas/src/store.ts +++ b/packages/canvas/src/store.ts @@ -365,6 +365,18 @@ export class CanvasStore { if (oldNode.linkedNodeId !== node.linkedNodeId) { changes.linkedNodeId = node.linkedNodeId } + if (oldNode.sourceNodeId !== node.sourceNodeId) { + changes.sourceNodeId = node.sourceNodeId + } + if (oldNode.sourceSchemaId !== node.sourceSchemaId) { + changes.sourceSchemaId = node.sourceSchemaId + } + if (oldNode.alias !== node.alias) { + changes.alias = node.alias + } + if (oldNode.locked !== node.locked) { + changes.locked = node.locked + } // Deep compare properties (JSON for simplicity) if (JSON.stringify(oldNode.properties) !== JSON.stringify(node.properties)) { changes.properties = node.properties @@ -476,14 +488,16 @@ export function createNode( position: Partial = {}, properties: Record = {} ): CanvasNode { + const defaultSize = getDefaultNodeSize(type) + return { id: generateNodeId(), type, position: { x: position.x ?? 0, y: position.y ?? 0, - width: position.width ?? 200, - height: position.height ?? 100, + width: position.width ?? defaultSize.width, + height: position.height ?? defaultSize.height, rotation: position.rotation, zIndex: position.zIndex ?? 0 }, @@ -491,6 +505,30 @@ export function createNode( } } +function getDefaultNodeSize(type: CanvasNodeType): { width: number; height: number } { + switch (type) { + case 'page': + return { width: 360, height: 220 } + case 'database': + return { width: 440, height: 260 } + case 'note': + return { width: 320, height: 180 } + case 'external-reference': + return { width: 360, height: 180 } + case 'media': + return { width: 320, height: 240 } + case 'group': + return { width: 320, height: 220 } + case 'shape': + case 'card': + case 'frame': + case 'image': + case 'embed': + default: + return { width: 200, height: 100 } + } +} + /** * Create a new edge */ diff --git a/packages/canvas/src/types.ts b/packages/canvas/src/types.ts index 583acc3a4..aefab07a2 100644 --- a/packages/canvas/src/types.ts +++ b/packages/canvas/src/types.ts @@ -36,9 +36,27 @@ export interface CanvasNodePosition { } /** - * Canvas node types + * Legacy canvas node types kept temporarily while the active app path + * moves to Canvas V2 object kinds. */ -export type CanvasNodeType = 'card' | 'frame' | 'shape' | 'image' | 'embed' | 'group' +export type LegacyCanvasNodeType = 'card' | 'frame' | 'image' | 'embed' + +/** + * Canvas V2 object kinds. + */ +export type CanvasObjectKind = + | 'page' + | 'database' + | 'external-reference' + | 'media' + | 'shape' + | 'note' + | 'group' + +/** + * Canvas node types. + */ +export type CanvasNodeType = CanvasObjectKind | LegacyCanvasNodeType /** * Shape types for shape nodes @@ -51,8 +69,16 @@ export type ShapeType = 'rectangle' | 'ellipse' | 'diamond' | 'triangle' | 'line export interface CanvasNode { id: string type: CanvasNodeType - /** Reference to linked xNet node (optional) */ + /** Reference to linked xNet node (legacy field, prefer sourceNodeId) */ linkedNodeId?: string + /** Stable reference to the source xNet node */ + sourceNodeId?: string + /** Stable reference to the source schema IRI */ + sourceSchemaId?: string + /** Optional canvas-local alias for the source object */ + alias?: string + /** Whether this object is locked against accidental edits/moves */ + locked?: boolean /** Position and dimensions */ position: CanvasNodePosition /** Node-specific properties */ diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 476964fc1..9653a503e 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -85,6 +85,8 @@ export { type Task, ExternalReferenceSchema, type ExternalReference, + MediaAssetSchema, + type MediaAsset, CanvasSchema, type Canvas, CommentSchema, diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index cc890e6ca..196521d67 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -107,6 +107,7 @@ export { DatabaseSchema, type Database } from './schemas' export { DatabaseRowSchema, type DatabaseRow } from './schemas' export { TaskSchema, type Task } from './schemas' export { ExternalReferenceSchema, type ExternalReference } from './schemas' +export { MediaAssetSchema, type MediaAsset } from './schemas' export { CanvasSchema, type Canvas } from './schemas' export { CommentSchema, type Comment } from './schemas' export { GrantSchema, type Grant } from './schemas' diff --git a/packages/data/src/schema/schemas/index.ts b/packages/data/src/schema/schemas/index.ts index ef008330a..b59aac1e4 100644 --- a/packages/data/src/schema/schemas/index.ts +++ b/packages/data/src/schema/schemas/index.ts @@ -10,6 +10,7 @@ export { DatabaseSchema, type Database } from './database' export { DatabaseRowSchema, type DatabaseRow } from './database-row' export { TaskSchema, type Task } from './task' export { ExternalReferenceSchema, type ExternalReference } from './external-reference' +export { MediaAssetSchema, type MediaAsset } from './media-asset' export { CanvasSchema, type Canvas } from './canvas' export { CommentSchema, type Comment } from './comment' export { GrantSchema, type Grant } from './grant' @@ -77,6 +78,7 @@ export const builtInSchemas = { 'xnet://xnet.fyi/Task@1.0.0': () => import('./task').then((m) => m.TaskSchema), 'xnet://xnet.fyi/ExternalReference@1.0.0': () => import('./external-reference').then((m) => m.ExternalReferenceSchema), + 'xnet://xnet.fyi/MediaAsset@1.0.0': () => import('./media-asset').then((m) => m.MediaAssetSchema), 'xnet://xnet.fyi/Canvas@1.0.0': () => import('./canvas').then((m) => m.CanvasSchema), 'xnet://xnet.fyi/Comment@1.0.0': () => import('./comment').then((m) => m.CommentSchema), 'xnet://xnet.fyi/Grant@1.0.0': () => import('./grant').then((m) => m.GrantSchema), @@ -88,6 +90,7 @@ export const builtInSchemas = { 'xnet://xnet.fyi/Task': () => import('./task').then((m) => m.TaskSchema), 'xnet://xnet.fyi/ExternalReference': () => import('./external-reference').then((m) => m.ExternalReferenceSchema), + 'xnet://xnet.fyi/MediaAsset': () => import('./media-asset').then((m) => m.MediaAssetSchema), 'xnet://xnet.fyi/Canvas': () => import('./canvas').then((m) => m.CanvasSchema), 'xnet://xnet.fyi/Comment': () => import('./comment').then((m) => m.CommentSchema), 'xnet://xnet.fyi/Grant': () => import('./grant').then((m) => m.GrantSchema) diff --git a/packages/data/src/schema/schemas/media-asset.test.ts b/packages/data/src/schema/schemas/media-asset.test.ts new file mode 100644 index 000000000..fac10bc2f --- /dev/null +++ b/packages/data/src/schema/schemas/media-asset.test.ts @@ -0,0 +1,50 @@ +import type { DID } from '../node' +import { describe, expect, it } from 'vitest' +import { MediaAssetSchema } from './media-asset' + +describe('MediaAssetSchema', () => { + const testDID = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK' as DID + + it('has the expected schema identity', () => { + expect(MediaAssetSchema.schema['@id']).toBe('xnet://xnet.fyi/MediaAsset@1.0.0') + expect(MediaAssetSchema.schema.name).toBe('MediaAsset') + expect(MediaAssetSchema.schema.version).toBe('1.0.0') + }) + + it('defines file-backed media properties', () => { + const propIds = MediaAssetSchema.schema.properties.map((prop) => prop['@id']) + + expect(propIds).toContain('xnet://xnet.fyi/MediaAsset@1.0.0#title') + expect(propIds).toContain('xnet://xnet.fyi/MediaAsset@1.0.0#file') + expect(propIds).toContain('xnet://xnet.fyi/MediaAsset@1.0.0#kind') + expect(propIds).toContain('xnet://xnet.fyi/MediaAsset@1.0.0#width') + expect(propIds).toContain('xnet://xnet.fyi/MediaAsset@1.0.0#height') + }) + + it('creates a valid image media node', () => { + const media = MediaAssetSchema.create( + { + title: 'Screenshot', + kind: 'image', + alt: 'Canvas screenshot', + width: 1920, + height: 1080, + file: { + cid: 'cid:blake3:test-image', + name: 'screenshot.png', + mimeType: 'image/png', + size: 2048 + } + }, + { createdBy: testDID } + ) + + const file = media.file + + expect(media.kind).toBe('image') + expect(file).toBeDefined() + expect(file?.mimeType).toBe('image/png') + expect(media.width).toBe(1920) + expect(media.height).toBe(1080) + }) +}) diff --git a/packages/data/src/schema/schemas/media-asset.ts b/packages/data/src/schema/schemas/media-asset.ts new file mode 100644 index 000000000..7285927d6 --- /dev/null +++ b/packages/data/src/schema/schemas/media-asset.ts @@ -0,0 +1,43 @@ +/** + * MediaAssetSchema - Reusable media/file node for canvas and page references. + */ + +import type { InferNode } from '../types' +import { defineSchema } from '../define' +import { file, number, select, text } from '../properties' + +export const MediaAssetSchema = defineSchema({ + name: 'MediaAsset', + namespace: 'xnet://xnet.fyi/', + properties: { + /** Display title */ + title: text({ required: true, maxLength: 500 }), + + /** Primary file reference */ + file: file({ required: true }), + + /** Normalized media kind */ + kind: select({ + options: [ + { id: 'image', name: 'Image' }, + { id: 'video', name: 'Video' }, + { id: 'audio', name: 'Audio' }, + { id: 'document', name: 'Document' }, + { id: 'file', name: 'File' } + ] as const, + default: 'file' + }), + + /** Optional alt text or description */ + alt: text({ maxLength: 2000 }), + + /** Natural width when known */ + width: number({ integer: true, min: 0 }), + + /** Natural height when known */ + height: number({ integer: true, min: 0 }) + }, + document: undefined +}) + +export type MediaAsset = InferNode<(typeof MediaAssetSchema)['_properties']> diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts new file mode 100644 index 000000000..a5b63786c --- /dev/null +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -0,0 +1,237 @@ +import { execSync, spawn, type ChildProcess } from 'node:child_process' +import { rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { chromium, expect, test, type Browser, type Page } from '@playwright/test' + +const ROOT = new URL('../../../', import.meta.url).pathname.replace(/\/$/, '') +const ELECTRON_PROFILE = 'e2e-canvas' +const ELECTRON_CDP_PORT = 9225 +const RENDERER_PORT = 5178 +const ELECTRON_CDP_URL = `http://127.0.0.1:${ELECTRON_CDP_PORT}` +const RENDERER_URL = `http://127.0.0.1:${RENDERER_PORT}` +const COMMAND_PALETTE_SHORTCUT = process.platform === 'darwin' ? 'Meta+Shift+P' : 'Control+Shift+P' +const ELECTRON_PROFILE_PATH = join( + homedir(), + 'Library', + 'Application Support', + `xnet-desktop-${ELECTRON_PROFILE}` +) + +test.skip( + ({ browserName }) => browserName !== 'chromium', + 'Electron CDP validation only runs on Chromium' +) + +function spawnElectronDev(): ChildProcess { + return spawn('pnpm', ['exec', 'electron-vite', 'dev'], { + cwd: `${ROOT}/apps/electron`, + env: { + ...process.env, + ELECTRON_CDP_PORT: String(ELECTRON_CDP_PORT), + VITE_PORT: String(RENDERER_PORT), + XNET_PROFILE: ELECTRON_PROFILE, + XNET_TEST_BYPASS: 'true' + }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: true + }) +} + +function killTree(proc: ChildProcess | null): void { + if (!proc) return + + try { + if (proc.pid) { + process.kill(-proc.pid, 'SIGTERM') + return + } + } catch { + // fall through + } + + try { + proc.kill('SIGTERM') + } catch { + // already dead + } +} + +async function waitForCdpReady(timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + try { + const response = await fetch(`${ELECTRON_CDP_URL}/json/version`) + if (response.ok) { + return + } + } catch { + // keep polling + } + + await sleep(500) + } + + throw new Error(`Timed out waiting for Electron CDP endpoint on ${ELECTRON_CDP_URL}`) +} + +async function waitForRendererReady(timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + try { + const response = await fetch(RENDERER_URL) + if (response.ok) { + return + } + } catch { + // keep polling + } + + await sleep(500) + } + + throw new Error(`Timed out waiting for Electron renderer dev server on ${RENDERER_URL}`) +} + +async function waitForElectronPage(browser: Browser, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const pages = browser + .contexts() + .flatMap((context) => context.pages()) + .filter((page) => !page.url().startsWith('devtools://')) + + const page = pages.find((candidate) => candidate.url() !== 'about:blank') + if (page) { + await page.waitForLoadState('domcontentloaded', { timeout: timeoutMs }) + await page.bringToFront() + return page + } + + await sleep(250) + } + + throw new Error('Timed out waiting for the Electron renderer page') +} + +async function advanceOnboardingIfNeeded(page: Page): Promise { + for (let index = 0; index < 4; index += 1) { + const getStartedButton = page.getByRole('button', { name: /Get started with/i }) + if ((await getStartedButton.count()) > 0 && (await getStartedButton.first().isVisible())) { + await getStartedButton.first().click() + await sleep(800) + continue + } + + const createFirstPageButton = page.getByRole('button', { name: /Create your first page/i }) + if ( + (await createFirstPageButton.count()) > 0 && + (await createFirstPageButton.first().isVisible()) + ) { + await createFirstPageButton.first().click() + await sleep(800) + continue + } + + break + } +} + +async function waitForCanvasShell(page: Page): Promise { + await expect(page.getByRole('button', { name: 'Page' })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: /hide minimap/i })).toBeVisible({ + timeout: 30_000 + }) +} + +test.describe('Electron canvas shell', () => { + test.describe.configure({ mode: 'serial' }) + + let electronProc: ChildProcess | null = null + let electronBrowser: Browser | null = null + let electronPage: Page | null = null + + test.beforeAll(async () => { + rmSync(ELECTRON_PROFILE_PATH, { recursive: true, force: true }) + + electronProc = spawnElectronDev() + await waitForCdpReady() + await waitForRendererReady() + electronBrowser = await chromium.connectOverCDP(ELECTRON_CDP_URL) + electronPage = await waitForElectronPage(electronBrowser) + + if (process.env.E2E_DEBUG) { + electronPage.on('console', (message) => { + process.stderr.write(`[electron:console] ${message.type()}: ${message.text()}\n`) + }) + } + + await advanceOnboardingIfNeeded(electronPage) + await waitForCanvasShell(electronPage) + }) + + test.afterAll(async () => { + if (electronBrowser) { + await electronBrowser.close() + } + + killTree(electronProc) + await sleep(1_000) + rmSync(ELECTRON_PROFILE_PATH, { recursive: true, force: true }) + + for (const port of [ELECTRON_CDP_PORT, RENDERER_PORT]) { + try { + execSync(`lsof -ti:${port} 2>/dev/null | xargs kill -9 2>/dev/null`, { stdio: 'ignore' }) + } catch { + // port already clear + } + } + }) + + test('creates page, database, and note objects while keeping the home shell lightweight', async () => { + test.skip(!electronPage, 'Electron page did not initialize') + const page = electronPage! + + await page.getByRole('button', { name: 'Page' }).click({ force: true }) + await expect(page.getByText('Untitled Page')).toBeVisible({ timeout: 30_000 }) + + await page.getByRole('button', { name: 'Database' }).click({ force: true }) + await expect(page.getByText('Untitled Database')).toBeVisible({ timeout: 30_000 }) + + await page.getByRole('button', { name: 'Note' }).click({ force: true }) + await expect(page.getByText('Untitled Note')).toBeVisible({ timeout: 30_000 }) + + await page.keyboard.press(COMMAND_PALETTE_SHORTCUT) + const commandInput = page.getByPlaceholder('Type a command or search...') + await expect(commandInput).toBeVisible({ timeout: 10_000 }) + await commandInput.fill('Create Page') + await page.keyboard.press('Enter') + + await expect(page.getByText('Untitled Page')).toHaveCount(2, { timeout: 30_000 }) + + await page.getByRole('button', { name: /hide minimap/i }).click({ force: true }) + await expect(page.getByRole('button', { name: /show minimap/i })).toBeVisible() + await page.getByRole('button', { name: /show minimap/i }).click({ force: true }) + await expect(page.getByRole('button', { name: /hide minimap/i })).toBeVisible() + + const shellMetrics = await page.evaluate(() => ({ + canvasElements: document.querySelectorAll('canvas').length, + contentEditableElements: document.querySelectorAll('[contenteditable="true"]').length, + tableElements: document.querySelectorAll('table').length + })) + + expect(shellMetrics.canvasElements).toBeGreaterThanOrEqual(2) + expect(shellMetrics.contentEditableElements).toBe(0) + expect(shellMetrics.tableElements).toBe(0) + + await page.screenshot({ + path: `${ROOT}/tmp/playwright/electron-canvas-shell.png`, + fullPage: true + }) + }) +}) From 1fde3aed98ce9fccc106495d36d2d9b381d83bee Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 16:36:02 -0700 Subject: [PATCH 06/42] fix(canvas): stabilize electron shell visibility and e2e coverage - fix Canvas viewport culling invalidation so newly inserted source-backed objects render without requiring camera movement - add lightweight canvas surface instrumentation for Electron automation and extend the Electron canvas spec to handle native rebuilds, renderer targeting, and command/minimap flows reliably - make the devtools FAB offset configurable and move the Electron dev FAB out of the canvas control lane to avoid minimap toggle collisions - update the Canvas V2 plan checklists to reflect the hybrid shell and Electron CDP progress --- apps/electron/src/renderer/main.tsx | 1 + .../02-hybrid-shell-and-renderer-runtime.md | 2 +- ...n-rollout-workbenches-and-release-gates.md | 2 +- docs/plans/plan03_9_83CanvasV2/README.md | 4 +- packages/canvas/src/renderer/Canvas.tsx | 12 +- .../src/provider/DevToolsProvider.tsx | 19 +- tests/e2e/src/electron-canvas.spec.ts | 237 ++++++++++++++++-- 7 files changed, 248 insertions(+), 29 deletions(-) diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 1c2fc975e..6ea68d421 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -262,6 +262,7 @@ async function init() { diff --git a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md index 5ccd60064..9769dc7cd 100644 --- a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md +++ b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md @@ -159,6 +159,6 @@ pnpm dev:stories - [ ] Introduce the Canvas V2 runtime host and make it the primary render entry. - [x] Move the grid and minimap into the default shell path. - [ ] Define explicit responsibilities for background, overview, DOM, and overlay layers. -- [ ] Replace the current custom linked-card shell rendering with runtime-fed object rendering. +- [x] Replace the current custom linked-card shell rendering with runtime-fed object rendering. - [ ] Keep persistent shell chrome minimal and contextual. - [ ] Centralize frame scheduling and redraw ownership. diff --git a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md index ba6bbb294..660bf9c29 100644 --- a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md +++ b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md @@ -169,7 +169,7 @@ Automated validation should include: - [ ] Replace the active Electron canvas path with Canvas V2. - [ ] Build realistic Storybook/workbench scenes for every major object family and density class. - [ ] Add repeatable performance scenes and capture frame/DOM/query metrics. -- [ ] Add Electron CDP e2e coverage for canvas-home workflows and shortcuts. +- [x] Add Electron CDP e2e coverage for canvas-home workflows and shortcuts. - [ ] Add Electron CDP large-scene performance coverage and record thresholds. - [ ] Run manual Electron validation for editing, navigation, collaboration, and shortcuts. - [ ] Document and enforce release gates before web rollout. diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md index d563afa7c..2b9197990 100644 --- a/docs/plans/plan03_9_83CanvasV2/README.md +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -263,7 +263,7 @@ flowchart LR - [ ] Replace the current generic canvas object contract with a typed scene graph. - [x] Add a reusable `MediaAsset`-style node schema for dropped images/files. -- [ ] Replace the current linked-card shell with a hybrid renderer shell. +- [x] Replace the current linked-card shell with a hybrid renderer shell. - [ ] Route the main runtime through chunking, culling, and explicit layer display lists. - [ ] Add universal drop ingestion for internal drags, URLs, text, images, and files. - [ ] Ship live page cards with inline editing and peek behavior. @@ -283,7 +283,7 @@ flowchart LR - [ ] Dropping a URL creates or reuses an `ExternalReference` node and renders the correct fallback chain. - [ ] Dropping an image or file creates a reusable media node and preserves it after reload. - [ ] Pan/zoom remains smooth on large scenes with chunk load/evict active. -- [ ] The background grid and minimap remain outside the main DOM path. +- [x] The background grid and minimap remain outside the main DOM path. - [ ] Far-field objects do not mount rich editors or oversized DOM subtrees. - [ ] Electron CDP tests cover dock creation, command-palette creation, minimap toggling, and focused-surface transitions. - [ ] Large-scene performance runs capture bounded DOM count, frame timing, minimap responsiveness, and query churn. diff --git a/packages/canvas/src/renderer/Canvas.tsx b/packages/canvas/src/renderer/Canvas.tsx index 162ad1550..90d8de2cd 100644 --- a/packages/canvas/src/renderer/Canvas.tsx +++ b/packages/canvas/src/renderer/Canvas.tsx @@ -556,7 +556,7 @@ export const Canvas = forwardRef(function Canvas( height: visibleRect.height + buffer * 2 } return canvas.store.getVisibleNodes(expandedRect) - }, [canvas.store, viewport]) + }, [canvas.store, nodes, viewport]) // PERF-01: Set of visible node IDs for fast edge culling lookup const visibleNodeIds = useMemo(() => new Set(visibleNodes.map((n) => n.id)), [visibleNodes]) @@ -633,6 +633,16 @@ export const Canvas = forwardRef(function Canvas( ref={containerRef} className={className} style={containerStyle} + data-canvas-surface="true" + data-node-count={nodes.length} + data-visible-node-count={visibleNodes.length} + data-edge-count={edges.length} + data-visible-edge-count={visibleEdges.length} + data-viewport-x={viewport.x} + data-viewport-y={viewport.y} + data-viewport-zoom={viewport.zoom} + data-viewport-width={viewport.width} + data-viewport-height={viewport.height} onMouseDown={handleMouseDown} tabIndex={0} // Make container focusable for keyboard shortcuts > diff --git a/packages/devtools/src/provider/DevToolsProvider.tsx b/packages/devtools/src/provider/DevToolsProvider.tsx index 3164ef4dc..eb74e4b32 100644 --- a/packages/devtools/src/provider/DevToolsProvider.tsx +++ b/packages/devtools/src/provider/DevToolsProvider.tsx @@ -89,8 +89,16 @@ function createYDocRegistry( * Floating Action Button for toggling DevTools. * Draggable to reposition anywhere on screen. */ -function DevToolsFab({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { - const [pos, setPos] = useState({ x: 16, y: 16 }) // bottom-right offset +function DevToolsFab({ + isOpen, + onToggle, + initialOffset = { x: 16, y: 16 } +}: { + isOpen: boolean + onToggle: () => void + initialOffset?: { x: number; y: number } +}) { + const [pos, setPos] = useState(initialOffset) // bottom-right offset const dragging = useRef(false) const dragStart = useRef({ x: 0, y: 0, posX: 0, posY: 0 }) const didDrag = useRef(false) @@ -197,6 +205,8 @@ export interface XNetDevToolsProviderProps { consentManager?: any /** Optional storage durability status supplied by the host app */ storageDurability?: StorageDurabilityInfo | null + /** Floating action button offset from the bottom-right corner */ + fabInitialOffset?: { x: number; y: number } } const STORAGE_KEY_OPEN = 'xnet:devtools:open' @@ -252,7 +262,8 @@ export function XNetDevToolsProvider({ maxEvents = DEFAULTS.MAX_EVENTS, telemetryCollector, consentManager, - storageDurability = null + storageDurability = null, + fabInitialOffset = { x: 16, y: 16 } }: XNetDevToolsProviderProps) { const { runtimeStatus, syncManager } = useXNet() const [isOpen, setIsOpenState] = useState(() => loadStoredOpen(defaultOpen)) @@ -465,7 +476,7 @@ export function XNetDevToolsProvider({ {children}
{isOpen && } - + diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts index a5b63786c..5a7bd3996 100644 --- a/tests/e2e/src/electron-canvas.spec.ts +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -10,22 +10,108 @@ const ELECTRON_PROFILE = 'e2e-canvas' const ELECTRON_CDP_PORT = 9225 const RENDERER_PORT = 5178 const ELECTRON_CDP_URL = `http://127.0.0.1:${ELECTRON_CDP_PORT}` -const RENDERER_URL = `http://127.0.0.1:${RENDERER_PORT}` +const RENDERER_URLS = [`http://localhost:${RENDERER_PORT}`, `http://127.0.0.1:${RENDERER_PORT}`] const COMMAND_PALETTE_SHORTCUT = process.platform === 'darwin' ? 'Meta+Shift+P' : 'Control+Shift+P' +const PNPM_BIN = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const ELECTRON_PROFILE_PATH = join( homedir(), 'Library', 'Application Support', `xnet-desktop-${ELECTRON_PROFILE}` ) +const MAX_LOG_LINES = 200 + +const electronStdoutLines: string[] = [] +const electronStderrLines: string[] = [] test.skip( ({ browserName }) => browserName !== 'chromium', 'Electron CDP validation only runs on Chromium' ) +function appendLogLine(buffer: string[], line: string): void { + buffer.push(line) + if (buffer.length > MAX_LOG_LINES) { + buffer.splice(0, buffer.length - MAX_LOG_LINES) + } +} + +function attachLogCollector( + stream: NodeJS.ReadableStream | null, + buffer: string[], + label: string +): void { + if (!stream) { + return + } + + stream.setEncoding('utf8') + + let pending = '' + stream.on('data', (chunk: string) => { + pending += chunk + const lines = pending.split(/\r?\n/) + pending = lines.pop() ?? '' + + for (const rawLine of lines) { + const line = rawLine.trimEnd() + appendLogLine(buffer, line) + + if (process.env.E2E_DEBUG) { + process.stderr.write(`[${label}] ${line}\n`) + } + } + }) + + stream.on('end', () => { + if (!pending) { + return + } + + const line = pending.trimEnd() + appendLogLine(buffer, line) + + if (process.env.E2E_DEBUG) { + process.stderr.write(`[${label}] ${line}\n`) + } + }) +} + +function formatElectronLogs(): string { + const sections = [ + ['stdout', electronStdoutLines], + ['stderr', electronStderrLines] + ] + .filter(([, lines]) => lines.length > 0) + .map(([label, lines]) => `${label}:\n${lines.join('\n')}`) + + return sections.length > 0 ? `\nRecent Electron dev logs:\n${sections.join('\n\n')}` : '' +} + +function logStep(message: string): void { + if (!process.env.E2E_DEBUG) { + return + } + + process.stderr.write(`[electron:e2e] ${message}\n`) +} + +function ensureElectronRuntimeDeps(): void { + if (process.env.SKIP_ELECTRON_DEPS_REBUILD === 'true') { + return + } + + execSync('pnpm --filter xnet-desktop run deps:electron', { + cwd: ROOT, + stdio: process.env.E2E_DEBUG ? 'inherit' : 'pipe' + }) +} + function spawnElectronDev(): ChildProcess { - return spawn('pnpm', ['exec', 'electron-vite', 'dev'], { + electronStdoutLines.length = 0 + electronStderrLines.length = 0 + + const proc = spawn(PNPM_BIN, ['exec', 'electron-vite', 'dev'], { cwd: `${ROOT}/apps/electron`, env: { ...process.env, @@ -35,9 +121,20 @@ function spawnElectronDev(): ChildProcess { XNET_TEST_BYPASS: 'true' }, stdio: ['ignore', 'pipe', 'pipe'], - shell: true, detached: true }) + + attachLogCollector(proc.stdout, electronStdoutLines, 'electron:stdout') + attachLogCollector(proc.stderr, electronStderrLines, 'electron:stderr') + + proc.on('exit', (code, signal) => { + appendLogLine( + electronStderrLines, + `electron-vite exited before test teardown (code=${code ?? 'null'}, signal=${signal ?? 'null'})` + ) + }) + + return proc } function killTree(proc: ChildProcess | null): void { @@ -75,48 +172,57 @@ async function waitForCdpReady(timeoutMs = 120_000): Promise { await sleep(500) } - throw new Error(`Timed out waiting for Electron CDP endpoint on ${ELECTRON_CDP_URL}`) + throw new Error( + `Timed out waiting for Electron CDP endpoint on ${ELECTRON_CDP_URL}${formatElectronLogs()}` + ) } async function waitForRendererReady(timeoutMs = 120_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - try { - const response = await fetch(RENDERER_URL) - if (response.ok) { - return + for (const url of RENDERER_URLS) { + try { + const response = await fetch(url) + if (response.ok) { + return + } + } catch { + // try the next host form } - } catch { - // keep polling } await sleep(500) } - throw new Error(`Timed out waiting for Electron renderer dev server on ${RENDERER_URL}`) + throw new Error( + `Timed out waiting for Electron renderer dev server on ${RENDERER_URLS.join(', ')}${formatElectronLogs()}` + ) } async function waitForElectronPage(browser: Browser, timeoutMs = 60_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - const pages = browser - .contexts() - .flatMap((context) => context.pages()) - .filter((page) => !page.url().startsWith('devtools://')) + const pages = browser.contexts().flatMap((context) => context.pages()) + const page = pages.find((candidate) => + RENDERER_URLS.some((url) => candidate.url().startsWith(url)) + ) - const page = pages.find((candidate) => candidate.url() !== 'about:blank') if (page) { await page.waitForLoadState('domcontentloaded', { timeout: timeoutMs }) - await page.bringToFront() return page } + if (process.env.E2E_DEBUG) { + const urls = pages.map((candidate) => candidate.url() || '') + logStep(`waiting for renderer target, saw: ${urls.join(', ') || ''}`) + } + await sleep(250) } - throw new Error('Timed out waiting for the Electron renderer page') + throw new Error(`Timed out waiting for the Electron renderer page${formatElectronLogs()}`) } async function advanceOnboardingIfNeeded(page: Page): Promise { @@ -149,8 +255,81 @@ async function waitForCanvasShell(page: Page): Promise { }) } +async function logShellDebugState(page: Page, label: string): Promise { + if (!process.env.E2E_DEBUG) { + return + } + + const state = await page.evaluate(async () => { + const store = ( + window as Window & { + __xnetNodeStore?: { + list: (params: { limit: number; offset: number }) => Promise< + Array<{ + id: string + schemaId: string + properties: { title?: unknown } + }> + > + } + } + ).__xnetNodeStore + + const nodes = store ? await store.list({ limit: 50, offset: 0 }) : [] + + return { + bodyText: document.body.innerText, + buttonLabels: Array.from(document.querySelectorAll('button')).map((button) => + button.textContent?.trim() + ), + canvasSurface: (() => { + const surface = document.querySelector('[data-canvas-surface="true"]') + if (!surface) { + return null + } + + return { + nodeCount: surface.dataset.nodeCount ?? null, + visibleNodeCount: surface.dataset.visibleNodeCount ?? null, + edgeCount: surface.dataset.edgeCount ?? null, + visibleEdgeCount: surface.dataset.visibleEdgeCount ?? null, + viewportX: surface.dataset.viewportX ?? null, + viewportY: surface.dataset.viewportY ?? null, + viewportZoom: surface.dataset.viewportZoom ?? null, + viewportWidth: surface.dataset.viewportWidth ?? null, + viewportHeight: surface.dataset.viewportHeight ?? null, + rect: { + width: surface.getBoundingClientRect().width, + height: surface.getBoundingClientRect().height + } + } + })(), + canvasNodes: Array.from(document.querySelectorAll('.canvas-node')).map( + (node) => ({ + id: node.dataset.nodeId ?? null, + type: node.dataset.nodeType ?? null, + lod: node.dataset.lod ?? null, + text: node.innerText, + left: node.style.left, + top: node.style.top, + width: node.style.width, + height: node.style.height + }) + ), + nodes: nodes.map((node) => ({ + id: node.id, + schemaId: node.schemaId, + title: typeof node.properties.title === 'string' ? node.properties.title : null + })) + } + }) + + logStep(`${label}: ${JSON.stringify(state)}`) +} + test.describe('Electron canvas shell', () => { test.describe.configure({ mode: 'serial' }) + test.setTimeout(240_000) let electronProc: ChildProcess | null = null let electronBrowser: Browser | null = null @@ -159,10 +338,17 @@ test.describe('Electron canvas shell', () => { test.beforeAll(async () => { rmSync(ELECTRON_PROFILE_PATH, { recursive: true, force: true }) + logStep('rebuilding Electron native dependencies') + ensureElectronRuntimeDeps() + logStep('spawning Electron dev server') electronProc = spawnElectronDev() + logStep('waiting for CDP endpoint') await waitForCdpReady() + logStep('waiting for renderer dev server') await waitForRendererReady() + logStep('connecting Playwright to Electron over CDP') electronBrowser = await chromium.connectOverCDP(ELECTRON_CDP_URL) + logStep('waiting for Electron renderer page') electronPage = await waitForElectronPage(electronBrowser) if (process.env.E2E_DEBUG) { @@ -171,8 +357,11 @@ test.describe('Electron canvas shell', () => { }) } + logStep('advancing onboarding if needed') await advanceOnboardingIfNeeded(electronPage) + logStep('waiting for canvas shell controls') await waitForCanvasShell(electronPage) + logStep('canvas shell ready') }) test.afterAll(async () => { @@ -197,7 +386,9 @@ test.describe('Electron canvas shell', () => { test.skip(!electronPage, 'Electron page did not initialize') const page = electronPage! + await logShellDebugState(page, 'before-create') await page.getByRole('button', { name: 'Page' }).click({ force: true }) + await logShellDebugState(page, 'after-page-click') await expect(page.getByText('Untitled Page')).toBeVisible({ timeout: 30_000 }) await page.getByRole('button', { name: 'Database' }).click({ force: true }) @@ -214,9 +405,15 @@ test.describe('Electron canvas shell', () => { await expect(page.getByText('Untitled Page')).toHaveCount(2, { timeout: 30_000 }) - await page.getByRole('button', { name: /hide minimap/i }).click({ force: true }) + await page + .getByRole('button', { name: /hide minimap/i }) + .evaluate((button: HTMLButtonElement) => button.click()) + await logShellDebugState(page, 'after-hide-minimap') await expect(page.getByRole('button', { name: /show minimap/i })).toBeVisible() - await page.getByRole('button', { name: /show minimap/i }).click({ force: true }) + await page + .getByRole('button', { name: /show minimap/i }) + .evaluate((button: HTMLButtonElement) => button.click()) + await logShellDebugState(page, 'after-show-minimap') await expect(page.getByRole('button', { name: /hide minimap/i })).toBeVisible() const shellMetrics = await page.evaluate(() => ({ From aa62897d46904cb94dd57d6cfc45a7a0a217db16 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 18:20:40 -0700 Subject: [PATCH 07/42] feat(canvas): add inline page editing surfaces - expose node render context so the shell can gate heavy content by zoom and selection - mount page-backed inline editors for active page and note objects while keeping other cards light - stop drag/open leaks through interactive content and cover it with unit and Electron CDP tests --- .../components/CanvasInlinePageSurface.tsx | 197 ++++++++++++++++++ .../src/renderer/components/CanvasView.tsx | 42 +++- .../05-page-cards-inline-editing-and-peek.md | 8 +- docs/plans/plan03_9_83CanvasV2/README.md | 4 +- .../canvas-navigation-shell.test.tsx | 40 +++- .../__tests__/canvas-node-component.test.tsx | 79 +++++++ packages/canvas/src/index.ts | 7 +- .../canvas/src/nodes/CanvasNodeComponent.tsx | 29 +++ packages/canvas/src/renderer/Canvas.tsx | 55 +++-- tests/e2e/src/electron-canvas.spec.ts | 62 ++++++ 10 files changed, 494 insertions(+), 29 deletions(-) create mode 100644 apps/electron/src/renderer/components/CanvasInlinePageSurface.tsx create mode 100644 packages/canvas/src/__tests__/canvas-node-component.test.tsx diff --git a/apps/electron/src/renderer/components/CanvasInlinePageSurface.tsx b/apps/electron/src/renderer/components/CanvasInlinePageSurface.tsx new file mode 100644 index 000000000..0851f906c --- /dev/null +++ b/apps/electron/src/renderer/components/CanvasInlinePageSurface.tsx @@ -0,0 +1,197 @@ +import type { CanvasNode } from '@xnetjs/canvas' +import type { TaskMentionSuggestion } from '@xnetjs/editor/react' +import { PageSchema } from '@xnetjs/data' +import { + RichTextEditor, + buildTaskMentionSuggestions, + useFileDownload, + useFileUpload, + useImageUpload +} from '@xnetjs/editor/react' +import { + TaskCollectionEmbed, + useEditorExtensionsSafe, + useIdentity, + useNode, + usePageTaskSync, + usePluginRegistryOptional +} from '@xnetjs/react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +type CanvasInlinePageSurfaceProps = { + node: CanvasNode + docId: string + variant: 'page' | 'note' + onOpenDocument?: (docId: string) => void +} + +type EditorExtensions = NonNullable['extensions']> + +function useStableTitle(initialTitle: string, onCommit: (title: string) => Promise) { + const [localTitle, setLocalTitle] = useState(initialTitle) + const isEditingRef = useRef(false) + + useEffect(() => { + if (!isEditingRef.current) { + setLocalTitle(initialTitle) + } + }, [initialTitle]) + + const handleChange = useCallback( + async (event: React.ChangeEvent) => { + const nextTitle = event.target.value + setLocalTitle(nextTitle) + await onCommit(nextTitle) + }, + [onCommit] + ) + + const handleFocus = useCallback(() => { + isEditingRef.current = true + }, []) + + const handleBlur = useCallback(() => { + isEditingRef.current = false + setLocalTitle(initialTitle) + }, [initialTitle]) + + return { + localTitle, + handleChange, + handleFocus, + handleBlur + } +} + +export function CanvasInlinePageSurface({ + node, + docId, + variant, + onOpenDocument +}: CanvasInlinePageSurfaceProps): React.ReactElement { + const { did } = useIdentity() + const onImageUpload = useImageUpload() + const onFileUpload = useFileUpload() + const onFileDownload = useFileDownload() + const { handleTasksChange } = usePageTaskSync({ pageId: docId }) + const editorContributions = useEditorExtensionsSafe() + const pluginRegistry = usePluginRegistryOptional() + const pluginsReady = !pluginRegistry || editorContributions.length > 0 + const pluginExtensions = useMemo( + () => editorContributions.map((contribution) => contribution.extension) as EditorExtensions, + [editorContributions] + ) + const { + data: page, + doc, + loading, + update, + awareness, + presence + } = useNode(PageSchema, docId, { + createIfMissing: { + title: + (node.alias ?? (node.properties.title as string) ?? 'Untitled Page').trim() || + 'Untitled Page' + }, + did: did ?? undefined + }) + + const mentionSuggestions = useMemo( + () => buildTaskMentionSuggestions(presence, did), + [did, presence] + ) + const handleOpenDocument = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + onOpenDocument?.(docId) + }, + [docId, onOpenDocument] + ) + const title = page?.title ?? node.alias ?? (node.properties.title as string) ?? 'Untitled Page' + const commitTitle = useCallback( + async (nextTitle: string) => update({ title: nextTitle }), + [update] + ) + const { localTitle, handleChange, handleFocus, handleBlur } = useStableTitle(title, commitTitle) + + return ( +
+
+
+ +
+ +
+ + {variant === 'note' ? 'Note' : 'Page'} + + +
+
+ +
+ {loading || !doc || !pluginsReady ? ( +
+ Loading page surface... +
+ ) : ( + ( + + )} + /> + )} +
+
+ ) +} diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index 6b06b0805..185400eef 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -2,7 +2,7 @@ * Canvas View - Infinite canvas for spatial visualization */ -import type { CanvasHandle, CanvasNode, Rect } from '@xnetjs/canvas' +import type { CanvasHandle, CanvasNode, CanvasNodeRenderContext, Rect } from '@xnetjs/canvas' import { Canvas, createNode } from '@xnetjs/canvas' import { CanvasSchema, DatabaseSchema, PageSchema } from '@xnetjs/data' import { useNode, useIdentity } from '@xnetjs/react' @@ -27,6 +27,7 @@ import { type LinkedDocType, type LinkedDocumentItem } from '../lib/canvas-shell' +import { CanvasInlinePageSurface } from './CanvasInlinePageSurface' type ViewportSnapshot = { x: number @@ -107,6 +108,30 @@ function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React. ) } +function shouldActivateInlinePageSurface( + node: CanvasNode, + context: CanvasNodeRenderContext, + linkedDocument?: LinkedDocumentItem +): boolean { + const displayType = getCanvasShellDisplayType(node, linkedDocument) + const sourceId = getCanvasShellSourceId(node) + + if (!sourceId) { + return false + } + + if (displayType !== 'page' && displayType !== 'note') { + return false + } + + return ( + context.selected && + context.selectionSize === 1 && + context.lod === 'full' && + context.viewportZoom >= 0.9 + ) +} + export const CanvasView = forwardRef(function CanvasView( { docId, @@ -315,9 +340,22 @@ export const CanvasView = forwardRef(function boxShadow: '0 18px 38px rgba(15, 23, 42, 0.12)', border: '1px solid rgba(148, 163, 184, 0.28)' }} - renderNode={(node) => { + renderNode={(node, context) => { const sourceNodeId = getCanvasShellSourceId(node) const linkedDocument = sourceNodeId ? documentMap.get(sourceNodeId) : undefined + const displayType = getCanvasShellDisplayType(node, linkedDocument) + + if (sourceNodeId && shouldActivateInlinePageSurface(node, context, linkedDocument)) { + return ( + onOpenDocument?.(targetDocId, 'page')} + /> + ) + } + if (shouldRenderCanvasShellCard(node, linkedDocument)) { return renderNodeCard(node, linkedDocument) } diff --git a/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md b/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md index 397996cc6..46aaec08a 100644 --- a/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md +++ b/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md @@ -131,9 +131,9 @@ pnpm --filter @xnetjs/react test ## Step Checklist -- [ ] Add page creation directly on the canvas using real `Page` nodes. -- [ ] Implement page-backed note objects as a display preset, not a separate editor primitive. -- [ ] Define page render modes and mount gates for preview/editing. +- [x] Add page creation directly on the canvas using real `Page` nodes. +- [x] Implement page-backed note objects as a display preset, not a separate editor primitive. +- [x] Define page render modes and mount gates for preview/editing. - [ ] Add center-peek behavior before full route transitions. -- [ ] Preserve full `PageView` open/focus behavior for deep work. +- [x] Preserve full `PageView` open/focus behavior for deep work. - [ ] Validate smooth transitions between preview, peek, editing, and full focus. diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md index 2b9197990..e8cb05bd3 100644 --- a/docs/plans/plan03_9_83CanvasV2/README.md +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -278,13 +278,13 @@ flowchart LR ## Validation Checklist -- [ ] Creating a page on the canvas immediately creates a real `Page` node and supports inline editing. +- [x] Creating a page on the canvas immediately creates a real `Page` node and supports inline editing. - [ ] Creating a database on the canvas immediately creates a real `Database` node and shows a bounded live preview. - [ ] Dropping a URL creates or reuses an `ExternalReference` node and renders the correct fallback chain. - [ ] Dropping an image or file creates a reusable media node and preserves it after reload. - [ ] Pan/zoom remains smooth on large scenes with chunk load/evict active. - [x] The background grid and minimap remain outside the main DOM path. -- [ ] Far-field objects do not mount rich editors or oversized DOM subtrees. +- [x] Far-field objects do not mount rich editors or oversized DOM subtrees. - [ ] Electron CDP tests cover dock creation, command-palette creation, minimap toggling, and focused-surface transitions. - [ ] Large-scene performance runs capture bounded DOM count, frame timing, minimap responsiveness, and query churn. - [ ] Shortcut-driven flows let a keyboard user create, select, group, lock, align, peek, edit, and open objects without excessive pointer travel. diff --git a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx index 65d9e7f90..2dc2f12d2 100644 --- a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx +++ b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx @@ -65,7 +65,15 @@ beforeAll(() => { }) }) -function createCanvasMock() { +function createCanvasMock(overrides: Partial> = {}) { + const base = createCanvasMockBase() + return { + ...base, + ...overrides + } +} + +function createCanvasMockBase() { const viewport = createViewport({ x: 100, y: 80, zoom: 1 }) viewport.width = 800 viewport.height = 600 @@ -169,4 +177,34 @@ describe('Canvas navigation shell', () => { zoom: 1 }) }) + + it('passes render context to full-detail node renderers', () => { + const node = { + id: 'page-1', + type: 'page', + position: { x: 20, y: 40, width: 320, height: 200 }, + properties: { title: 'Canvas Page' } + } + const canvasMock = createCanvasMock() + canvasMock.nodes = [node] + canvasMock.selectedNodeIds = new Set(['page-1']) + canvasMock.viewport.zoom = 1.25 + canvasMock.store.getVisibleNodes = vi.fn(() => [node]) + + mockUseCanvas.mockReturnValue(canvasMock) + + const renderNode = vi.fn(() =>
inline
) + + render() + + expect(renderNode).toHaveBeenCalledWith( + node, + expect.objectContaining({ + selected: true, + lod: 'full', + selectionSize: 1, + viewportZoom: 1.25 + }) + ) + }) }) diff --git a/packages/canvas/src/__tests__/canvas-node-component.test.tsx b/packages/canvas/src/__tests__/canvas-node-component.test.tsx new file mode 100644 index 000000000..bbfc92524 --- /dev/null +++ b/packages/canvas/src/__tests__/canvas-node-component.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { CanvasNodeComponent } from '../nodes/CanvasNodeComponent' + +const TEST_NODE = { + id: 'page-1', + type: 'page' as const, + position: { + x: 10, + y: 20, + width: 320, + height: 220 + }, + properties: { + title: 'Canvas Page' + } +} + +describe('CanvasNodeComponent', () => { + it('treats interactive child regions as selectable but not draggable', () => { + const onSelect = vi.fn() + const onDragStart = vi.fn() + const onDrag = vi.fn() + const onDragEnd = vi.fn() + + render( + +
+ +
+
+ ) + + fireEvent.mouseDown(screen.getByRole('button', { name: 'Edit title' }), { + button: 0, + clientX: 100, + clientY: 120 + }) + fireEvent.mouseMove(window, { clientX: 120, clientY: 140 }) + fireEvent.mouseUp(window) + + expect(onSelect).toHaveBeenCalledWith('page-1', false) + expect(onDragStart).not.toHaveBeenCalled() + expect(onDrag).not.toHaveBeenCalled() + expect(onDragEnd).not.toHaveBeenCalled() + }) + + it('prevents node double-click handlers from firing through interactive child regions', () => { + const onDoubleClick = vi.fn() + + render( + +
+ +
+
+ ) + + fireEvent.doubleClick(screen.getByRole('button', { name: 'Inline editor' })) + + expect(onDoubleClick).not.toHaveBeenCalled() + }) +}) diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index d48410497..865f94dea 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -121,7 +121,12 @@ export { // React components export { Canvas } from './renderer/Canvas' -export type { CanvasProps, CanvasHandle, CanvasRemoteUser } from './renderer/Canvas' +export type { + CanvasProps, + CanvasHandle, + CanvasRemoteUser, + CanvasNodeRenderContext +} from './renderer/Canvas' export { CanvasNodeComponent, calculateLOD } from './nodes/CanvasNodeComponent' export type { CanvasNodeProps, NodeRemoteUser, LODLevel } from './nodes/CanvasNodeComponent' diff --git a/packages/canvas/src/nodes/CanvasNodeComponent.tsx b/packages/canvas/src/nodes/CanvasNodeComponent.tsx index 778bdb1a4..332e16164 100644 --- a/packages/canvas/src/nodes/CanvasNodeComponent.tsx +++ b/packages/canvas/src/nodes/CanvasNodeComponent.tsx @@ -148,6 +148,22 @@ function getNodeTitle(node: CanvasNode): string { return node.alias ?? (node.properties.title as string) ?? node.type ?? 'Untitled' } +function isInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + + if (target.closest('[data-canvas-interactive="true"]')) { + return true + } + + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.isContentEditable + ) +} + /** * Node icon based on type (for compact LOD) */ @@ -260,6 +276,10 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ // Select node onSelect(node.id, e.shiftKey || e.metaKey) + if (isInteractiveTarget(e.target)) { + return + } + // Start drag tracking isDragging.current = true dragStart.current = { x: e.clientX, y: e.clientY } @@ -330,6 +350,11 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ const handleDoubleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation() + + if (isInteractiveTarget(e.target)) { + return + } + onDoubleClick?.(node.id) }, [node.id, onDoubleClick] @@ -359,6 +384,7 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ }} onClick={handleClick} data-node-id={node.id} + data-selected={selected ? 'true' : 'false'} data-lod="placeholder" /> ) @@ -390,6 +416,7 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ }} onClick={handleClick} data-node-id={node.id} + data-selected={selected ? 'true' : 'false'} data-lod="minimal" > @@ -490,6 +518,7 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ onDoubleClick={handleDoubleClick} data-node-id={node.id} data-node-type={node.type} + data-selected={selected ? 'true' : 'false'} data-lod="full" > {/* Content wrapper (clips overflow) */} diff --git a/packages/canvas/src/renderer/Canvas.tsx b/packages/canvas/src/renderer/Canvas.tsx index 90d8de2cd..de00523ed 100644 --- a/packages/canvas/src/renderer/Canvas.tsx +++ b/packages/canvas/src/renderer/Canvas.tsx @@ -21,7 +21,7 @@ import { NavigationTools } from '../components/NavigationTools' import { CanvasEdgeComponent } from '../edges/CanvasEdgeComponent' import { useCanvas } from '../hooks/useCanvas' import { createGridLayer, type GridLayer } from '../layers' -import { CanvasNodeComponent, calculateLOD } from '../nodes/CanvasNodeComponent' +import { CanvasNodeComponent, calculateLOD, type LODLevel } from '../nodes/CanvasNodeComponent' import { handleUndoRedoShortcut, isTextInputLikeElement } from './keyboard-shortcuts' /** Minimal Awareness interface (avoids y-protocols dependency) */ @@ -68,7 +68,7 @@ export interface CanvasProps { /** Initial viewport state */ initialViewport?: { x?: number; y?: number; zoom?: number } /** Custom node renderer */ - renderNode?: (node: CanvasNode) => React.ReactNode + renderNode?: (node: CanvasNode, context: CanvasNodeRenderContext) => React.ReactNode /** Callback when node is double-clicked */ onNodeDoubleClick?: (id: string) => void /** Callback when canvas background is clicked */ @@ -107,6 +107,13 @@ export interface CanvasProps { navigationToolsStyle?: React.CSSProperties } +export interface CanvasNodeRenderContext { + selected: boolean + lod: LODLevel + selectionSize: number + viewportZoom: number +} + /** * WebGL Grid background hook * @@ -683,23 +690,33 @@ export const Canvas = forwardRef(function Canvas( {/* Nodes layer - PERF-01: Only render nodes visible in viewport */} {/* PERF-02: LOD reduces detail at low zoom levels */}
- {visibleNodes.map((node) => ( - - {/* Only render custom content at full LOD for performance */} - {lod === 'full' ? renderNode?.(node) : undefined} - - ))} + {visibleNodes.map((node) => { + const selected = selectedNodeIds.has(node.id) + const renderContext: CanvasNodeRenderContext = { + selected, + lod, + selectionSize: selectedNodeIds.size, + viewportZoom: viewport.zoom + } + + return ( + + {/* Only render custom content at full LOD for performance */} + {lod === 'full' ? renderNode?.(node, renderContext) : undefined} + + ) + })}
{/* Comment overlay (optional - only when canvasNodeId provided) */} diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts index 5a7bd3996..15be9f7a5 100644 --- a/tests/e2e/src/electron-canvas.spec.ts +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -327,6 +327,10 @@ async function logShellDebugState(page: Page, label: string): Promise { logStep(`${label}: ${JSON.stringify(state)}`) } +async function getContentEditableCount(page: Page): Promise { + return page.evaluate(() => document.querySelectorAll('[contenteditable="true"]').length) +} + test.describe('Electron canvas shell', () => { test.describe.configure({ mode: 'serial' }) test.setTimeout(240_000) @@ -431,4 +435,62 @@ test.describe('Electron canvas shell', () => { fullPage: true }) }) + + test('mounts a single inline page editor only for the active canvas object', async () => { + test.skip(!electronPage, 'Electron page did not initialize') + const page = electronPage! + + const firstPageNode = page.locator('.canvas-node[data-node-type="page"]').first() + await expect(firstPageNode).toBeVisible({ timeout: 30_000 }) + + await firstPageNode.click({ + force: true, + position: { x: 40, y: 80 } + }) + const pageSurface = page.locator('[data-canvas-page-surface="true"]').first() + await expect(pageSurface).toBeVisible({ timeout: 30_000 }) + await expect + .poll(async () => getContentEditableCount(page), { + timeout: 15_000 + }) + .toBe(1) + + const titleInput = page.locator('[data-canvas-page-title="true"]').first() + await titleInput.fill('Canvas draft') + + const editor = page.locator('[data-canvas-page-editor="true"] [contenteditable="true"]') + await editor.click() + await page.keyboard.type('Canvas body text') + await expect(pageSurface).toContainText('Canvas body text') + + await page.locator('[data-canvas-surface="true"]').click({ + position: { x: 24, y: 240 }, + force: true + }) + await expect + .poll(async () => getContentEditableCount(page), { + timeout: 15_000 + }) + .toBe(0) + await expect(page.getByText('Canvas draft')).toBeVisible({ timeout: 30_000 }) + + await page + .locator('.canvas-node[data-node-type="page"]') + .first() + .click({ + force: true, + position: { x: 40, y: 80 } + }) + await expect(pageSurface).toContainText('Canvas body text', { timeout: 30_000 }) + await expect + .poll(async () => getContentEditableCount(page), { + timeout: 15_000 + }) + .toBe(1) + + await page.screenshot({ + path: `${ROOT}/tmp/playwright/electron-canvas-inline-page.png`, + fullPage: true + }) + }) }) From 777d4a863c26e3d733949b54cc2ebb63356f13b7 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 19:51:21 -0700 Subject: [PATCH 08/42] feat(canvas): add database preview surfaces - mount bounded database previews for selected canvas database nodes - settle empty-database loading in useDatabase with a regression test - extend Electron CDP coverage for database preview and focus-return flows --- .../CanvasDatabasePreviewSurface.tsx | 340 ++++++++++++++++++ .../src/renderer/components/CanvasView.tsx | 34 ++ .../src/renderer/components/DatabaseView.tsx | 22 +- ...-database-cards-preview-focus-and-split.md | 6 +- docs/plans/plan03_9_83CanvasV2/README.md | 4 +- packages/react/src/hooks/useDatabase.test.tsx | 44 +++ packages/react/src/hooks/useDatabase.ts | 31 +- tests/e2e/src/electron-canvas.spec.ts | 111 +++++- 8 files changed, 562 insertions(+), 30 deletions(-) create mode 100644 apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx diff --git a/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx new file mode 100644 index 000000000..b46dcceaa --- /dev/null +++ b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx @@ -0,0 +1,340 @@ +import type { CanvasNode } from '@xnetjs/canvas' +import type { CellValue, ColumnDefinition } from '@xnetjs/data' +import { DatabaseSchema } from '@xnetjs/data' +import { useDatabase, useDatabaseDoc, useIdentity, useNode } from '@xnetjs/react' +import { Database, LayoutGrid, Plus, Rows3 } from 'lucide-react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +type CanvasDatabasePreviewSurfaceProps = { + node: CanvasNode + docId: string + onOpenDocument?: (docId: string) => void +} + +function useStableTitle(initialTitle: string, onCommit: (title: string) => Promise) { + const [localTitle, setLocalTitle] = useState(initialTitle) + const isEditingRef = useRef(false) + + useEffect(() => { + if (!isEditingRef.current) { + setLocalTitle(initialTitle) + } + }, [initialTitle]) + + const handleChange = useCallback( + async (event: React.ChangeEvent) => { + const nextTitle = event.target.value + setLocalTitle(nextTitle) + await onCommit(nextTitle) + }, + [onCommit] + ) + + const handleFocus = useCallback(() => { + isEditingRef.current = true + }, []) + + const handleBlur = useCallback(() => { + isEditingRef.current = false + setLocalTitle(initialTitle) + }, [initialTitle]) + + return { + localTitle, + handleChange, + handleFocus, + handleBlur + } +} + +function formatCellValue(value: CellValue, column: ColumnDefinition): string { + if (value === null) { + return '—' + } + + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No' + } + + if (typeof value === 'number') { + return String(value) + } + + if (typeof value === 'string') { + if (column.type === 'date') { + const parsed = new Date(value) + if (!Number.isNaN(parsed.getTime())) { + return parsed.toLocaleDateString() + } + } + + if (column.type === 'select') { + const option = + 'options' in column.config + ? column.config.options?.find((entry) => entry.id === value) + : undefined + return option?.name ?? value + } + + return value || '—' + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return '—' + } + + if ('options' in column.config) { + const optionNames = value.map( + (entry) => column.config.options?.find((option) => option.id === entry)?.name ?? entry + ) + return optionNames.join(', ') + } + + return value.join(', ') + } + + if ('name' in value) { + return value.name + } + + if ('start' in value && 'end' in value) { + return `${new Date(value.start).toLocaleDateString()} - ${new Date(value.end).toLocaleDateString()}` + } + + return '—' +} + +export function CanvasDatabasePreviewSurface({ + node, + docId, + onOpenDocument +}: CanvasDatabasePreviewSurfaceProps): React.ReactElement { + const { did } = useIdentity() + const { + data: database, + loading: nodeLoading, + update + } = useNode(DatabaseSchema, docId, { + createIfMissing: { + title: + (node.alias ?? (node.properties.title as string) ?? 'Untitled Database').trim() || + 'Untitled Database' + }, + did: did ?? undefined + }) + const { columns, views, loading: docLoading, createColumn, createView } = useDatabaseDoc(docId) + const { + rows, + loading: rowsLoading, + activeView + } = useDatabase(docId, { + pageSize: 8 + }) + + const orderedColumns = useMemo( + () => [ + ...columns.filter((column) => column.isTitle), + ...columns.filter((column) => !column.isTitle) + ], + [columns] + ) + const previewColumns = useMemo(() => orderedColumns.slice(0, 3), [orderedColumns]) + const previewRows = useMemo(() => rows.slice(0, 5), [rows]) + const rowCount = Math.max( + typeof database?.rowCount === 'number' ? database.rowCount : 0, + rows.length + ) + const title = + database?.title ?? node.alias ?? (node.properties.title as string) ?? 'Untitled Database' + const commitTitle = useCallback( + async (nextTitle: string) => update({ title: nextTitle }), + [update] + ) + const { localTitle, handleChange, handleFocus, handleBlur } = useStableTitle(title, commitTitle) + + const handleOpenDocument = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + onOpenDocument?.(docId) + }, + [docId, onOpenDocument] + ) + + const handleStartTable = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + + if (columns.length === 0) { + const titleColumnId = createColumn({ + name: 'Title', + type: 'text', + config: {}, + isTitle: true, + width: 260 + }) + + if (titleColumnId && views.length === 0) { + createView({ + name: 'Default View', + type: 'table', + visibleColumns: [titleColumnId], + columnWidths: { [titleColumnId]: 260 }, + sorts: [], + filters: null, + groupBy: null + }) + } + + return + } + + if (views.length === 0) { + createView({ + name: 'Default View', + type: 'table', + visibleColumns: columns.map((column) => column.id), + columnWidths: Object.fromEntries( + columns.map((column) => [column.id, column.width ?? (column.isTitle ? 260 : 160)]) + ), + sorts: [], + filters: null, + groupBy: null + }) + } + }, + [columns, createColumn, createView, views.length] + ) + + const activeViewType = activeView?.type ?? database?.defaultView ?? 'table' + const isEmpty = columns.length === 0 + const isLoading = nodeLoading || (!isEmpty && (docLoading || rowsLoading)) + + return ( +
+
+
+ + +
+ + + Database + + + + {activeViewType} + + + + {rowCount} rows + + {columns.length} fields +
+
+ +
+ +
+
+ +
+ {isEmpty ? ( +
+

This database has no fields yet.

+

+ Keep the canvas surface light: start a simple table here, then open the focused + database surface for deeper schema and view work. +

+
+ +
+
+ ) : isLoading ? ( +
+ Loading database preview... +
+ ) : ( +
+
+ {previewColumns.map((column) => ( +
+ {column.name} +
+ ))} +
+ +
+ {previewRows.length > 0 ? ( + previewRows.map((row) => ( +
+ {previewColumns.map((column) => ( +
+ {formatCellValue(row.cells[column.id] ?? null, column)} +
+ ))} +
+ )) + ) : ( +
+ No rows yet. Add a row here or open the full database to keep shaping it. +
+ )} +
+ +
+ + {views.length} view{views.length === 1 ? '' : 's'} + + + Showing {previewRows.length} of {rowCount} + +
+
+ )} +
+
+ ) +} diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index 185400eef..53e1f47d6 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -27,6 +27,7 @@ import { type LinkedDocType, type LinkedDocumentItem } from '../lib/canvas-shell' +import { CanvasDatabasePreviewSurface } from './CanvasDatabasePreviewSurface' import { CanvasInlinePageSurface } from './CanvasInlinePageSurface' type ViewportSnapshot = { @@ -132,6 +133,26 @@ function shouldActivateInlinePageSurface( ) } +function shouldActivateDatabasePreviewSurface( + node: CanvasNode, + context: CanvasNodeRenderContext, + linkedDocument?: LinkedDocumentItem +): boolean { + const displayType = getCanvasShellDisplayType(node, linkedDocument) + const sourceId = getCanvasShellSourceId(node) + + if (!sourceId || displayType !== 'database') { + return false + } + + return ( + context.selected && + context.selectionSize === 1 && + context.lod === 'full' && + context.viewportZoom >= 0.9 + ) +} + export const CanvasView = forwardRef(function CanvasView( { docId, @@ -356,6 +377,19 @@ export const CanvasView = forwardRef(function ) } + if ( + sourceNodeId && + shouldActivateDatabasePreviewSurface(node, context, linkedDocument) + ) { + return ( + onOpenDocument?.(targetDocId, 'database')} + /> + ) + } + if (shouldRenderCanvasShellCard(node, linkedDocument)) { return renderNodeCard(node, linkedDocument) } diff --git a/apps/electron/src/renderer/components/DatabaseView.tsx b/apps/electron/src/renderer/components/DatabaseView.tsx index 49616905f..7e074a62e 100644 --- a/apps/electron/src/renderer/components/DatabaseView.tsx +++ b/apps/electron/src/renderer/components/DatabaseView.tsx @@ -31,6 +31,7 @@ import { useMutate, useQuery } from '@xnetjs/react' +import { useUndoScope } from '@xnetjs/react/internal' import { CommentPopover, CommentsSidebar, @@ -40,7 +41,6 @@ import { MenuSeparator, type CommentThreadData } from '@xnetjs/ui' -import { useUndoScope } from '@xnetjs/react/internal' import { TableView, BoardView, @@ -1598,7 +1598,11 @@ export function DatabaseView({ docId, minimalChrome = false }: DatabaseViewProps if (nodeLoading || databaseDocLoading || rowsLoading || !databaseDoc) { return ( -
+

Loading database...

) @@ -1607,7 +1611,12 @@ export function DatabaseView({ docId, minimalChrome = false }: DatabaseViewProps // Empty state when no columns if (columns.length === 0) { return ( -
+
{/* Toolbar */}
+
{/* Toolbar */}
{ ]) }) }) + + it('settles loading for empty databases without columns', async () => { + const wrapper = createWrapper() + + const { result: storeResult } = renderHook(() => useNodeStore(), { wrapper }) + + await waitFor(() => { + expect(storeResult.current.isReady).toBe(true) + }) + + let databaseId = '' + await act(async () => { + const database = await storeResult.current.store?.create({ + schemaId: DatabaseSchema.schema['@id'], + properties: { title: 'Empty Database' } + }) + databaseId = database?.id ?? '' + }) + + expect(databaseId).not.toBe('') + + const { result } = renderHook( + () => ({ + databaseDoc: useDatabaseDoc(databaseId), + database: useDatabase(databaseId) + }), + { wrapper } + ) + + await waitFor(() => { + expect(result.current.databaseDoc.loading).toBe(false) + expect(result.current.databaseDoc.doc).not.toBeNull() + }) + + await waitFor(() => { + expect(result.current.database.loading).toBe(false) + }) + + expect(result.current.database.columns).toEqual([]) + expect(result.current.database.rows).toEqual([]) + expect(result.current.database.total).toBe(0) + expect(result.current.database.hasMore).toBe(false) + expect(result.current.database.error).toBeNull() + }) }) diff --git a/packages/react/src/hooks/useDatabase.ts b/packages/react/src/hooks/useDatabase.ts index 473379519..9d266511e 100644 --- a/packages/react/src/hooks/useDatabase.ts +++ b/packages/react/src/hooks/useDatabase.ts @@ -155,7 +155,13 @@ export function useDatabase( options: UseDatabaseOptions = {} ): UseDatabaseResult { const { store, isReady } = useNodeStore() - const { columns, views, doc, storageMode } = useDatabaseDoc(databaseId) + const { + columns, + views, + doc, + storageMode, + loading: databaseDocLoading + } = useDatabaseDoc(databaseId) const [rows, setRows] = useState([]) const [total, setTotal] = useState(0) @@ -274,19 +280,32 @@ export function useDatabase( // Initial fetch when columns are loaded useEffect(() => { + if (databaseDocLoading) { + return + } + if (columns.length > 0) { - fetchRows(true) + void fetchRows(true) + return } + + setRows([]) + setTotal(0) + setCursor(undefined) + setHasMore(false) + setError(null) + setLoading(false) + setLoadingMore(false) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [databaseId, columns.length, doc, storageMode]) + }, [databaseId, columns.length, databaseDocLoading, doc, storageMode]) // Refetch when filters/sorts change useEffect(() => { - if (columns.length > 0) { - fetchRows(true) + if (!databaseDocLoading && columns.length > 0) { + void fetchRows(true) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [effectiveFilters, effectiveSorts, search, doc, storageMode]) + }, [databaseDocLoading, effectiveFilters, effectiveSorts, search, doc, storageMode]) // Subscribe to row changes useEffect(() => { diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts index 15be9f7a5..064f16008 100644 --- a/tests/e2e/src/electron-canvas.spec.ts +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -331,6 +331,27 @@ async function getContentEditableCount(page: Page): Promise { return page.evaluate(() => document.querySelectorAll('[contenteditable="true"]').length) } +async function selectCanvasNode(page: Page, selector: string, index = 0): Promise { + const locator = page.locator(selector).nth(index) + await expect(locator).toBeVisible({ timeout: 30_000 }) + await locator.evaluate((element: HTMLElement) => { + const rect = element.getBoundingClientRect() + const clientX = rect.left + Math.min(40, rect.width / 2) + const clientY = rect.top + Math.min(80, rect.height / 2) + const eventInit = { + bubbles: true, + cancelable: true, + button: 0, + clientX, + clientY + } + + element.dispatchEvent(new MouseEvent('mousedown', eventInit)) + element.dispatchEvent(new MouseEvent('mouseup', eventInit)) + element.dispatchEvent(new MouseEvent('click', eventInit)) + }) +} + test.describe('Electron canvas shell', () => { test.describe.configure({ mode: 'serial' }) test.setTimeout(240_000) @@ -440,13 +461,7 @@ test.describe('Electron canvas shell', () => { test.skip(!electronPage, 'Electron page did not initialize') const page = electronPage! - const firstPageNode = page.locator('.canvas-node[data-node-type="page"]').first() - await expect(firstPageNode).toBeVisible({ timeout: 30_000 }) - - await firstPageNode.click({ - force: true, - position: { x: 40, y: 80 } - }) + await selectCanvasNode(page, '.canvas-node[data-node-type="page"]') const pageSurface = page.locator('[data-canvas-page-surface="true"]').first() await expect(pageSurface).toBeVisible({ timeout: 30_000 }) await expect @@ -459,7 +474,7 @@ test.describe('Electron canvas shell', () => { await titleInput.fill('Canvas draft') const editor = page.locator('[data-canvas-page-editor="true"] [contenteditable="true"]') - await editor.click() + await editor.focus() await page.keyboard.type('Canvas body text') await expect(pageSurface).toContainText('Canvas body text') @@ -474,13 +489,7 @@ test.describe('Electron canvas shell', () => { .toBe(0) await expect(page.getByText('Canvas draft')).toBeVisible({ timeout: 30_000 }) - await page - .locator('.canvas-node[data-node-type="page"]') - .first() - .click({ - force: true, - position: { x: 40, y: 80 } - }) + await selectCanvasNode(page, '.canvas-node[data-node-type="page"]') await expect(pageSurface).toContainText('Canvas body text', { timeout: 30_000 }) await expect .poll(async () => getContentEditableCount(page), { @@ -493,4 +502,76 @@ test.describe('Electron canvas shell', () => { fullPage: true }) }) + + test('keeps database preview bounded and supports open-return workflows', async () => { + test.skip(!electronPage, 'Electron page did not initialize') + const page = electronPage! + + await selectCanvasNode(page, '.canvas-node[data-node-type="database"]') + const databaseSurface = page.locator('[data-canvas-database-surface="true"]').first() + await expect(databaseSurface).toBeVisible({ timeout: 30_000 }) + await expect(page.locator('[data-canvas-page-surface="true"]')).toHaveCount(0) + await expect + .poll( + () => + page.evaluate(() => ({ + contentEditableElements: document.querySelectorAll('[contenteditable="true"]').length, + tableElements: document.querySelectorAll('table').length + })), + { + timeout: 15_000 + } + ) + .toEqual({ + contentEditableElements: 0, + tableElements: 0 + }) + + await expect + .poll( + () => + page.evaluate(() => ({ + startTable: document.querySelectorAll('[data-canvas-database-start-table="true"]') + .length, + open: document.querySelectorAll('[data-canvas-database-open="true"]').length + })), + { + timeout: 30_000 + } + ) + .not.toEqual({ + startTable: 0, + open: 0 + }) + + const startTableButton = page.locator('[data-canvas-database-start-table="true"]').first() + if ((await startTableButton.count()) > 0 && (await startTableButton.isVisible())) { + await startTableButton.evaluate((button: HTMLButtonElement) => button.click()) + await expect(databaseSurface).toHaveAttribute('data-canvas-database-empty', 'false', { + timeout: 30_000 + }) + } + + await page + .locator('[data-canvas-database-open="true"]') + .first() + .evaluate((button: HTMLButtonElement) => button.click()) + await expect( + page.locator('[data-database-view="true"][data-database-view-chrome="minimal"]') + ).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: 'Canvas' })).toBeVisible({ timeout: 30_000 }) + + await page.getByRole('button', { name: 'Canvas' }).click({ force: true }) + await expect( + page.locator('[data-database-view="true"][data-database-view-chrome="minimal"]') + ).toHaveCount(0, { + timeout: 30_000 + }) + await expect(databaseSurface).toBeVisible({ timeout: 30_000 }) + + await page.screenshot({ + path: `${ROOT}/tmp/playwright/electron-canvas-database-preview.png`, + fullPage: true + }) + }) }) From a8836eaab035546d36fa92dfbbae38a794e52515 Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 20:05:25 -0700 Subject: [PATCH 09/42] feat(canvas): add dense scene performance harnesses - add a shared seeded-scene fixture for Storybook, renderer tests, and Electron runtime seeding - expose minimap and query diagnostics so CDP tests can assert bounded DOM and hook stability - cover dense-scene minimap, frame-budget, and query-threshold behavior in the Canvas V2 rollout plan --- apps/electron/src/renderer/main.tsx | 80 ++++- ...n-rollout-workbenches-and-release-gates.md | 20 +- docs/plans/plan03_9_83CanvasV2/README.md | 4 +- packages/canvas/src/Canvas.stories.tsx | 92 ++++++ .../canvas-navigation-shell.test.tsx | 44 ++- packages/canvas/src/components/Minimap.tsx | 9 + .../canvas/src/fixtures/performance-scene.ts | 290 ++++++++++++++++++ packages/canvas/src/index.ts | 9 + .../src/provider/DevToolsProvider.tsx | 42 +++ tests/e2e/src/electron-canvas.spec.ts | 274 +++++++++++++++++ 10 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 packages/canvas/src/fixtures/performance-scene.ts diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 6ea68d421..a935fd41a 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -1,7 +1,8 @@ /** * Renderer entry point */ -import { BlobService } from '@xnetjs/data' +import { seedCanvasPerformanceScene } from '@xnetjs/canvas' +import { BlobService, CanvasSchema } from '@xnetjs/data' import { XNetDevToolsProvider, useDevTools } from '@xnetjs/devtools' import { BlobProvider } from '@xnetjs/editor/react' import { identityFromPrivateKey } from '@xnetjs/identity' @@ -11,6 +12,7 @@ import { ConsentManager, TelemetryCollector, TelemetryProvider } from '@xnetjs/t import { ThemeProvider } from '@xnetjs/ui' import React, { useEffect } from 'react' import { createRoot, type Root } from 'react-dom/client' +import * as Y from 'yjs' import { App } from './App' import { createIPCBlobStore } from './lib/ipc-blob-store' import { IPCNodeStorageAdapter } from './lib/ipc-node-storage' @@ -44,6 +46,26 @@ type LocalAPIStore = { } ): Promise delete(id: string): Promise + getDocumentContent(nodeId: string): Promise + setDocumentContent(nodeId: string, content: Uint8Array): Promise +} + +type CanvasTestHarness = { + seedPerformanceScene: (input?: { + canvasId?: string + title?: string + columns?: number + rows?: number + clusterColumns?: number + clusterRows?: number + }) => Promise<{ + canvasId: string + title: string + nodeCount: number + edgeCount: number + bounds: { x: number; y: number; width: number; height: number } + kindCounts: Record + }> } // TODO: In production, load identity from secure storage via IPC @@ -80,6 +102,7 @@ const ipcSyncManager = createIPCSyncManager() declare global { interface Window { __xnetIpcSyncManager?: IPCSyncManager + __xnetCanvasTestHarness?: CanvasTestHarness | null __xnetRoot?: Root __xnetDevToolsToggleCleanup?: (() => void) | null } @@ -87,6 +110,59 @@ declare global { window.__xnetIpcSyncManager = ipcSyncManager +function createCanvasTestHarness(syncManager: IPCSyncManager): CanvasTestHarness { + return { + async seedPerformanceScene(input = {}) { + const store = (window as Window & { __xnetNodeStore?: LocalAPIStore }).__xnetNodeStore + if (!store) { + throw new Error('NodeStore not available') + } + + const canvases = await store.list({ + schemaId: CanvasSchema._schemaId, + limit: 50, + offset: 0 + }) + const targetCanvas = + (input.canvasId ? await store.get(input.canvasId) : null) ?? + [...canvases].sort((left, right) => right.updatedAt - left.updatedAt)[0] + + if (!targetCanvas) { + throw new Error('No canvas available to seed') + } + + syncManager.track(targetCanvas.id, CanvasSchema._schemaId) + const doc = await syncManager.acquire(targetCanvas.id) + const summary = seedCanvasPerformanceScene(doc, { + columns: input.columns, + rows: input.rows, + clusterColumns: input.clusterColumns, + clusterRows: input.clusterRows + }) + const title = input.title ?? `Canvas Performance Scene (${summary.nodeCount} nodes)` + + await store.update(targetCanvas.id, { + properties: { + ...targetCanvas.properties, + title + } + }) + await store.setDocumentContent(targetCanvas.id, Y.encodeStateAsUpdate(doc)) + + return { + canvasId: targetCanvas.id, + title, + nodeCount: summary.nodeCount, + edgeCount: summary.edgeCount, + bounds: summary.bounds, + kindCounts: Object.fromEntries( + Object.entries(summary.kindCounts).map(([key, value]) => [key, value ?? 0]) + ) + } + } + } +} + /** * Component that instruments the sync manager with devtools. * Must be rendered inside XNetDevToolsProvider to access the event bus. @@ -231,6 +307,7 @@ async function init() { window.__xnetDevToolsToggleCleanup = window.xnet.onDevToolsToggle(() => { window.dispatchEvent(new CustomEvent('xnet-devtools-toggle')) }) + window.__xnetCanvasTestHarness = createCanvasTestHarness(ipcSyncManager) const container = document.getElementById('root') if (!container) { @@ -286,6 +363,7 @@ init() if (import.meta.hot) { import.meta.hot.dispose(() => { + window.__xnetCanvasTestHarness = null window.__xnetDevToolsToggleCleanup?.() window.__xnetDevToolsToggleCleanup = null }) diff --git a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md index 660bf9c29..0497d6cb5 100644 --- a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md +++ b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md @@ -61,6 +61,10 @@ Build dedicated Canvas V2 stories that cover: - shape/connector dense canvas, - very large synthetic scene for performance testing. +The current workbench baseline should include a dense seeded-scene story that reuses the same +fixture as the Electron performance harness so DOM-count and minimap regressions can be inspected +without booting the full shell. + ### 3. Performance harnesses Create repeatable scenes for: @@ -80,6 +84,16 @@ Track: - query counts/churn, - memory profile. +Current seeded-scene gate for Electron CDP: + +- shared dense scene fixture with `48 x 36` content objects plus cluster groups (`1,800` total nodes), +- visible home-surface DOM nodes stay under `120` locally and under `180` in CI, +- active query count stays at or below `5`, +- no `contenteditable` or `table` mounts appear on the home canvas, +- minimap hide/show and minimap click navigation both remain responsive, +- requestAnimationFrame pan samples stay under `24ms` average / `50ms` max locally and + `40ms` average / `80ms` max in CI. + The release process should include both: - **Electron CDP e2e flows** for real shell behavior and shortcut ergonomics. @@ -117,6 +131,8 @@ Only after Electron passes the gates should the team adapt the new shell/runtime - Update Storybook workbenches as the scene model changes; do not leave stories wired to the old generic object contract. - Use frame and query devtools during manual validation rather than relying on subjective feel alone. - Record benchmark scenes and release gates in the plan/PR notes so performance claims remain traceable. +- Keep the Storybook large-scene workbench and the Electron seeded-scene helper on the same fixture + contract so any threshold drift can be reproduced quickly. ## Testing and Validation Approach @@ -168,9 +184,9 @@ Automated validation should include: - [ ] Replace the active Electron canvas path with Canvas V2. - [ ] Build realistic Storybook/workbench scenes for every major object family and density class. -- [ ] Add repeatable performance scenes and capture frame/DOM/query metrics. +- [x] Add repeatable performance scenes and capture frame/DOM/query metrics. - [x] Add Electron CDP e2e coverage for canvas-home workflows and shortcuts. -- [ ] Add Electron CDP large-scene performance coverage and record thresholds. +- [x] Add Electron CDP large-scene performance coverage and record thresholds. - [ ] Run manual Electron validation for editing, navigation, collaboration, and shortcuts. - [ ] Document and enforce release gates before web rollout. - [ ] Start web adaptation only after Electron passes the full gate set. diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md index 1871b6e0d..8b426133c 100644 --- a/docs/plans/plan03_9_83CanvasV2/README.md +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -273,7 +273,7 @@ flowchart LR - [ ] Integrate collaboration, undo, comments, and accessibility into the new scene/runtime model. - [ ] Build Storybook and manual validation scenes that reflect the real Canvas V2 object model. - [ ] Add Electron CDP e2e coverage for canvas creation, minimap, command palette, drag/drop, and focused-surface transitions. -- [ ] Add large-scene performance harnesses with DOM-count, query-churn, and frame-budget assertions. +- [x] Add large-scene performance harnesses with DOM-count, query-churn, and frame-budget assertions. - [ ] Validate Electron-first performance and interaction budgets before web rollout. ## Validation Checklist @@ -286,7 +286,7 @@ flowchart LR - [x] The background grid and minimap remain outside the main DOM path. - [x] Far-field objects do not mount rich editors or oversized DOM subtrees. - [x] Electron CDP tests cover dock creation, command-palette creation, minimap toggling, and focused-surface transitions. -- [ ] Large-scene performance runs capture bounded DOM count, frame timing, minimap responsiveness, and query churn. +- [x] Large-scene performance runs capture bounded DOM count, frame timing, minimap responsiveness, and query churn. - [ ] Shortcut-driven flows let a keyboard user create, select, group, lock, align, peek, edit, and open objects without excessive pointer travel. - [ ] Undo/redo behaves correctly across canvas-object moves and inline content edits. - [ ] Collaboration keeps canvas movement/selection awareness separate from page/database editing awareness. diff --git a/packages/canvas/src/Canvas.stories.tsx b/packages/canvas/src/Canvas.stories.tsx index 181262406..3548f51ed 100644 --- a/packages/canvas/src/Canvas.stories.tsx +++ b/packages/canvas/src/Canvas.stories.tsx @@ -2,6 +2,7 @@ import type { CanvasNode } from './types' import type { Meta, StoryObj } from '@storybook/react-vite' import { Badge, Button } from '@xnetjs/ui' import { useRef, useState, type ReactElement } from 'react' +import { createCanvasPerformanceSceneDoc } from './fixtures/performance-scene' import { Canvas, type CanvasHandle } from './renderer/Canvas' import { createCanvasDoc, createEdge, createNode } from './store' @@ -178,3 +179,94 @@ export const Playground: Story = { }, render: () => } + +function LargeSceneWorkbench(): ReactElement { + const [doc] = useState(() => + createCanvasPerformanceSceneDoc( + 'storybook-canvas-performance', + 'Canvas Performance Workbench', + { + columns: 54, + rows: 30, + clusterColumns: 6, + clusterRows: 5 + } + ) + ) + const canvasRef = useRef(null) + + return ( +
+
+
+

Large-scene workbench

+

+ Dense seeded canvas for bounded-DOM, minimap, and frame-budget tuning. +

+
+ +
+ 1,640+ objects + + +
+
+ +
+
+ +
+ + +
+
+ ) +} + +export const LargeScene: Story = { + parameters: { + layout: 'fullscreen', + docs: { + description: { + story: + 'Dense performance workbench for validating large-scene culling, minimap interaction, and grid rendering from the shared seeded-scene fixture.' + } + } + }, + render: () => +} diff --git a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx index 2dc2f12d2..cf2dca6ce 100644 --- a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx +++ b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import React from 'react' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' +import { buildCanvasPerformanceScene } from '../fixtures/performance-scene' import { Canvas } from '../renderer/Canvas' import { createViewport } from '../spatial' @@ -28,7 +29,17 @@ vi.mock('../edges/CanvasEdgeComponent', () => ({ })) vi.mock('../nodes/CanvasNodeComponent', () => ({ - CanvasNodeComponent: ({ children }: { children?: React.ReactNode }) =>
{children}
, + CanvasNodeComponent: ({ + node, + children + }: { + node: { id: string; type: string } + children?: React.ReactNode + }) => ( +
+ {children} +
+ ), calculateLOD: () => 'full' })) @@ -207,4 +218,35 @@ describe('Canvas navigation shell', () => { }) ) }) + + it('keeps dense scenes bounded to visible nodes while retaining minimap diagnostics', () => { + const scene = buildCanvasPerformanceScene({ + columns: 42, + rows: 28, + clusterColumns: 6, + clusterRows: 4 + }) + const visibleNodes = scene.nodes.slice(0, 28) + const canvasMock = createCanvasMock() + canvasMock.nodes = scene.nodes + canvasMock.edges = scene.edges + canvasMock.store.getVisibleNodes = vi.fn(() => visibleNodes) + + mockUseCanvas.mockReturnValue(canvasMock) + + const renderNode = vi.fn(() =>
) + + render() + + const surface = document.querySelector('[data-canvas-surface="true"]') + const minimap = document.querySelector('[data-canvas-minimap="true"]') + + expect(surface?.dataset.nodeCount).toBe(String(scene.nodeCount)) + expect(surface?.dataset.visibleNodeCount).toBe(String(visibleNodes.length)) + expect(Number(surface?.dataset.visibleEdgeCount ?? 0)).toBeLessThanOrEqual(scene.edgeCount) + expect(document.querySelectorAll('.canvas-node')).toHaveLength(visibleNodes.length) + expect(renderNode).toHaveBeenCalledTimes(visibleNodes.length) + expect(minimap?.dataset.canvasMinimapNodeCount).toBe(String(scene.nodeCount)) + expect(minimap?.dataset.canvasMinimapEdgeCount).toBe(String(scene.edgeCount)) + }) }) diff --git a/packages/canvas/src/components/Minimap.tsx b/packages/canvas/src/components/Minimap.tsx index e395b8b73..a1c710375 100644 --- a/packages/canvas/src/components/Minimap.tsx +++ b/packages/canvas/src/components/Minimap.tsx @@ -304,6 +304,10 @@ export function Minimap({ cursor: 'crosshair', userSelect: 'none' }} + data-canvas-minimap="true" + data-canvas-minimap-node-count={nodes.length} + data-canvas-minimap-edge-count={edges.length} + data-canvas-minimap-show-edges={showEdges ? 'true' : 'false'} >
) @@ -348,6 +353,8 @@ export function CollapsibleMinimap({ defaultExpanded = true, ...props }: Collaps bottom: 16, right: 16 }} + data-canvas-minimap-shell="true" + data-canvas-minimap-expanded={isExpanded ? 'true' : 'false'} > {isExpanded ? (
@@ -377,6 +384,7 @@ export function CollapsibleMinimap({ defaultExpanded = true, ...props }: Collaps }} title="Hide minimap" aria-label="Hide minimap" + data-canvas-minimap-toggle="hide" > @@ -400,6 +408,7 @@ export function CollapsibleMinimap({ defaultExpanded = true, ...props }: Collaps }} title="Show minimap" aria-label="Show minimap" + data-canvas-minimap-toggle="show" > diff --git a/packages/canvas/src/fixtures/performance-scene.ts b/packages/canvas/src/fixtures/performance-scene.ts new file mode 100644 index 000000000..2fe7a96b4 --- /dev/null +++ b/packages/canvas/src/fixtures/performance-scene.ts @@ -0,0 +1,290 @@ +/** + * Dense canvas scene fixtures for large-scene workbenches and performance tests. + */ + +import type { CanvasEdge, CanvasNode, CanvasNodeType, Rect } from '../types' +import * as Y from 'yjs' +import { createCanvasDoc, createEdge, createNode } from '../store' + +export interface CanvasPerformanceSceneOptions { + columns?: number + rows?: number + startX?: number + startY?: number + horizontalGap?: number + verticalGap?: number + clusterColumns?: number + clusterRows?: number + clusterGapX?: number + clusterGapY?: number + includeEdges?: boolean + includeGroups?: boolean +} + +export interface CanvasPerformanceSceneSummary { + nodeCount: number + edgeCount: number + bounds: Rect + kindCounts: Partial> +} + +export interface CanvasPerformanceSceneSeedResult extends CanvasPerformanceSceneSummary { + nodes: CanvasNode[] + edges: CanvasEdge[] +} + +const DEFAULT_OPTIONS: Required = { + columns: 36, + rows: 24, + startX: -9000, + startY: -5200, + horizontalGap: 520, + verticalGap: 340, + clusterColumns: 6, + clusterRows: 4, + clusterGapX: 480, + clusterGapY: 360, + includeEdges: true, + includeGroups: true +} + +const CONTENT_NODE_SEQUENCE: CanvasNodeType[] = [ + 'page', + 'database', + 'note', + 'external-reference', + 'media', + 'shape' +] + +function resolveOptions( + options: CanvasPerformanceSceneOptions = {} +): Required { + return { + ...DEFAULT_OPTIONS, + ...options + } +} + +function createClusterGroup( + clusterIndex: number, + clusterRow: number, + clusterColumn: number, + options: Required +): CanvasNode { + const clusterOriginX = + options.startX + + clusterColumn * options.clusterColumns * options.horizontalGap + + clusterColumn * options.clusterGapX + const clusterOriginY = + options.startY + + clusterRow * options.clusterRows * options.verticalGap + + clusterRow * options.clusterGapY + + return createNode( + 'group', + { + x: clusterOriginX - 120, + y: clusterOriginY - 120, + width: options.clusterColumns * options.horizontalGap - options.horizontalGap + 700, + height: options.clusterRows * options.verticalGap - options.verticalGap + 520, + zIndex: -10 + }, + { + title: `Cluster ${clusterIndex + 1}`, + subtitle: 'Large-scene performance fixture' + } + ) +} + +function createContentNode( + index: number, + row: number, + column: number, + options: Required +): CanvasNode { + const kind = CONTENT_NODE_SEQUENCE[index % CONTENT_NODE_SEQUENCE.length] + const clusterColumnOffset = Math.floor(column / options.clusterColumns) * options.clusterGapX + const clusterRowOffset = Math.floor(row / options.clusterRows) * options.clusterGapY + const x = options.startX + column * options.horizontalGap + clusterColumnOffset + const y = options.startY + row * options.verticalGap + clusterRowOffset + + const properties: Record = { + title: `${kind} ${index + 1}`, + subtitle: `Grid ${row + 1}, ${column + 1}` + } + + if (kind === 'shape') { + const shapeTypes = ['rectangle', 'diamond', 'ellipse', 'triangle'] as const + properties.shapeType = shapeTypes[index % shapeTypes.length] + } + + if (kind === 'external-reference') { + properties.url = `https://example.com/workbench/${index + 1}` + } + + if (kind === 'media') { + properties.alt = `Media preview ${index + 1}` + } + + return createNode(kind, { x, y, zIndex: 1 }, properties) +} + +function calculateBounds(nodes: CanvasNode[]): Rect { + if (nodes.length === 0) { + return { x: 0, y: 0, width: 0, height: 0 } + } + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const node of nodes) { + minX = Math.min(minX, node.position.x) + minY = Math.min(minY, node.position.y) + maxX = Math.max(maxX, node.position.x + node.position.width) + maxY = Math.max(maxY, node.position.y + node.position.height) + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + } +} + +function incrementKindCount( + kindCounts: Partial>, + kind: CanvasNodeType +): void { + kindCounts[kind] = (kindCounts[kind] ?? 0) + 1 +} + +export function buildCanvasPerformanceScene( + options: CanvasPerformanceSceneOptions = {} +): CanvasPerformanceSceneSeedResult { + const resolved = resolveOptions(options) + const nodes: CanvasNode[] = [] + const edges: CanvasEdge[] = [] + const kindCounts: Partial> = {} + + if (resolved.includeGroups) { + const clusterColumns = Math.ceil(resolved.columns / resolved.clusterColumns) + const clusterRows = Math.ceil(resolved.rows / resolved.clusterRows) + + for (let clusterRow = 0; clusterRow < clusterRows; clusterRow += 1) { + for (let clusterColumn = 0; clusterColumn < clusterColumns; clusterColumn += 1) { + const clusterIndex = clusterRow * clusterColumns + clusterColumn + const group = createClusterGroup(clusterIndex, clusterRow, clusterColumn, resolved) + nodes.push(group) + incrementKindCount(kindCounts, group.type) + } + } + } + + const gridNodes: CanvasNode[][] = [] + + for (let row = 0; row < resolved.rows; row += 1) { + const rowNodes: CanvasNode[] = [] + + for (let column = 0; column < resolved.columns; column += 1) { + const index = row * resolved.columns + column + const node = createContentNode(index, row, column, resolved) + rowNodes.push(node) + nodes.push(node) + incrementKindCount(kindCounts, node.type) + } + + gridNodes.push(rowNodes) + } + + if (resolved.includeEdges) { + for (let row = 0; row < gridNodes.length; row += 1) { + const rowNodes = gridNodes[row] ?? [] + + for (let column = 0; column < rowNodes.length; column += 1) { + const node = rowNodes[column] + if (!node) { + continue + } + + const rightNode = rowNodes[column + 1] + if (rightNode && column % 2 === 0) { + edges.push( + createEdge(node.id, rightNode.id, { + style: { markerEnd: 'arrow', strokeWidth: 1.25 } + }) + ) + } + + const lowerNode = gridNodes[row + 1]?.[column] + if (lowerNode && column % 3 === 0) { + edges.push( + createEdge(node.id, lowerNode.id, { + style: { markerEnd: 'arrow', strokeDasharray: '6,6', strokeWidth: 1 } + }) + ) + } + } + } + } + + return { + nodes, + edges, + nodeCount: nodes.length, + edgeCount: edges.length, + bounds: calculateBounds(nodes), + kindCounts + } +} + +export function seedCanvasPerformanceScene( + doc: Y.Doc, + options: CanvasPerformanceSceneOptions = {} +): CanvasPerformanceSceneSummary { + const scene = buildCanvasPerformanceScene(options) + const nodesMap = doc.getMap('nodes') + const edgesMap = doc.getMap('edges') + const metadata = doc.getMap('metadata') + + doc.transact(() => { + nodesMap.clear() + edgesMap.clear() + + for (const node of scene.nodes) { + nodesMap.set(node.id, node) + } + + for (const edge of scene.edges) { + edgesMap.set(edge.id, edge) + } + + metadata.set('performanceScene', { + nodeCount: scene.nodeCount, + edgeCount: scene.edgeCount, + kindCounts: scene.kindCounts, + bounds: scene.bounds + }) + metadata.set('performanceSceneSeededAt', Date.now()) + }) + + return { + nodeCount: scene.nodeCount, + edgeCount: scene.edgeCount, + kindCounts: scene.kindCounts, + bounds: scene.bounds + } +} + +export function createCanvasPerformanceSceneDoc( + id: string, + title = 'Canvas Performance Scene', + options: CanvasPerformanceSceneOptions = {} +): Y.Doc { + const doc = createCanvasDoc(id, title) + seedCanvasPerformanceScene(doc, options) + return doc +} diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index 865f94dea..e36eb2797 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -86,6 +86,15 @@ export { type CanvasStoreListener } from './store' +export { + createCanvasPerformanceSceneDoc, + buildCanvasPerformanceScene, + seedCanvasPerformanceScene, + type CanvasPerformanceSceneOptions, + type CanvasPerformanceSceneSummary, + type CanvasPerformanceSceneSeedResult +} from './fixtures/performance-scene' + // Chunked storage (for infinite canvases) export { // Configuration diff --git a/packages/devtools/src/provider/DevToolsProvider.tsx b/packages/devtools/src/provider/DevToolsProvider.tsx index eb74e4b32..020fb29ec 100644 --- a/packages/devtools/src/provider/DevToolsProvider.tsx +++ b/packages/devtools/src/provider/DevToolsProvider.tsx @@ -209,6 +209,24 @@ export interface XNetDevToolsProviderProps { fabInitialOffset?: { x: number; y: number } } +declare global { + interface Window { + __xnetDevToolsDiagnostics?: { + getActiveNodeId: () => string | null + getActiveQueries: () => Array<{ + id: string + type: string + schemaId: string + mode: string + descriptorKey?: string + nodeId?: string + updateCount: number + resultCount: number + }> + } | null + } +} + const STORAGE_KEY_OPEN = 'xnet:devtools:open' const STORAGE_KEY_PANEL = 'xnet:devtools:panel' const STORAGE_KEY_POSITION = 'xnet:devtools:position' @@ -380,6 +398,30 @@ export function XNetDevToolsProvider({ } }, []) + useEffect(() => { + const diagnostics = { + getActiveNodeId: () => activeNodeId, + getActiveQueries: () => + queryTrackerRef.current.getActive().map((query) => ({ + id: query.id, + type: query.type, + schemaId: query.schemaId, + mode: query.mode, + descriptorKey: query.descriptorKey, + nodeId: query.nodeId, + updateCount: query.updateCount, + resultCount: query.resultCount + })) + } + + window.__xnetDevToolsDiagnostics = diagnostics + return () => { + if (window.__xnetDevToolsDiagnostics === diagnostics) { + window.__xnetDevToolsDiagnostics = null + } + } + }, [activeNodeId]) + // Keyboard shortcut: Ctrl/Cmd + Shift + D useEffect(() => { const handler = (e: KeyboardEvent) => { diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts index 064f16008..b5ecf7354 100644 --- a/tests/e2e/src/electron-canvas.spec.ts +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -29,6 +29,10 @@ test.skip( 'Electron CDP validation only runs on Chromium' ) +function getPerformanceBudget(localBudgetMs: number, ciBudgetMs: number): number { + return process.env.CI ? ciBudgetMs : localBudgetMs +} + function appendLogLine(buffer: string[], line: string): void { buffer.push(line) if (buffer.length > MAX_LOG_LINES) { @@ -331,6 +335,174 @@ async function getContentEditableCount(page: Page): Promise { return page.evaluate(() => document.querySelectorAll('[contenteditable="true"]').length) } +async function seedPerformanceScene( + page: Page, + input: { + canvasId?: string + title?: string + columns?: number + rows?: number + clusterColumns?: number + clusterRows?: number + } = {} +): Promise<{ + canvasId: string + title: string + nodeCount: number + edgeCount: number + bounds: { x: number; y: number; width: number; height: number } + kindCounts: Record +}> { + return page.evaluate(async (sceneInput) => { + const harness = ( + window as Window & { + __xnetCanvasTestHarness?: { + seedPerformanceScene: (input?: typeof sceneInput) => Promise<{ + canvasId: string + title: string + nodeCount: number + edgeCount: number + bounds: { x: number; y: number; width: number; height: number } + kindCounts: Record + }> + } + } + ).__xnetCanvasTestHarness + + if (!harness) { + throw new Error('Canvas test harness not available') + } + + return harness.seedPerformanceScene(sceneInput) + }, input) +} + +async function getActiveQueryDiagnostics(page: Page): Promise< + Array<{ + id: string + type: string + schemaId: string + mode: string + descriptorKey?: string + nodeId?: string + updateCount: number + resultCount: number + }> +> { + return page.evaluate(() => { + const diagnostics = ( + window as Window & { + __xnetDevToolsDiagnostics?: { + getActiveNodeId: () => string | null + getActiveQueries: () => Array<{ + id: string + type: string + schemaId: string + mode: string + descriptorKey?: string + nodeId?: string + updateCount: number + resultCount: number + }> + } + } + ).__xnetDevToolsDiagnostics + + return diagnostics ? diagnostics.getActiveQueries() : [] + }) +} + +async function getActiveCanvasNodeId(page: Page): Promise { + return page.evaluate(() => { + const diagnostics = ( + window as Window & { + __xnetDevToolsDiagnostics?: { + getActiveNodeId: () => string | null + } + } + ).__xnetDevToolsDiagnostics + + return diagnostics ? diagnostics.getActiveNodeId() : null + }) +} + +async function getCanvasShellMetrics(page: Page): Promise<{ + nodeCount: number + visibleNodeCount: number + edgeCount: number + visibleEdgeCount: number + viewportX: number + viewportY: number + viewportZoom: number + canvasNodeElements: number + canvasElements: number + contentEditableElements: number + tableElements: number + minimapVisible: boolean +}> { + return page.evaluate(() => { + const surface = document.querySelector('[data-canvas-surface="true"]') + if (!surface) { + throw new Error('Canvas surface not found') + } + + return { + nodeCount: Number(surface.dataset.nodeCount ?? 0), + visibleNodeCount: Number(surface.dataset.visibleNodeCount ?? 0), + edgeCount: Number(surface.dataset.edgeCount ?? 0), + visibleEdgeCount: Number(surface.dataset.visibleEdgeCount ?? 0), + viewportX: Number(surface.dataset.viewportX ?? 0), + viewportY: Number(surface.dataset.viewportY ?? 0), + viewportZoom: Number(surface.dataset.viewportZoom ?? 0), + canvasNodeElements: document.querySelectorAll('.canvas-node').length, + canvasElements: document.querySelectorAll('canvas').length, + contentEditableElements: document.querySelectorAll('[contenteditable="true"]').length, + tableElements: document.querySelectorAll('table').length, + minimapVisible: document.querySelector('[data-canvas-minimap="true"]') !== null + } + }) +} + +async function measureCanvasFrameBudget( + page: Page, + stepCount = 18 +): Promise<{ samples: number; averageMs: number; maxMs: number }> { + return page.evaluate(async (steps) => { + const surface = document.querySelector('[data-canvas-surface="true"]') + if (!surface) { + throw new Error('Canvas surface not found') + } + + const nextFrame = async (): Promise => + await new Promise((resolve) => requestAnimationFrame((timestamp) => resolve(timestamp))) + + const samples: number[] = [] + let previous = await nextFrame() + + for (let index = 0; index < steps; index += 1) { + surface.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaX: index % 2 === 0 ? 140 : -120, + deltaY: index % 3 === 0 ? 90 : -70 + }) + ) + + const current = await nextFrame() + samples.push(current - previous) + previous = current + } + + const total = samples.reduce((sum, value) => sum + value, 0) + return { + samples: samples.length, + averageMs: samples.length > 0 ? total / samples.length : 0, + maxMs: samples.reduce((max, value) => Math.max(max, value), 0) + } + }, stepCount) +} + async function selectCanvasNode(page: Page, selector: string, index = 0): Promise { const locator = page.locator(selector).nth(index) await expect(locator).toBeVisible({ timeout: 30_000 }) @@ -574,4 +746,106 @@ test.describe('Electron canvas shell', () => { fullPage: true }) }) + + test('keeps dense seeded scenes virtualized while minimap and query metrics stay stable', async () => { + test.skip(!electronPage, 'Electron page did not initialize') + const page = electronPage! + await expect + .poll(async () => getActiveCanvasNodeId(page), { + timeout: 15_000 + }) + .not.toBeNull() + const activeCanvasId = await getActiveCanvasNodeId(page) + + const seededScene = await seedPerformanceScene(page, { + canvasId: activeCanvasId ?? undefined, + title: 'Canvas Performance Validation', + columns: 48, + rows: 36, + clusterColumns: 6, + clusterRows: 4 + }) + + await expect + .poll(async () => (await getCanvasShellMetrics(page)).nodeCount, { + timeout: 30_000 + }) + .toBe(seededScene.nodeCount) + + await logShellDebugState(page, 'after-seed-performance-scene') + + await page + .getByRole('button', { name: /hide minimap/i }) + .evaluate((button: HTMLButtonElement) => button.click()) + await expect(page.locator('[data-canvas-minimap="true"]')).toHaveCount(0) + await page + .getByRole('button', { name: /show minimap/i }) + .evaluate((button: HTMLButtonElement) => button.click()) + await expect(page.locator('[data-canvas-minimap="true"]')).toHaveCount(1, { + timeout: 15_000 + }) + + const initialMetrics = await getCanvasShellMetrics(page) + const initialQueries = await getActiveQueryDiagnostics(page) + + expect(initialMetrics.visibleNodeCount).toBeGreaterThan(0) + expect(initialMetrics.visibleNodeCount).toBeLessThan(getPerformanceBudget(120, 180)) + expect(initialMetrics.canvasNodeElements).toBe(initialMetrics.visibleNodeCount) + expect(initialMetrics.edgeCount).toBe(seededScene.edgeCount) + expect(initialMetrics.visibleEdgeCount).toBeLessThanOrEqual(initialMetrics.edgeCount) + expect(initialMetrics.canvasElements).toBeGreaterThanOrEqual(2) + expect(initialMetrics.contentEditableElements).toBe(0) + expect(initialMetrics.tableElements).toBe(0) + expect(initialMetrics.minimapVisible).toBe(true) + expect(initialQueries.length).toBeLessThanOrEqual(5) + + const initialQueryIds = [...initialQueries].map((query) => query.id).sort() + const initialViewport = { + x: initialMetrics.viewportX, + y: initialMetrics.viewportY + } + + await page + .locator('[data-canvas-minimap-canvas="true"]') + .evaluate((canvas: HTMLCanvasElement) => { + const rect = canvas.getBoundingClientRect() + const eventInit = { + bubbles: true, + cancelable: true, + button: 0, + clientX: rect.left + rect.width - 12, + clientY: rect.top + rect.height - 12 + } + + canvas.dispatchEvent(new MouseEvent('mousedown', eventInit)) + canvas.dispatchEvent(new MouseEvent('mouseup', eventInit)) + }) + + await expect + .poll(async () => { + const metrics = await getCanvasShellMetrics(page) + return `${metrics.viewportX}:${metrics.viewportY}` + }) + .not.toBe(`${initialViewport.x}:${initialViewport.y}`) + + const postMinimapMetrics = await getCanvasShellMetrics(page) + const postMinimapQueryIds = [...(await getActiveQueryDiagnostics(page))] + .map((query) => query.id) + .sort() + + expect(postMinimapMetrics.visibleNodeCount).toBeLessThan(getPerformanceBudget(120, 180)) + expect(postMinimapMetrics.canvasNodeElements).toBe(postMinimapMetrics.visibleNodeCount) + expect(postMinimapQueryIds).toEqual(initialQueryIds) + + const frameBudget = await measureCanvasFrameBudget(page, 18) + + expect(frameBudget.samples).toBe(18) + expect(frameBudget.averageMs).toBeLessThan(getPerformanceBudget(24, 40)) + expect(frameBudget.maxMs).toBeLessThan(getPerformanceBudget(50, 80)) + + await page.screenshot({ + path: `${ROOT}/tmp/playwright/electron-canvas-performance-scene.png`, + fullPage: true + }) + }) }) From 93679c48cb925354293ccc6440a66ff83c41566b Mon Sep 17 00:00:00 2001 From: crs48 Date: Mon, 9 Mar 2026 20:20:40 -0700 Subject: [PATCH 10/42] feat(canvas): add keyboard-first canvas shell controls - expand the shared canvas keyboard hook with scoped create, peek, help, selection-step, and nudge shortcuts - add a minimal canvas selection HUD, shortcut help overlay, and canvas-scoped command palette actions in Electron - cover the shortcut layer with canvas unit tests and Electron Playwright typing-guard flows --- apps/electron/src/renderer/App.tsx | 108 ++++++- .../src/renderer/components/CanvasView.tsx | 296 +++++++++++++++++- .../src/renderer/components/PageView.tsx | 6 +- .../08-navigation-shortcuts-and-minimal-ux.md | 14 +- .../canvas-navigation-shell.test.tsx | 136 ++++++++ .../canvas/src/hooks/useCanvasKeyboard.ts | 210 +++++++++++-- packages/canvas/src/index.ts | 1 + .../canvas/src/nodes/CanvasNodeComponent.tsx | 13 +- packages/canvas/src/renderer/Canvas.tsx | 176 ++++++++--- tests/e2e/src/electron-canvas.spec.ts | 90 ++++++ 10 files changed, 956 insertions(+), 94 deletions(-) diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index 47566372b..cfe1466e7 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -12,7 +12,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActionDock } from './components/ActionDock' import { AddSharedDialog, type AddSharedInput } from './components/AddSharedDialog' import { BundledPluginInstaller } from './components/BundledPluginInstaller' -import { CanvasView, type CanvasViewHandle } from './components/CanvasView' +import { + CanvasView, + type CanvasViewCommandState, + type CanvasViewHandle +} from './components/CanvasView' import { DatabaseView } from './components/DatabaseView' import { PageView } from './components/PageView' import { SettingsView } from './components/SettingsView' @@ -44,6 +48,16 @@ type DocumentItem = { const OVERLAY_OPEN_DELAY_MS = 180 const STORIES_ENABLED = import.meta.env.DEV +const MOD_ENTER_SHORTCUT = navigator.platform.includes('Mac') ? '⌘↩' : 'Ctrl+Enter' +const EMPTY_CANVAS_COMMAND_STATE: CanvasViewCommandState = { + selectionCount: 0, + selectedNodeId: null, + selectedSourceId: null, + selectedSourceType: null, + selectedDisplayType: null, + selectedTitle: null, + shortcutHelpOpen: false +} function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) @@ -57,6 +71,9 @@ export function App(): React.ReactElement { requestId: string document: LinkedDocumentItem } | null>(null) + const [canvasCommandState, setCanvasCommandState] = useState( + EMPTY_CANVAS_COMMAND_STATE + ) const [showAddSharedDialog, setShowAddSharedDialog] = useState(false) const [prefilledShareValue, setPrefilledShareValue] = useState('') const { setActiveNodeId } = useDevTools() @@ -353,6 +370,9 @@ export function App(): React.ReactElement { name: 'Create Page', description: 'Create a new page and place it on the canvas', icon: 'file-text', + shortcut: 'P', + group: 'Canvas', + keywords: ['page', 'canvas', 'create'], execute: () => void handleCreateLinkedDocument('page') }, { @@ -360,6 +380,9 @@ export function App(): React.ReactElement { name: 'Create Database', description: 'Create a new database and place it on the canvas', icon: 'database', + shortcut: 'D', + group: 'Canvas', + keywords: ['database', 'canvas', 'create'], execute: () => void handleCreateLinkedDocument('database') }, { @@ -367,8 +390,86 @@ export function App(): React.ReactElement { name: 'Create Canvas Note', description: 'Create a page-backed note and place it on the canvas', icon: 'sparkles', + shortcut: 'N', + group: 'Canvas', + keywords: ['note', 'canvas', 'create'], execute: () => handleCreateCanvasNote() }, + { + id: 'canvas-peek-selection', + name: 'Peek Selected Object', + description: + canvasCommandState.selectedTitle && canvasCommandState.selectionCount === 1 + ? `Center and activate ${canvasCommandState.selectedTitle}` + : 'Center and activate the current canvas selection', + icon: 'eye', + shortcut: 'Enter', + group: 'Canvas', + keywords: ['peek', 'edit', 'selection', 'canvas'], + when: () => shellState.kind === 'canvas-home' && canvasCommandState.selectionCount === 1, + execute: () => { + canvasViewRef.current?.openSelection('peek') + } + }, + { + id: 'canvas-open-selection', + name: 'Open Selected Object', + description: + canvasCommandState.selectedTitle && canvasCommandState.selectionCount === 1 + ? `Open ${canvasCommandState.selectedTitle} in a focused surface` + : 'Open the current canvas selection in a focused surface', + icon: 'external-link', + shortcut: MOD_ENTER_SHORTCUT, + group: 'Canvas', + keywords: ['open', 'focus', 'selection', 'canvas'], + when: () => + shellState.kind === 'canvas-home' && + canvasCommandState.selectionCount === 1 && + Boolean(canvasCommandState.selectedSourceId && canvasCommandState.selectedSourceType), + execute: () => { + canvasViewRef.current?.openSelection('focus') + } + }, + { + id: 'canvas-fit-selection', + name: 'Fit Selected Object', + description: 'Center the current canvas selection in view', + icon: 'layout', + group: 'Canvas', + keywords: ['fit', 'selection', 'zoom', 'canvas'], + when: () => shellState.kind === 'canvas-home' && canvasCommandState.selectionCount > 0, + execute: () => { + canvasViewRef.current?.fitSelection() + } + }, + { + id: 'canvas-clear-selection', + name: 'Clear Selection', + description: 'Clear the current canvas selection', + icon: 'x', + shortcut: 'Esc', + group: 'Canvas', + keywords: ['clear', 'selection', 'canvas'], + when: () => shellState.kind === 'canvas-home' && canvasCommandState.selectionCount > 0, + execute: () => { + canvasViewRef.current?.clearSelection() + } + }, + { + id: 'canvas-shortcut-help', + name: canvasCommandState.shortcutHelpOpen + ? 'Hide Canvas Shortcuts' + : 'Show Canvas Shortcuts', + description: 'Toggle the canvas shortcut help overlay', + icon: 'help-circle', + shortcut: '?', + group: 'Canvas', + keywords: ['help', 'shortcuts', 'canvas', 'hotkeys'], + when: () => shellState.kind === 'canvas-home', + execute: () => { + canvasViewRef.current?.toggleShortcutHelp() + } + }, { id: 'open-settings', name: 'Open Settings', @@ -408,6 +509,7 @@ export function App(): React.ReactElement { handleOpenDocument, handleOpenSettings, handleOpenStories, + canvasCommandState, recentDocuments ] ) @@ -529,6 +631,10 @@ export function App(): React.ReactElement { docId={homeCanvasId} documents={documents} pendingInsert={pendingCanvasInsert} + onCreatePage={() => void handleCreateLinkedDocument('page')} + onCreateDatabase={() => void handleCreateLinkedDocument('database')} + onCreateNote={handleCreateCanvasNote} + onCommandStateChange={setCanvasCommandState} onPendingInsertConsumed={(requestId) => { setPendingCanvasInsert((current) => current?.requestId === requestId ? null : current diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index 53e1f47d6..74ac7835b 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -2,11 +2,17 @@ * Canvas View - Infinite canvas for spatial visualization */ -import type { CanvasHandle, CanvasNode, CanvasNodeRenderContext, Rect } from '@xnetjs/canvas' +import type { + CanvasHandle, + CanvasNode, + CanvasNodeRenderContext, + CanvasSelectionSnapshot, + Rect +} from '@xnetjs/canvas' import { Canvas, createNode } from '@xnetjs/canvas' import { CanvasSchema, DatabaseSchema, PageSchema } from '@xnetjs/data' import { useNode, useIdentity } from '@xnetjs/react' -import { Database, FileText, StickyNote } from 'lucide-react' +import { Command, Database, Eye, FileText, StickyNote, X } from 'lucide-react' import React, { forwardRef, useCallback, @@ -45,11 +51,29 @@ type CanvasViewProps = { } | null onPendingInsertConsumed?: (requestId: string) => void onOpenDocument?: (docId: string, docType: Exclude) => void + onCreatePage?: () => void + onCreateDatabase?: () => void + onCreateNote?: () => void + onCommandStateChange?: (state: CanvasViewCommandState) => void +} + +export type CanvasViewCommandState = { + selectionCount: number + selectedNodeId: string | null + selectedSourceId: string | null + selectedSourceType: Exclude | null + selectedDisplayType: LinkedDocType | 'note' | null + selectedTitle: string | null + shortcutHelpOpen: boolean } export type CanvasViewHandle = { focusLinkedDocument: (docId: string) => ViewportSnapshot | null restoreViewport: (snapshot: ViewportSnapshot) => void + clearSelection: () => void + fitSelection: () => boolean + openSelection: (mode?: 'peek' | 'focus') => boolean + toggleShortcutHelp: (open?: boolean) => void } function getNodeRect(node: CanvasNode): Rect { @@ -159,7 +183,11 @@ export const CanvasView = forwardRef(function documents = [], pendingInsert, onPendingInsertConsumed, - onOpenDocument + onOpenDocument, + onCreatePage, + onCreateDatabase, + onCreateNote, + onCommandStateChange }: CanvasViewProps, ref ): React.ReactElement { @@ -184,11 +212,42 @@ export const CanvasView = forwardRef(function }) const [canvasReady, setCanvasReady] = useState(false) const [hasNodes, setHasNodes] = useState(false) + const [selection, setSelection] = useState({ + nodeIds: [], + edgeIds: [] + }) + const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false) const documentMap = useMemo( () => new Map(documents.map((entry) => [entry.id, entry])), [documents] ) + const selectedCanvasObject = useMemo(() => { + if (!doc || selection.nodeIds.length !== 1) { + return null + } + + const node = doc.getMap('nodes').get(selection.nodeIds[0]) + if (!node) { + return null + } + + const sourceId = getCanvasShellSourceId(node) + const linkedDocument = sourceId ? documentMap.get(sourceId) : undefined + const displayType = getCanvasShellDisplayType(node, linkedDocument) + const sourceType = getCanvasShellSourceType(node, linkedDocument) + const title = + node.alias ?? linkedDocument?.title ?? (node.properties.title as string) ?? 'Untitled' + + return { + node, + sourceId: sourceId ?? null, + sourceType, + displayType, + title + } + }, [doc, documentMap, selection.nodeIds]) + useEffect(() => { if (!doc) return setCanvasReady(true) @@ -294,13 +353,122 @@ export const CanvasView = forwardRef(function canvasRef.current?.setViewportSnapshot(snapshot) }, []) + const clearCanvasSelection = useCallback(() => { + canvasRef.current?.clearSelection() + }, []) + + const fitSelection = useCallback((): boolean => { + if (!selectedCanvasObject) { + return false + } + + canvasRef.current?.fitToRect(getNodeRect(selectedCanvasObject.node), 140) + return true + }, [selectedCanvasObject]) + + const focusSelectionSurface = useCallback( + (sourceId: string, displayType: LinkedDocType | 'note') => { + window.requestAnimationFrame(() => { + const titleSelector = + displayType === 'database' + ? `[data-canvas-source-id="${sourceId}"] [data-canvas-database-title="true"]` + : `[data-canvas-source-id="${sourceId}"] [data-canvas-page-title="true"]` + const target = document.querySelector(titleSelector) + target?.focus() + if (target instanceof HTMLInputElement) { + target.select() + } + }) + }, + [] + ) + + const openSelection = useCallback( + (mode: 'peek' | 'focus' = 'focus'): boolean => { + if (!selectedCanvasObject) { + return false + } + + if (mode === 'peek') { + const didFit = fitSelection() + + if (selectedCanvasObject.sourceId) { + focusSelectionSurface(selectedCanvasObject.sourceId, selectedCanvasObject.displayType) + } + + return didFit + } + + if (!selectedCanvasObject.sourceId || !selectedCanvasObject.sourceType) { + return false + } + + onOpenDocument?.(selectedCanvasObject.sourceId, selectedCanvasObject.sourceType) + return true + }, + [fitSelection, focusSelectionSurface, onOpenDocument, selectedCanvasObject] + ) + + const toggleShortcutHelp = useCallback((open?: boolean) => { + setShortcutHelpOpen((current) => (typeof open === 'boolean' ? open : !current)) + }, []) + + const handleDismissTransientUi = useCallback((): boolean => { + if (!shortcutHelpOpen) { + return false + } + + setShortcutHelpOpen(false) + return true + }, [shortcutHelpOpen]) + + const handleCreateObject = useCallback( + (kind: 'page' | 'database' | 'note') => { + if (kind === 'page') { + onCreatePage?.() + return + } + + if (kind === 'database') { + onCreateDatabase?.() + return + } + + onCreateNote?.() + }, + [onCreateDatabase, onCreateNote, onCreatePage] + ) + + useEffect(() => { + onCommandStateChange?.({ + selectionCount: selection.nodeIds.length, + selectedNodeId: selectedCanvasObject?.node.id ?? null, + selectedSourceId: selectedCanvasObject?.sourceId ?? null, + selectedSourceType: selectedCanvasObject?.sourceType ?? null, + selectedDisplayType: selectedCanvasObject?.displayType ?? null, + selectedTitle: selectedCanvasObject?.title ?? null, + shortcutHelpOpen + }) + }, [onCommandStateChange, selectedCanvasObject, selection.nodeIds.length, shortcutHelpOpen]) + useImperativeHandle( ref, () => ({ focusLinkedDocument, - restoreViewport + restoreViewport, + clearSelection: clearCanvasSelection, + fitSelection, + openSelection, + toggleShortcutHelp }), - [focusLinkedDocument, restoreViewport] + [ + clearCanvasSelection, + fitSelection, + focusLinkedDocument, + openSelection, + restoreViewport, + toggleShortcutHelp + ] ) if (loading || !doc) { @@ -325,6 +493,119 @@ export const CanvasView = forwardRef(function {canvas?.title || 'Workspace Canvas'}
+ {selection.nodeIds.length > 0 ? ( +
+
+ + {selectedCanvasObject + ? `${selectedCanvasObject.displayType === 'note' ? 'Note' : selectedCanvasObject.displayType === 'database' ? 'Database' : 'Page'} · ${selectedCanvasObject.title}` + : `${selection.nodeIds.length} selected`} + + + {selectedCanvasObject ? ( + <> + + + + ) : null} + + +
+
+ ) : null} + + {shortcutHelpOpen ? ( +
+
+
+
+

Canvas shortcuts

+

+ Keep the chrome quiet. Create, select, edit, and open directly from the board. +

+
+ + +
+ +
+ {[ + ['P / D / N', 'Create page, database, or note'], + ['Tab', 'Step through canvas objects'], + ['Arrow keys', 'Pan the board or nudge the selection'], + ['Enter', 'Peek or edit the selected object'], + ['Mod+Enter', 'Open the focused page or database view'], + ['Mod+Shift+P', 'Open the command palette'], + ['Mod+1 / Mod+0', 'Fit content or reset the camera'], + ['Esc', 'Dismiss help or clear the selection'] + ].map(([shortcut, description]) => ( +
+ {description} + + {shortcut} + +
+ ))} +
+
+
+ ) : null} + {!hasNodes ? (
@@ -352,6 +633,11 @@ export const CanvasView = forwardRef(function showNavigationTools navigationToolsPosition="bottom-right" navigationToolsShowZoomLabel={false} + onSelectionChange={setSelection} + onCreateObject={handleCreateObject} + onOpenSelection={openSelection} + onToggleShortcutHelp={toggleShortcutHelp} + onDismissTransientUi={handleDismissTransientUi} navigationToolsStyle={{ bottom: 24, right: 24, diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index 881813f02..6cece9713 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -716,7 +716,11 @@ export function PageView({ docId, minimalChrome = false }: PageViewProps) { } return ( -
+
{ expect(minimap?.dataset.canvasMinimapNodeCount).toBe(String(scene.nodeCount)) expect(minimap?.dataset.canvasMinimapEdgeCount).toBe(String(scene.edgeCount)) }) + + it('dispatches canvas creation, help, and selection-open shortcuts when focused', () => { + const nodes = [ + { + id: 'page-1', + type: 'page', + position: { x: 20, y: 40, width: 320, height: 200 }, + properties: { title: 'Canvas Page' } + }, + { + id: 'page-2', + type: 'page', + position: { x: 420, y: 40, width: 320, height: 200 }, + properties: { title: 'Canvas Page 2' } + } + ] + const canvasMock = createCanvasMock() + canvasMock.nodes = nodes + canvasMock.selectedNodeIds = new Set(['page-1']) + canvasMock.store.getVisibleNodes = vi.fn(() => nodes) + + mockUseCanvas.mockReturnValue(canvasMock) + + const onCreateObject = vi.fn() + const onOpenSelection = vi.fn() + const onToggleShortcutHelp = vi.fn() + + render( + + ) + + const surface = document.querySelector('[data-canvas-surface="true"]') + surface?.focus() + + fireEvent.keyDown(window, { key: 'Tab' }) + fireEvent.keyDown(window, { key: 'P' }) + fireEvent.keyDown(window, { key: '/', shiftKey: true }) + fireEvent.keyDown(window, { key: 'Enter', metaKey: true }) + + expect(canvasMock.selectNode).toHaveBeenCalledWith('page-2') + expect(onCreateObject).toHaveBeenCalledWith('page') + expect(onToggleShortcutHelp).toHaveBeenCalledOnce() + expect(onOpenSelection).toHaveBeenCalledWith('focus') + }) + + it('nudges the current selection instead of panning when arrow shortcuts are used', () => { + const selectedNode = { + id: 'page-1', + type: 'page', + position: { x: 20, y: 40, width: 320, height: 200 }, + properties: { title: 'Canvas Page' } + } + const canvasMock = createCanvasMock() + canvasMock.nodes = [selectedNode] + canvasMock.selectedNodeIds = new Set(['page-1']) + canvasMock.store.getVisibleNodes = vi.fn(() => [selectedNode]) + canvasMock.store.getNode = vi.fn(() => selectedNode) + + mockUseCanvas.mockReturnValue(canvasMock) + + render() + + const surface = document.querySelector('[data-canvas-surface="true"]') + surface?.focus() + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + fireEvent.keyDown(window, { key: 'ArrowDown', shiftKey: true }) + + expect(canvasMock.updateNodePositions).toHaveBeenNthCalledWith(1, [ + { + id: 'page-1', + position: { + x: 36, + y: 40 + } + } + ]) + expect(canvasMock.updateNodePositions).toHaveBeenNthCalledWith(2, [ + { + id: 'page-1', + position: { + x: 20, + y: 72 + } + } + ]) + expect(canvasMock.pan).not.toHaveBeenCalled() + }) + + it('keeps single-key shortcuts disabled while typing inside an inline surface', () => { + const selectedNode = { + id: 'page-1', + type: 'page', + position: { x: 20, y: 40, width: 320, height: 200 }, + properties: { title: 'Canvas Page' } + } + const canvasMock = createCanvasMock() + canvasMock.nodes = [selectedNode] + canvasMock.selectedNodeIds = new Set(['page-1']) + canvasMock.store.getVisibleNodes = vi.fn(() => [selectedNode]) + + mockUseCanvas.mockReturnValue(canvasMock) + + const onCreateObject = vi.fn() + const onToggleShortcutHelp = vi.fn() + + render( + ( + + )} + /> + ) + + const input = screen.getByRole('textbox', { name: 'Canvas title' }) + input.focus() + + fireEvent.keyDown(window, { key: 'P' }) + fireEvent.keyDown(window, { key: '/', shiftKey: true }) + + expect(onCreateObject).not.toHaveBeenCalled() + expect(onToggleShortcutHelp).not.toHaveBeenCalled() + }) }) diff --git a/packages/canvas/src/hooks/useCanvasKeyboard.ts b/packages/canvas/src/hooks/useCanvasKeyboard.ts index 8a9ceb75d..ab76bd45e 100644 --- a/packages/canvas/src/hooks/useCanvasKeyboard.ts +++ b/packages/canvas/src/hooks/useCanvasKeyboard.ts @@ -1,62 +1,132 @@ /** * Canvas Keyboard Hook * - * Keyboard shortcuts for canvas navigation: + * Keyboard shortcuts for canvas navigation and Canvas V2 object flows: * - Ctrl/Cmd + Plus: Zoom in * - Ctrl/Cmd + Minus: Zoom out * - Ctrl/Cmd + 0: Reset view * - Ctrl/Cmd + 1: Fit to content - * - Arrow keys: Pan viewport + * - Arrow keys: Pan viewport or nudge selection + * - Tab / Shift+Tab: Step selection + * - P / D / N: Create page, database, note + * - Enter / Ctrl+Enter: Peek or open selection + * - ?: Toggle shortcut help */ -import type { Rect } from '../types' -import { useEffect, useCallback } from 'react' +import type { Point, Rect } from '../types' +import type { RefObject } from 'react' +import { useCallback, useEffect } from 'react' +import { isTextInputLikeElement } from '../renderer/keyboard-shortcuts' import { Viewport } from '../spatial/index' // ─── Types ──────────────────────────────────────────────────────────────────── +export type CanvasCreationShortcut = 'page' | 'database' | 'note' + +export type CanvasOpenShortcutMode = 'peek' | 'focus' + export interface UseCanvasKeyboardOptions { + /** Canvas surface element used to scope shortcuts */ + containerRef: RefObject /** Current viewport state */ viewport: Viewport /** Bounds of all canvas content (for fit-to-content) */ canvasBounds: Rect | null /** Callback when viewport should change */ onViewportChange: (changes: { x?: number; y?: number; zoom?: number }) => void + /** Callback when the current selection should be nudged */ + onNudgeSelection?: (delta: Point) => void + /** Callback when the current selection should be deleted */ + onDeleteSelection?: () => void + /** Callback when all canvas objects should be selected */ + onSelectAll?: () => void + /** Callback when the current selection should be cleared */ + onClearSelection?: () => void + /** Callback for keyboard-only selection stepping */ + onStepSelection?: (direction: -1 | 1) => void + /** Callback for single-key object creation */ + onCreateObject?: (kind: CanvasCreationShortcut) => void + /** Callback for peek/open actions on the current selection */ + onOpenSelection?: (mode: CanvasOpenShortcutMode) => void + /** Callback for toggling the shortcut help overlay */ + onToggleShortcutHelp?: () => void + /** Callback for dismissing transient canvas UI such as help overlays */ + onDismissTransientUi?: () => boolean | void /** Whether keyboard shortcuts are enabled */ enabled?: boolean + /** Number of selected nodes on the canvas */ + selectedNodeCount?: number /** Pan amount per arrow key press (in screen pixels at zoom 1) */ panAmount?: number + /** Nudge amount per arrow key press in canvas coordinates */ + nudgeAmount?: number + /** Maximum zoom level */ + maxZoom?: number + /** Minimum zoom level */ + minZoom?: number } // ─── Hook ───────────────────────────────────────────────────────────────────── export function useCanvasKeyboard({ + containerRef, viewport, canvasBounds, onViewportChange, + onNudgeSelection, + onDeleteSelection, + onSelectAll, + onClearSelection, + onStepSelection, + onCreateObject, + onOpenSelection, + onToggleShortcutHelp, + onDismissTransientUi, enabled = true, - panAmount = 50 -}: UseCanvasKeyboardOptions) { + selectedNodeCount = 0, + panAmount = 50, + nudgeAmount = 16, + maxZoom = 4, + minZoom = 0.1 +}: UseCanvasKeyboardOptions): void { const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (!enabled) return - // Don't activate if user is typing in an input - const target = e.target as HTMLElement - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target.isContentEditable - ) { + const container = containerRef.current + if (!container) return + + const activeElement = document.activeElement + if (!container.contains(activeElement)) { return } + const isTyping = isTextInputLikeElement(activeElement) const isMod = e.metaKey || e.ctrlKey + const normalizedKey = e.key.toLowerCase() + + if (e.key === 'Escape') { + const dismissed = onDismissTransientUi?.() + if (dismissed) { + e.preventDefault() + return + } + + if (selectedNodeCount > 0) { + e.preventDefault() + onClearSelection?.() + } + return + } + + if (isTyping) { + return + } // Zoom in: Ctrl/Cmd + Plus or Ctrl/Cmd + = if (isMod && (e.key === '+' || e.key === '=' || e.code === 'Equal')) { e.preventDefault() - const newZoom = Math.min(viewport.zoom * 1.5, 4) + const newZoom = Math.min(viewport.zoom * 1.5, maxZoom) onViewportChange({ zoom: newZoom }) return } @@ -64,7 +134,7 @@ export function useCanvasKeyboard({ // Zoom out: Ctrl/Cmd + Minus if (isMod && (e.key === '-' || e.code === 'Minus')) { e.preventDefault() - const newZoom = Math.max(viewport.zoom / 1.5, 0.1) + const newZoom = Math.max(viewport.zoom / 1.5, minZoom) onViewportChange({ zoom: newZoom }) return } @@ -94,31 +164,117 @@ export function useCanvasKeyboard({ return } - // Arrow key panning (when no modifier) - if (!isMod && !e.shiftKey && !e.altKey) { - const scaledPanAmount = panAmount / viewport.zoom + if (!isMod && !e.altKey && normalizedKey === 'tab' && onStepSelection) { + e.preventDefault() + onStepSelection(e.shiftKey ? -1 : 1) + return + } + + if (!isMod && !e.altKey && !e.shiftKey) { + if (normalizedKey === 'p') { + e.preventDefault() + onCreateObject?.('page') + return + } + + if (normalizedKey === 'd') { + e.preventDefault() + onCreateObject?.('database') + return + } + + if (normalizedKey === 'n') { + e.preventDefault() + onCreateObject?.('note') + return + } + } + + if (!isMod && ((e.shiftKey && e.key === '?') || (e.shiftKey && e.key === '/'))) { + e.preventDefault() + onToggleShortcutHelp?.() + return + } + + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeCount > 0) { + e.preventDefault() + onDeleteSelection?.() + return + } + + if (isMod && normalizedKey === 'a') { + e.preventDefault() + onSelectAll?.() + return + } + + if (e.key === 'Enter' && selectedNodeCount > 0) { + e.preventDefault() + onOpenSelection?.(isMod ? 'focus' : 'peek') + return + } + + if (!isMod && !e.altKey) { + const baseAmount = e.shiftKey ? nudgeAmount * 2 : nudgeAmount + const scaledPanAmount = (e.shiftKey ? panAmount * 2 : panAmount) / viewport.zoom switch (e.key) { case 'ArrowUp': e.preventDefault() - onViewportChange({ y: viewport.y - scaledPanAmount }) - break + if (selectedNodeCount > 0 && onNudgeSelection) { + onNudgeSelection({ x: 0, y: -baseAmount }) + } else { + onViewportChange({ y: viewport.y - scaledPanAmount }) + } + return case 'ArrowDown': e.preventDefault() - onViewportChange({ y: viewport.y + scaledPanAmount }) - break + if (selectedNodeCount > 0 && onNudgeSelection) { + onNudgeSelection({ x: 0, y: baseAmount }) + } else { + onViewportChange({ y: viewport.y + scaledPanAmount }) + } + return case 'ArrowLeft': e.preventDefault() - onViewportChange({ x: viewport.x - scaledPanAmount }) - break + if (selectedNodeCount > 0 && onNudgeSelection) { + onNudgeSelection({ x: -baseAmount, y: 0 }) + } else { + onViewportChange({ x: viewport.x - scaledPanAmount }) + } + return case 'ArrowRight': e.preventDefault() - onViewportChange({ x: viewport.x + scaledPanAmount }) - break + if (selectedNodeCount > 0 && onNudgeSelection) { + onNudgeSelection({ x: baseAmount, y: 0 }) + } else { + onViewportChange({ x: viewport.x + scaledPanAmount }) + } + return } } }, - [enabled, viewport, canvasBounds, onViewportChange, panAmount] + [ + canvasBounds, + containerRef, + enabled, + maxZoom, + minZoom, + nudgeAmount, + onClearSelection, + onCreateObject, + onDeleteSelection, + onDismissTransientUi, + onNudgeSelection, + onOpenSelection, + onSelectAll, + onStepSelection, + onToggleShortcutHelp, + onViewportChange, + panAmount, + selectedNodeCount, + viewport + ] ) useEffect(() => { diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index e36eb2797..dcff5473e 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -133,6 +133,7 @@ export { Canvas } from './renderer/Canvas' export type { CanvasProps, CanvasHandle, + CanvasSelectionSnapshot, CanvasRemoteUser, CanvasNodeRenderContext } from './renderer/Canvas' diff --git a/packages/canvas/src/nodes/CanvasNodeComponent.tsx b/packages/canvas/src/nodes/CanvasNodeComponent.tsx index 332e16164..9f025e61a 100644 --- a/packages/canvas/src/nodes/CanvasNodeComponent.tsx +++ b/packages/canvas/src/nodes/CanvasNodeComponent.tsx @@ -164,6 +164,10 @@ function isInteractiveTarget(target: EventTarget | null): boolean { ) } +function focusCanvasSurface(nodeElement: HTMLDivElement | null): void { + nodeElement?.closest('[data-canvas-surface="true"]')?.focus() +} + /** * Node icon based on type (for compact LOD) */ @@ -262,6 +266,7 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation() + focusCanvasSurface(nodeRef.current) onSelect(node.id, e.shiftKey || e.metaKey) }, [node.id, onSelect] @@ -280,6 +285,12 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ return } + focusCanvasSurface(nodeRef.current) + + if (node.locked) { + return + } + // Start drag tracking isDragging.current = true dragStart.current = { x: e.clientX, y: e.clientY } @@ -504,7 +515,7 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({ : hasRemotePresence ? `0 0 0 2px ${presenceColor}33` : '0 1px 3px rgba(0,0,0,0.1)', - cursor: 'move', + cursor: node.locked ? 'default' : 'move', userSelect: 'none', overflow: 'visible' } diff --git a/packages/canvas/src/renderer/Canvas.tsx b/packages/canvas/src/renderer/Canvas.tsx index de00523ed..bf113de49 100644 --- a/packages/canvas/src/renderer/Canvas.tsx +++ b/packages/canvas/src/renderer/Canvas.tsx @@ -20,6 +20,7 @@ import { CollapsibleMinimap } from '../components/Minimap' import { NavigationTools } from '../components/NavigationTools' import { CanvasEdgeComponent } from '../edges/CanvasEdgeComponent' import { useCanvas } from '../hooks/useCanvas' +import { useCanvasKeyboard } from '../hooks/useCanvasKeyboard' import { createGridLayer, type GridLayer } from '../layers' import { CanvasNodeComponent, calculateLOD, type LODLevel } from '../nodes/CanvasNodeComponent' import { handleUndoRedoShortcut, isTextInputLikeElement } from './keyboard-shortcuts' @@ -58,6 +59,13 @@ export interface CanvasHandle { getViewportSnapshot: () => { x: number; y: number; zoom: number } /** Restore a previous viewport state */ setViewportSnapshot: (snapshot: { x: number; y: number; zoom: number }) => void + /** Clear the current selection */ + clearSelection: () => void +} + +export interface CanvasSelectionSnapshot { + nodeIds: string[] + edgeIds: string[] } export interface CanvasProps { @@ -73,6 +81,16 @@ export interface CanvasProps { onNodeDoubleClick?: (id: string) => void /** Callback when canvas background is clicked */ onBackgroundClick?: () => void + /** Callback when the canvas selection changes */ + onSelectionChange?: (selection: CanvasSelectionSnapshot) => void + /** Callback when the user triggers a canvas creation shortcut */ + onCreateObject?: (kind: 'page' | 'database' | 'note') => void + /** Callback when the user triggers a selection open/peek shortcut */ + onOpenSelection?: (mode: 'peek' | 'focus') => void + /** Callback when the user toggles canvas shortcut help */ + onToggleShortcutHelp?: () => void + /** Callback when transient canvas UI should be dismissed before clearing selection */ + onDismissTransientUi?: () => boolean | void /** Yjs Awareness instance for presence (optional) */ awareness?: AwarenessLike | null /** CSS class name */ @@ -196,6 +214,11 @@ export const Canvas = forwardRef(function Canvas( renderNode, onNodeDoubleClick, onBackgroundClick, + onSelectionChange, + onCreateObject, + onOpenSelection, + onToggleShortcutHelp, + onDismissTransientUi, awareness, className, style, @@ -246,19 +269,6 @@ export const Canvas = forwardRef(function Canvas( zoom: canvas.viewport.zoom }) - // Expose imperative methods via ref - useImperativeHandle( - ref, - () => ({ - fitToContent: (padding?: number) => canvas.fitToContent(padding), - fitToRect: (rect: Rect, padding?: number) => canvas.fitToRect(rect, padding), - resetView: () => canvas.resetView(), - getViewportSnapshot: () => canvas.getViewportSnapshot(), - setViewportSnapshot: (snapshot: { x: number; y: number; zoom: number }) => - canvas.setViewportSnapshot(snapshot) - }), - [canvas] - ) const { nodes, edges, @@ -272,6 +282,21 @@ export const Canvas = forwardRef(function Canvas( zoomAt } = canvas + // Expose imperative methods via ref + useImperativeHandle( + ref, + () => ({ + fitToContent: (padding?: number) => canvas.fitToContent(padding), + fitToRect: (rect: Rect, padding?: number) => canvas.fitToRect(rect, padding), + resetView: () => canvas.resetView(), + getViewportSnapshot: () => canvas.getViewportSnapshot(), + setViewportSnapshot: (snapshot: { x: number; y: number; zoom: number }) => + canvas.setViewportSnapshot(snapshot), + clearSelection: () => clearSelection() + }), + [canvas, clearSelection] + ) + // === Presence: track remote users' selected nodes === const [nodePresence, setNodePresence] = useState>(new Map()) @@ -281,6 +306,13 @@ export const Canvas = forwardRef(function Canvas( awareness.setLocalStateField('canvasSelection', Array.from(selectedNodeIds)) }, [awareness, selectedNodeIds]) + useEffect(() => { + onSelectionChange?.({ + nodeIds: Array.from(selectedNodeIds), + edgeIds: Array.from(selectedEdgeIds) + }) + }, [onSelectionChange, selectedEdgeIds, selectedNodeIds]) + // Listen for remote awareness changes useEffect(() => { if (!awareness) return @@ -372,6 +404,8 @@ export const Canvas = forwardRef(function Canvas( if (e.button !== 0) return if (e.target !== containerRef.current) return + containerRef.current?.focus() + // Clicked on background clearSelection() onBackgroundClick?.() @@ -399,6 +433,63 @@ export const Canvas = forwardRef(function Canvas( [clearSelection, onBackgroundClick, pan] ) + const handleStepSelection = useCallback( + (direction: -1 | 1) => { + if (nodes.length === 0) { + return + } + + const orderedNodes = [...nodes].sort((left, right) => { + const leftZ = left.position.zIndex ?? 0 + const rightZ = right.position.zIndex ?? 0 + if (leftZ !== rightZ) { + return leftZ - rightZ + } + + if (left.position.y !== right.position.y) { + return left.position.y - right.position.y + } + + return left.position.x - right.position.x + }) + + if (selectedNodeIds.size !== 1) { + const fallbackNode = direction > 0 ? orderedNodes[0] : orderedNodes[orderedNodes.length - 1] + selectNode(fallbackNode.id) + return + } + + const [currentId] = Array.from(selectedNodeIds) + const currentIndex = orderedNodes.findIndex((node) => node.id === currentId) + const resolvedIndex = currentIndex >= 0 ? currentIndex : 0 + const nextIndex = (resolvedIndex + direction + orderedNodes.length) % orderedNodes.length + selectNode(orderedNodes[nextIndex].id) + }, + [nodes, selectNode, selectedNodeIds] + ) + + const handleNudgeSelection = useCallback( + (delta: Point) => { + const updates = Array.from(selectedNodeIds) + .map((nodeId) => canvas.store.getNode(nodeId)) + .filter((node): node is CanvasNode => node !== undefined && !node.locked) + .map((node) => ({ + id: node.id, + position: { + x: node.position.x + delta.x, + y: node.position.y + delta.y + } + })) + + if (updates.length === 0) { + return + } + + canvas.updateNodePositions(updates) + }, + [canvas, selectedNodeIds] + ) + // Handle keyboard shortcuts useEffect(() => { const manager = new Y.UndoManager( @@ -439,49 +530,14 @@ export const Canvas = forwardRef(function Canvas( } // Delete selected - only if canvas has focus or no input focused - if (e.key === 'Delete' || e.key === 'Backspace') { - if (isInputFocused) return // Don't intercept if typing in an input - if (selectedNodeIds.size > 0 && container.contains(activeElement)) { - e.preventDefault() - canvas.deleteSelected() - } - } - - // Select all - only if canvas has focus - if ((e.metaKey || e.ctrlKey) && e.key === 'a') { - if (container.contains(activeElement) && !isInputFocused) { - e.preventDefault() - canvas.selectAll() - } - } - - // Escape to clear selection - safe to handle globally within canvas - if (e.key === 'Escape') { - if (container.contains(activeElement)) { - clearSelection() - } - } - - // Fit to content - only if canvas has focus - if (e.key === '1' && (e.metaKey || e.ctrlKey)) { - if (container.contains(activeElement)) { - e.preventDefault() - canvas.fitToContent() - } - } - - // Reset view - only if canvas has focus - if (e.key === '0' && (e.metaKey || e.ctrlKey)) { - if (container.contains(activeElement)) { - e.preventDefault() - canvas.resetView() - } + if (!container.contains(activeElement) || isInputFocused) { + return } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [canvas, selectedNodeIds, clearSelection]) + }, [canvas]) // Node event handlers const handleNodeSelect = useCallback( @@ -603,7 +659,6 @@ export const Canvas = forwardRef(function Canvas( const canvasBounds = useMemo(() => canvas.store.getBounds(), [canvas.store, nodes]) const navigationToolsInsetRight = showMinimap && navigationToolsPosition === 'bottom-right' ? minimapWidth + 40 : 16 - const handleNavigationViewportChange = useCallback( (changes: { x?: number; y?: number; zoom?: number }) => { const snapshot = canvas.getViewportSnapshot() @@ -617,6 +672,23 @@ export const Canvas = forwardRef(function Canvas( [canvas] ) + useCanvasKeyboard({ + containerRef, + viewport, + canvasBounds, + selectedNodeCount: selectedNodeIds.size, + onViewportChange: handleNavigationViewportChange, + onDeleteSelection: () => canvas.deleteSelected(), + onSelectAll: () => canvas.selectAll(), + onClearSelection: clearSelection, + onStepSelection: handleStepSelection, + onNudgeSelection: handleNudgeSelection, + onCreateObject, + onOpenSelection, + onToggleShortcutHelp, + onDismissTransientUi + }) + // Container styles const containerStyle: React.CSSProperties = { position: 'relative', diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts index b5ecf7354..cce8b652b 100644 --- a/tests/e2e/src/electron-canvas.spec.ts +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -12,6 +12,7 @@ const RENDERER_PORT = 5178 const ELECTRON_CDP_URL = `http://127.0.0.1:${ELECTRON_CDP_PORT}` const RENDERER_URLS = [`http://localhost:${RENDERER_PORT}`, `http://127.0.0.1:${RENDERER_PORT}`] const COMMAND_PALETTE_SHORTCUT = process.platform === 'darwin' ? 'Meta+Shift+P' : 'Control+Shift+P' +const FOCUSED_OPEN_SHORTCUT = process.platform === 'darwin' ? 'Meta+Enter' : 'Control+Enter' const PNPM_BIN = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const ELECTRON_PROFILE_PATH = join( homedir(), @@ -524,6 +525,10 @@ async function selectCanvasNode(page: Page, selector: string, index = 0): Promis }) } +async function getCanvasNodeCount(page: Page, type: string): Promise { + return page.locator(`.canvas-node[data-node-type="${type}"]`).count() +} + test.describe('Electron canvas shell', () => { test.describe.configure({ mode: 'serial' }) test.setTimeout(240_000) @@ -675,6 +680,91 @@ test.describe('Electron canvas shell', () => { }) }) + test('supports canvas-scoped hotkeys, command commands, and typing guards', async () => { + test.skip(!electronPage, 'Electron page did not initialize') + const page = electronPage! + + await page.locator('[data-canvas-surface="true"]').click({ + position: { x: 28, y: 260 }, + force: true + }) + + await page.keyboard.press('Shift+/') + await expect(page.locator('[data-canvas-shortcut-help="true"]')).toBeVisible({ + timeout: 15_000 + }) + await page.keyboard.press('Escape') + await expect(page.locator('[data-canvas-shortcut-help="true"]')).toHaveCount(0) + + await page.keyboard.press('Tab') + await expect(page.locator('[data-canvas-selection-hud="true"]')).toBeVisible({ + timeout: 15_000 + }) + + await page.keyboard.press(COMMAND_PALETTE_SHORTCUT) + const commandInput = page.getByPlaceholder('Type a command or search...') + await expect(commandInput).toBeVisible({ timeout: 10_000 }) + await commandInput.fill('Show Canvas Shortcuts') + await page.keyboard.press('Enter') + await expect(page.locator('[data-canvas-shortcut-help="true"]')).toBeVisible({ + timeout: 15_000 + }) + await page.keyboard.press('Escape') + await expect(page.locator('[data-canvas-shortcut-help="true"]')).toHaveCount(0) + + const pageCountBefore = await getCanvasNodeCount(page, 'page') + const databaseCountBefore = await getCanvasNodeCount(page, 'database') + const noteCountBefore = await getCanvasNodeCount(page, 'note') + + await page.locator('[data-canvas-surface="true"]').click({ + position: { x: 36, y: 320 }, + force: true + }) + await page.keyboard.press('P') + await page.keyboard.press('D') + await page.keyboard.press('N') + + await expect + .poll(async () => ({ + page: await getCanvasNodeCount(page, 'page'), + database: await getCanvasNodeCount(page, 'database'), + note: await getCanvasNodeCount(page, 'note') + })) + .toEqual({ + page: pageCountBefore + 1, + database: databaseCountBefore + 1, + note: noteCountBefore + 1 + }) + + const newestPageIndex = (await getCanvasNodeCount(page, 'page')) - 1 + await selectCanvasNode(page, '.canvas-node[data-node-type="page"]', newestPageIndex) + await expect(page.locator('[data-canvas-page-surface="true"]').first()).toBeVisible({ + timeout: 30_000 + }) + + await page.keyboard.press(FOCUSED_OPEN_SHORTCUT) + await expect( + page.locator('[data-page-view="true"][data-page-view-chrome="minimal"]') + ).toBeVisible({ + timeout: 30_000 + }) + await page.getByRole('button', { name: 'Canvas' }).click({ force: true }) + await expect( + page.locator('[data-page-view="true"][data-page-view-chrome="minimal"]') + ).toHaveCount(0, { + timeout: 30_000 + }) + + await selectCanvasNode(page, '.canvas-node[data-node-type="page"]', newestPageIndex) + const titleInput = page.locator('[data-canvas-page-title="true"]').first() + await titleInput.focus() + await page.keyboard.type('p') + await page.keyboard.press('Shift+/') + + await expect.poll(async () => await getCanvasNodeCount(page, 'page')).toBe(pageCountBefore + 1) + await expect(page.locator('[data-canvas-shortcut-help="true"]')).toHaveCount(0) + }) + test('keeps database preview bounded and supports open-return workflows', async () => { test.skip(!electronPage, 'Electron page did not initialize') const page = electronPage! From 35cc9f47c8a51bce6c60a17c9dadcd388e2207da Mon Sep 17 00:00:00 2001 From: crs48 Date: Tue, 10 Mar 2026 02:55:36 -0700 Subject: [PATCH 11/42] feat(canvas): add source-backed canvas ingestion - add a shared drop and paste ingestion pipeline for internal drags, urls, and media - wire electron and web canvas shells through the new source-backed placement flow - add web and canvas package coverage for ingestion behavior and rollout docs --- .../src/renderer/components/CanvasView.tsx | 222 +++++-- .../src/renderer/components/Sidebar.tsx | 26 + apps/web/src/components/CanvasView.tsx | 349 ++++++++--- apps/web/src/components/Sidebar.tsx | 36 ++ ...op-ingestion-and-source-object-creation.md | 14 +- ...n-rollout-workbenches-and-release-gates.md | 4 + docs/plans/plan03_9_83CanvasV2/README.md | 3 +- .../canvas-navigation-shell.test.tsx | 27 + .../canvas/src/__tests__/ingestion.test.ts | 93 +++ .../src/hooks/useCanvasObjectIngestion.ts | 427 +++++++++++++ packages/canvas/src/index.ts | 33 + packages/canvas/src/ingestion.ts | 575 ++++++++++++++++++ packages/canvas/src/renderer/Canvas.tsx | 86 ++- tests/e2e/src/web-canvas-ingestion.spec.ts | 87 +++ 14 files changed, 1812 insertions(+), 170 deletions(-) create mode 100644 packages/canvas/src/__tests__/ingestion.test.ts create mode 100644 packages/canvas/src/hooks/useCanvasObjectIngestion.ts create mode 100644 packages/canvas/src/ingestion.ts create mode 100644 tests/e2e/src/web-canvas-ingestion.spec.ts diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index 74ac7835b..984bc17ea 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -9,10 +9,11 @@ import type { CanvasSelectionSnapshot, Rect } from '@xnetjs/canvas' -import { Canvas, createNode } from '@xnetjs/canvas' +import { Canvas, extractCanvasIngressPayloads, useCanvasObjectIngestion } from '@xnetjs/canvas' import { CanvasSchema, DatabaseSchema, PageSchema } from '@xnetjs/data' +import { useBlobService } from '@xnetjs/editor/react' import { useNode, useIdentity } from '@xnetjs/react' -import { Command, Database, Eye, FileText, StickyNote, X } from 'lucide-react' +import { Command, Database, Eye, FileImage, FileText, Link2, StickyNote, X } from 'lucide-react' import React, { forwardRef, useCallback, @@ -27,8 +28,6 @@ import { getCanvasShellDisplayType, getCanvasShellSourceId, getCanvasShellSourceType, - getCanvasShellNotePlacement, - getLinkedDocumentPlacement, shouldRenderCanvasShellCard, type LinkedDocType, type LinkedDocumentItem @@ -62,7 +61,7 @@ export type CanvasViewCommandState = { selectedNodeId: string | null selectedSourceId: string | null selectedSourceType: Exclude | null - selectedDisplayType: LinkedDocType | 'note' | null + selectedDisplayType: LinkedDocType | 'note' | 'external-reference' | 'media' | null selectedTitle: string | null shortcutHelpOpen: boolean } @@ -85,8 +84,19 @@ function getNodeRect(node: CanvasNode): Rect { } } +function getCanvasViewDisplayType( + node: CanvasNode, + document?: LinkedDocumentItem +): LinkedDocType | 'note' | 'external-reference' | 'media' { + if (node.type === 'external-reference' || node.type === 'media') { + return node.type + } + + return getCanvasShellDisplayType(node, document) +} + function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React.ReactElement { - const displayType = getCanvasShellDisplayType(node, document) + const displayType = getCanvasViewDisplayType(node, document) const sourceId = getCanvasShellSourceId(node) const linkedTitle = node.alias ?? document?.title ?? (node.properties.title as string) ?? 'Untitled' @@ -97,13 +107,38 @@ function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React. ? 'Database' : displayType === 'note' ? 'Canvas note' - : 'Canvas' + : displayType === 'external-reference' + ? 'Link preview' + : 'Media asset' const Icon = - displayType === 'page' ? FileText : displayType === 'database' ? Database : StickyNote + displayType === 'page' + ? FileText + : displayType === 'database' + ? Database + : displayType === 'note' + ? StickyNote + : displayType === 'external-reference' + ? Link2 + : FileImage const isOpenable = Boolean( sourceId && (displayType === 'page' || displayType === 'database' || displayType === 'note') ) + const status = typeof node.properties.status === 'string' ? node.properties.status : null + const summary = + displayType === 'database' + ? 'Open a focused database surface from the canvas.' + : displayType === 'page' + ? 'Open a focused writing surface from the canvas.' + : displayType === 'note' + ? 'A lightweight note pinned directly to the workspace.' + : displayType === 'external-reference' + ? typeof node.properties.url === 'string' + ? node.properties.url + : 'Dropped link preview' + : typeof node.properties.mimeType === 'string' + ? `${String(node.properties.kind ?? 'file')} · ${node.properties.mimeType}` + : 'Dropped media or file' return (
@@ -116,18 +151,21 @@ function renderNodeCard(node: CanvasNode, document?: LinkedDocumentItem): React. Open + ) : status ? ( + + {status} + ) : null}
{linkedTitle}
-

- {displayType === 'database' - ? 'Open a focused database surface from the canvas.' - : displayType === 'page' - ? 'Open a focused writing surface from the canvas.' - : 'A lightweight note pinned directly to the workspace.'} -

+

{summary}

+ {displayType === 'external-reference' && typeof node.properties.subtitle === 'string' ? ( +

+ {node.properties.subtitle} +

+ ) : null}
) @@ -192,6 +230,7 @@ export const CanvasView = forwardRef(function ref ): React.ReactElement { const { did } = useIdentity() + const blobService = useBlobService() const { data: canvas, @@ -221,6 +260,12 @@ export const CanvasView = forwardRef(function () => new Map(documents.map((entry) => [entry.id, entry])), [documents] ) + const { placeSourceObject, ingestDataTransfer } = useCanvasObjectIngestion({ + doc, + blobService, + getViewportSnapshot: () => + canvasRef.current?.getViewportSnapshot() ?? lastViewportSnapshotRef.current + }) const selectedCanvasObject = useMemo(() => { if (!doc || selection.nodeIds.length !== 1) { @@ -234,7 +279,7 @@ export const CanvasView = forwardRef(function const sourceId = getCanvasShellSourceId(node) const linkedDocument = sourceId ? documentMap.get(sourceId) : undefined - const displayType = getCanvasShellDisplayType(node, linkedDocument) + const displayType = getCanvasViewDisplayType(node, linkedDocument) const sourceType = getCanvasShellSourceType(node, linkedDocument) const title = node.alias ?? linkedDocument?.title ?? (node.properties.title as string) ?? 'Untitled' @@ -274,12 +319,12 @@ export const CanvasView = forwardRef(function } }, [doc]) - const addLinkedDocumentNode = useCallback( + const placeLinkedDocumentNode = useCallback( (document: LinkedDocumentItem): boolean => { - if (!doc || document.type === 'canvas') return false + if (document.type === 'canvas') { + return false + } - const viewport = canvasRef.current?.getViewportSnapshot() ?? lastViewportSnapshotRef.current - const nodesMap = doc.getMap('nodes') const canvasKind = document.canvasKind ?? document.type const properties = canvasKind === 'note' @@ -288,28 +333,19 @@ export const CanvasView = forwardRef(function title: document.title } : { title: document.title } - const placement = - canvasKind === 'note' - ? getCanvasShellNotePlacement(viewport) - : getLinkedDocumentPlacement(viewport, document.type) - const linkedNode = createNode(canvasKind, placement, properties) - - linkedNode.sourceNodeId = document.id - linkedNode.sourceSchemaId = - document.type === 'page' ? PageSchema._schemaId : DatabaseSchema._schemaId - nodesMap.set(linkedNode.id, linkedNode) - return true - }, - [doc] - ) - - const addCanvasNote = useCallback( - (document: LinkedDocumentItem): boolean => { - if (document.type !== 'page') return false - return addLinkedDocumentNode({ ...document, canvasKind: 'note' }) + return Boolean( + placeSourceObject({ + objectKind: canvasKind, + sourceNodeId: document.id, + sourceSchemaId: + document.type === 'page' ? PageSchema._schemaId : DatabaseSchema._schemaId, + title: document.title, + properties + }) + ) }, - [addLinkedDocumentNode] + [placeSourceObject] ) useEffect(() => { @@ -317,10 +353,7 @@ export const CanvasView = forwardRef(function return } - const inserted = - pendingInsert.document.canvasKind === 'note' - ? addCanvasNote(pendingInsert.document) - : addLinkedDocumentNode(pendingInsert.document) + const inserted = placeLinkedDocumentNode(pendingInsert.document) if (!inserted) { return @@ -328,7 +361,7 @@ export const CanvasView = forwardRef(function handledInsertIdsRef.current.add(pendingInsert.requestId) onPendingInsertConsumed?.(pendingInsert.requestId) - }, [addCanvasNote, addLinkedDocumentNode, onPendingInsertConsumed, pendingInsert]) + }, [onPendingInsertConsumed, pendingInsert, placeLinkedDocumentNode]) const focusLinkedDocument = useCallback( (linkedDocumentId: string): ViewportSnapshot | null => { @@ -392,7 +425,12 @@ export const CanvasView = forwardRef(function if (mode === 'peek') { const didFit = fitSelection() - if (selectedCanvasObject.sourceId) { + if ( + selectedCanvasObject.sourceId && + (selectedCanvasObject.displayType === 'page' || + selectedCanvasObject.displayType === 'database' || + selectedCanvasObject.displayType === 'note') + ) { focusSelectionSurface(selectedCanvasObject.sourceId, selectedCanvasObject.displayType) } @@ -409,6 +447,39 @@ export const CanvasView = forwardRef(function [fitSelection, focusSelectionSurface, onOpenDocument, selectedCanvasObject] ) + const handleSurfaceDrop = useCallback( + ( + event: React.DragEvent, + context: { + screenToCanvas: (clientX: number, clientY: number) => { x: number; y: number } + } + ) => { + void ingestDataTransfer(event.dataTransfer, { + canvasPoint: context.screenToCanvas(event.clientX, event.clientY) + }) + }, + [ingestDataTransfer] + ) + + const handleSurfacePaste = useCallback( + ( + event: React.ClipboardEvent, + _context: { + screenToCanvas: (clientX: number, clientY: number) => { x: number; y: number } + } + ) => { + const payloads = extractCanvasIngressPayloads(event.clipboardData) + const hasMeaningfulPaste = payloads.some((payload) => payload.kind !== 'text') + if (!hasMeaningfulPaste) { + return + } + + event.preventDefault() + void ingestDataTransfer(event.clipboardData) + }, + [ingestDataTransfer] + ) + const toggleShortcutHelp = useCallback((open?: boolean) => { setShortcutHelpOpen((current) => (typeof open === 'boolean' ? open : !current)) }, []) @@ -503,7 +574,17 @@ export const CanvasView = forwardRef(function > {selectedCanvasObject - ? `${selectedCanvasObject.displayType === 'note' ? 'Note' : selectedCanvasObject.displayType === 'database' ? 'Database' : 'Page'} · ${selectedCanvasObject.title}` + ? `${ + selectedCanvasObject.displayType === 'note' + ? 'Note' + : selectedCanvasObject.displayType === 'database' + ? 'Database' + : selectedCanvasObject.displayType === 'external-reference' + ? 'Link' + : selectedCanvasObject.displayType === 'media' + ? 'Media' + : 'Page' + } · ${selectedCanvasObject.title}` : `${selection.nodeIds.length} selected`} @@ -518,25 +599,32 @@ export const CanvasView = forwardRef(function data-canvas-selection-action="peek" > - {selectedCanvasObject.displayType === 'database' ? 'Peek' : 'Edit'} + {selectedCanvasObject.displayType === 'database' + ? 'Peek' + : selectedCanvasObject.displayType === 'external-reference' || + selectedCanvasObject.displayType === 'media' + ? 'Center' + : 'Edit'} Enter - + {selectedCanvasObject.sourceId && selectedCanvasObject.sourceType ? ( + + ) : null} ) : null} @@ -638,6 +726,8 @@ export const CanvasView = forwardRef(function onOpenSelection={openSelection} onToggleShortcutHelp={toggleShortcutHelp} onDismissTransientUi={handleDismissTransientUi} + onSurfaceDrop={handleSurfaceDrop} + onSurfacePaste={handleSurfacePaste} navigationToolsStyle={{ bottom: 24, right: 24, @@ -650,7 +740,7 @@ export const CanvasView = forwardRef(function renderNode={(node, context) => { const sourceNodeId = getCanvasShellSourceId(node) const linkedDocument = sourceNodeId ? documentMap.get(sourceNodeId) : undefined - const displayType = getCanvasShellDisplayType(node, linkedDocument) + const displayType = getCanvasViewDisplayType(node, linkedDocument) if (sourceNodeId && shouldActivateInlinePageSurface(node, context, linkedDocument)) { return ( @@ -676,7 +766,11 @@ export const CanvasView = forwardRef(function ) } - if (shouldRenderCanvasShellCard(node, linkedDocument)) { + if ( + node.type === 'external-reference' || + node.type === 'media' || + shouldRenderCanvasShellCard(node, linkedDocument) + ) { return renderNodeCard(node, linkedDocument) } return undefined diff --git a/apps/electron/src/renderer/components/Sidebar.tsx b/apps/electron/src/renderer/components/Sidebar.tsx index 92c4b2428..5a188aad3 100644 --- a/apps/electron/src/renderer/components/Sidebar.tsx +++ b/apps/electron/src/renderer/components/Sidebar.tsx @@ -7,6 +7,8 @@ import type { Document } from '../lib/types' import type { SidebarContribution } from '@xnetjs/plugins' +import { CANVAS_INTERNAL_NODE_MIME, serializeCanvasInternalNodeDragData } from '@xnetjs/canvas' +import { DatabaseSchema, PageSchema } from '@xnetjs/data' import * as icons from 'lucide-react' import { FileText, @@ -46,6 +48,11 @@ const typeLabels: Record = { canvas: 'Canvas' } +const schemaByType = { + page: PageSchema._schemaId, + database: DatabaseSchema._schemaId +} as const + /** * Render an icon from a string name or component */ @@ -217,9 +224,28 @@ export function Sidebar({
  • onSelect(doc.id)} + draggable={doc.type !== 'canvas'} + onDragStart={(event) => { + if (doc.type === 'canvas') { + return + } + + event.dataTransfer.effectAllowed = 'copy' + event.dataTransfer.setData( + CANVAS_INTERNAL_NODE_MIME, + serializeCanvasInternalNodeDragData({ + nodeId: doc.id, + schemaId: schemaByType[doc.type], + title: doc.title + }) + ) + event.dataTransfer.setData('text/plain', doc.title) + }} className={`flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer mb-0.5 group transition-colors ${ selectedId === doc.id ? 'bg-accent' : 'hover:bg-accent/50' }`} + data-sidebar-document-id={doc.id} + data-sidebar-document-type={doc.type} > {doc.title} diff --git a/apps/web/src/components/CanvasView.tsx b/apps/web/src/components/CanvasView.tsx index 51de9f61b..a4b30e884 100644 --- a/apps/web/src/components/CanvasView.tsx +++ b/apps/web/src/components/CanvasView.tsx @@ -1,14 +1,15 @@ /** - * Canvas View - Infinite canvas for spatial visualization - * - * Ported from apps/electron/src/renderer/components/CanvasView.tsx + * Canvas View - Web canvas surface with source-backed drops. */ -import { Canvas, createNode, createEdge, type CanvasHandle } from '@xnetjs/canvas' -import { CanvasSchema } from '@xnetjs/data' -import { useNode, useIdentity } from '@xnetjs/react' -import { Plus, LayoutGrid, ZoomIn, Maximize2 } from 'lucide-react' -import { useEffect, useState, useCallback, useRef } from 'react' +import type { CanvasHandle, CanvasNode } from '@xnetjs/canvas' +import { useNavigate } from '@tanstack/react-router' +import { Canvas, extractCanvasIngressPayloads, useCanvasObjectIngestion } from '@xnetjs/canvas' +import { CanvasSchema, DatabaseSchema, PageSchema } from '@xnetjs/data' +import { useBlobService } from '@xnetjs/editor/react' +import { useIdentity, useMutate, useNode } from '@xnetjs/react' +import { FileImage, FileText, Link2, Maximize2, Plus, StickyNote, Table2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PresenceAvatars } from './PresenceAvatars' import { ShareButton } from './ShareButton' @@ -16,8 +17,99 @@ interface CanvasViewProps { docId: string } -export function CanvasView({ docId }: CanvasViewProps) { +function getNodeCard(node: CanvasNode): JSX.Element { + const title = node.alias ?? (node.properties.title as string) ?? 'Untitled' + const status = typeof node.properties.status === 'string' ? node.properties.status : null + + if (node.type === 'external-reference') { + return ( +
    +
    + + + Link preview + + {status ? ( + + {status} + + ) : null} +
    +
    +
    {title}
    +

    + {typeof node.properties.url === 'string' ? node.properties.url : 'Dropped URL'} +

    +
    +
    + ) + } + + if (node.type === 'media') { + return ( +
    +
    + + + Media asset + + {status ? ( + + {status} + + ) : null} +
    +
    +
    {title}
    +

    + {typeof node.properties.mimeType === 'string' + ? `${String(node.properties.kind ?? 'file')} · ${node.properties.mimeType}` + : 'Dropped media or file'} +

    +
    +
    + ) + } + + const displayType = node.type === 'database' ? 'database' : node.type === 'note' ? 'note' : 'page' + const Icon = displayType === 'database' ? Table2 : displayType === 'note' ? StickyNote : FileText + + return ( +
    +
    + + + {displayType === 'database' + ? 'Database' + : displayType === 'note' + ? 'Canvas note' + : 'Document'} + + {node.sourceNodeId ? ( + + Open + + ) : null} +
    +
    +
    {title}
    +

    + {displayType === 'database' + ? 'A linked database surface placed on the board.' + : displayType === 'note' + ? 'A page-backed note created directly on the board.' + : 'A linked page placed directly on the board.'} +

    +
    +
    + ) +} + +export function CanvasView({ docId }: CanvasViewProps): JSX.Element { + const navigate = useNavigate() const { identity } = useIdentity() + const { create } = useMutate() + const blobService = useBlobService() const did = identity?.did const { @@ -34,72 +126,113 @@ export function CanvasView({ docId }: CanvasViewProps) { const canvasRef = useRef(null) const [canvasReady, setCanvasReady] = useState(false) + const [hasNodes, setHasNodes] = useState(false) + const { placeSourceObject, ingestDataTransfer } = useCanvasObjectIngestion({ + doc, + blobService, + getViewportSnapshot: () => canvasRef.current?.getViewportSnapshot() ?? { x: 0, y: 0, zoom: 1 } + }) - // Initialize canvas data structure if needed useEffect(() => { - if (!doc) return - - const nodesMap = doc.getMap('nodes') - const edgesMap = doc.getMap('edges') - - // Initialize with sample nodes if empty - if (nodesMap.size === 0) { - const node1 = createNode( - 'card', - { x: 100, y: 100, width: 200, height: 100 }, - { title: 'Start Here' } - ) - const node2 = createNode( - 'card', - { x: 400, y: 100, width: 200, height: 100 }, - { title: 'Next Step' } - ) - const node3 = createNode( - 'card', - { x: 250, y: 300, width: 200, height: 100 }, - { title: 'Final Goal' } - ) - - const edge1 = createEdge(node1.id, node2.id, { style: { markerEnd: 'arrow' } }) - const edge2 = createEdge(node2.id, node3.id, { style: { markerEnd: 'arrow' } }) - const edge3 = createEdge(node1.id, node3.id, { - style: { markerEnd: 'arrow', strokeDasharray: '5,5' } - }) - - doc.transact(() => { - nodesMap.set(node1.id, node1) - nodesMap.set(node2.id, node2) - nodesMap.set(node3.id, node3) - edgesMap.set(edge1.id, edge1) - edgesMap.set(edge2.id, edge2) - edgesMap.set(edge3.id, edge3) - }) + if (!doc) { + return } setCanvasReady(true) - }, [doc]) - // Add a new node to the canvas - const handleAddNode = useCallback(() => { - if (!doc) return - - const nodesMap = doc.getMap('nodes') - const newNode = createNode( - 'card', - { - x: 100 + Math.random() * 400, - y: 100 + Math.random() * 300, - width: 200, - height: 100 - }, - { title: 'New Node' } - ) - nodesMap.set(newNode.id, newNode) + const nodesMap = doc.getMap('nodes') + const syncHasNodes = () => { + setHasNodes(nodesMap.size > 0) + } + + syncHasNodes() + nodesMap.observe(syncHasNodes) + + return () => { + nodesMap.unobserve(syncHasNodes) + } }, [doc]) + const handleCreateNote = useCallback(async () => { + const note = await create(PageSchema, { title: 'Untitled Note' }) + if (!note) { + return + } + + placeSourceObject({ + objectKind: 'note', + sourceNodeId: note.id, + sourceSchemaId: PageSchema._schemaId, + title: note.title || 'Untitled Note', + properties: { + title: note.title || 'Untitled Note', + shellRole: 'canvas-note' + } + }) + }, [create, placeSourceObject]) + + const handleSurfaceDrop = useCallback( + ( + event: React.DragEvent, + context: { + screenToCanvas: (clientX: number, clientY: number) => { x: number; y: number } + } + ) => { + void ingestDataTransfer(event.dataTransfer, { + canvasPoint: context.screenToCanvas(event.clientX, event.clientY) + }) + }, + [ingestDataTransfer] + ) + + const handleSurfacePaste = useCallback( + (event: React.ClipboardEvent) => { + const payloads = extractCanvasIngressPayloads(event.clipboardData) + const hasMeaningfulPaste = payloads.some((payload) => payload.kind !== 'text') + if (!hasMeaningfulPaste) { + return + } + + event.preventDefault() + void ingestDataTransfer(event.clipboardData) + }, + [ingestDataTransfer] + ) + + const handleNodeDoubleClick = useCallback( + (nodeId: string) => { + const node = doc?.getMap('nodes').get(nodeId) + if (!node?.sourceNodeId) { + return + } + + if (node.type === 'database' || node.sourceSchemaId === DatabaseSchema._schemaId) { + void navigate({ to: '/db/$dbId', params: { dbId: node.sourceNodeId } }) + return + } + + if ( + node.type === 'page' || + node.type === 'note' || + node.sourceSchemaId === PageSchema._schemaId + ) { + void navigate({ to: '/doc/$docId', params: { docId: node.sourceNodeId } }) + } + }, + [doc, navigate] + ) + + const canvasHint = useMemo( + () => + hasNodes + ? 'Drag pages, databases, links, or files directly onto the board.' + : 'Drop links, files, pages, or databases anywhere on the board.', + [hasNodes] + ) + if (loading || !doc) { return ( -
    +

    Loading canvas...

    ) @@ -107,61 +240,69 @@ export function CanvasView({ docId }: CanvasViewProps) { if (!canvasReady) { return ( -
    +

    Preparing canvas...

    ) } return ( -
    - {/* Canvas toolbar */} -
    - {/* Title */} +
    +
    update({ title: e.target.value })} + onChange={(event) => update({ title: event.target.value })} placeholder="Untitled" + data-web-canvas-title="true" /> -
    - - {/* Presence avatars */} - - {/* Share button */} +
    -
    - - Pan: Drag background - | - - Zoom: Scroll +
    +
    + {canvasHint}
    -
    - {/* Canvas */} -
    + {!hasNodes ? ( +
    +
    +

    Canvas-first workspace

    +

    + Drop a URL for a link card, drag in a page or database from the sidebar, or create a + note directly on the board. +

    +
    +
    + ) : null} + { - console.log('Double-clicked node:', id) - }} - onBackgroundClick={() => { - console.log('Background clicked') + showMinimap + showNavigationTools + navigationToolsPosition="bottom-right" + navigationToolsShowZoomLabel={false} + onSurfaceDrop={handleSurfaceDrop} + onSurfacePaste={handleSurfacePaste} + renderNode={(node) => { + if ( + node.type === 'page' || + node.type === 'database' || + node.type === 'note' || + node.type === 'external-reference' || + node.type === 'media' + ) { + return getNodeCard(node) + } + + return undefined }} + onNodeDoubleClick={handleNodeDoubleClick} />
    diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9448156c9..f807f7488 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ * Sidebar component with collapsible sections for all document types */ import { Link, useLocation, useNavigate } from '@tanstack/react-router' +import { CANVAS_INTERNAL_NODE_MIME, serializeCanvasInternalNodeDragData } from '@xnetjs/canvas' import { PageSchema, DatabaseSchema, CanvasSchema } from '@xnetjs/data' import { useQuery } from '@xnetjs/react' import { @@ -49,6 +50,11 @@ const typeConfig = { } } as const +const schemaByType = { + page: PageSchema._schemaId, + database: DatabaseSchema._schemaId +} as const + export function Sidebar() { const location = useLocation() const navigate = useNavigate() @@ -121,9 +127,24 @@ export function Sidebar() { { + event.dataTransfer.effectAllowed = 'copy' + event.dataTransfer.setData( + CANVAS_INTERNAL_NODE_MIME, + serializeCanvasInternalNodeDragData({ + nodeId: doc.id, + schemaId: schemaByType.page, + title: doc.title || 'Untitled' + }) + ) + event.dataTransfer.setData('text/plain', doc.title || 'Untitled') + }} className={`flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer mb-0.5 group transition-colors no-underline hover:no-underline ${ isActive ? 'bg-accent' : 'hover:bg-accent/50' }`} + data-sidebar-document-id={doc.id} + data-sidebar-document-type="page" > {doc.title || 'Untitled'} @@ -146,9 +167,24 @@ export function Sidebar() { { + event.dataTransfer.effectAllowed = 'copy' + event.dataTransfer.setData( + CANVAS_INTERNAL_NODE_MIME, + serializeCanvasInternalNodeDragData({ + nodeId: doc.id, + schemaId: schemaByType.database, + title: doc.title || 'Untitled' + }) + ) + event.dataTransfer.setData('text/plain', doc.title || 'Untitled') + }} className={`flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer mb-0.5 group transition-colors no-underline hover:no-underline ${ isActive ? 'bg-accent' : 'hover:bg-accent/50' }`} + data-sidebar-document-id={doc.id} + data-sidebar-document-type="database" > {doc.title || 'Untitled'} diff --git a/docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md b/docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md index e9fb179f2..a5e0d1174 100644 --- a/docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md +++ b/docs/plans/plan03_9_83CanvasV2/04-drop-ingestion-and-source-object-creation.md @@ -127,8 +127,10 @@ async function ingestCanvasPayload( Suggested commands: ```bash +pnpm --filter @xnetjs/canvas exec vitest run src/__tests__/ingestion.test.ts src/__tests__/canvas-navigation-shell.test.tsx pnpm --filter @xnetjs/data test pnpm --filter @xnetjs/react test +pnpm --filter @xnetjs/e2e-tests exec playwright test src/web-canvas-ingestion.spec.ts --project=chromium ``` ## Risks and Edge Cases @@ -139,9 +141,9 @@ pnpm --filter @xnetjs/react test ## Step Checklist -- [ ] Build a unified canvas ingestion boundary for drags, drops, paste, and create commands. -- [ ] Reuse source-node identity for internal page/database drags. -- [ ] Create or reuse `ExternalReference` nodes for URL drops. -- [ ] Upload files/images through `BlobService` and create `MediaAsset` nodes. -- [ ] Share one placement pipeline between command creation and drop-based creation. -- [ ] Add optimistic placement with async preview/media resolution. +- [x] Build a unified canvas ingestion boundary for drags, drops, paste, and create commands. +- [x] Reuse source-node identity for internal page/database drags. +- [x] Create or reuse `ExternalReference` nodes for URL drops. +- [x] Upload files/images through `BlobService` and create `MediaAsset` nodes. +- [x] Share one placement pipeline between command creation and drop-based creation. +- [x] Add optimistic placement with async preview/media resolution. diff --git a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md index 0497d6cb5..40e1a7242 100644 --- a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md +++ b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md @@ -142,6 +142,7 @@ Suggested commands: pnpm --filter @xnetjs/canvas test pnpm --filter @xnetjs/react test pnpm --filter @xnetjs/data test +pnpm --filter @xnetjs/e2e-tests exec playwright test src/web-canvas-ingestion.spec.ts --project=chromium cd tests/e2e && pnpm exec playwright test src/electron-canvas.spec.ts --project=chromium pnpm dev:stories cd apps/electron && pnpm dev @@ -167,6 +168,9 @@ Automated validation should include: - command-palette creation - minimap toggle - page/database focus-return flows +- Web Playwright smoke coverage for: + - URL drops creating source-backed `ExternalReference` cards + - image/file drops creating source-backed `MediaAsset` cards - Electron CDP performance coverage for: - dense seeded scenes - bounded DOM node counts diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md index 8b426133c..ff0e38eb0 100644 --- a/docs/plans/plan03_9_83CanvasV2/README.md +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -265,7 +265,7 @@ flowchart LR - [x] Add a reusable `MediaAsset`-style node schema for dropped images/files. - [x] Replace the current linked-card shell with a hybrid renderer shell. - [ ] Route the main runtime through chunking, culling, and explicit layer display lists. -- [ ] Add universal drop ingestion for internal drags, URLs, text, images, and files. +- [x] Add universal drop ingestion for internal drags, URLs, text, images, and files. - [ ] Ship live page cards with inline editing and peek behavior. - [ ] Ship database preview cards with focus/open and split workflows. - [ ] Add connector bindings, shapes, groups, locks, align/tidy operations, and aliases/backlinks. @@ -282,6 +282,7 @@ flowchart LR - [x] Creating a database on the canvas immediately creates a real `Database` node and shows a bounded live preview. - [ ] Dropping a URL creates or reuses an `ExternalReference` node and renders the correct fallback chain. - [ ] Dropping an image or file creates a reusable media node and preserves it after reload. +- [x] Web Playwright smoke coverage verifies URL and image drops create source-backed canvas objects. - [ ] Pan/zoom remains smooth on large scenes with chunk load/evict active. - [x] The background grid and minimap remain outside the main DOM path. - [x] Far-field objects do not mount rich editors or oversized DOM subtrees. diff --git a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx index 54bed9c9f..253386cda 100644 --- a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx +++ b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx @@ -189,6 +189,33 @@ describe('Canvas navigation shell', () => { }) }) + it('provides drop callbacks with a surface coordinate transformer', () => { + mockUseCanvas.mockReturnValue(createCanvasMock()) + const onSurfaceDrop = vi.fn() + + render() + + const surface = document.querySelector('[data-canvas-surface="true"]') + expect(surface).toBeTruthy() + + fireEvent.drop(surface as HTMLElement, { + clientX: 400, + clientY: 300, + dataTransfer: { + files: [], + getData: () => '' + } + }) + + expect(onSurfaceDrop).toHaveBeenCalledTimes(1) + const [, context] = onSurfaceDrop.mock.calls[0] as [ + React.DragEvent, + { screenToCanvas: (clientX: number, clientY: number) => { x: number; y: number } } + ] + + expect(context.screenToCanvas(400, 300)).toEqual({ x: 100, y: 80 }) + }) + it('passes render context to full-detail node renderers', () => { const node = { id: 'page-1', diff --git a/packages/canvas/src/__tests__/ingestion.test.ts b/packages/canvas/src/__tests__/ingestion.test.ts new file mode 100644 index 000000000..70b215ddb --- /dev/null +++ b/packages/canvas/src/__tests__/ingestion.test.ts @@ -0,0 +1,93 @@ +import { DatabaseSchema, PageSchema } from '@xnetjs/data' +import { describe, expect, it } from 'vitest' +import { + CANVAS_INTERNAL_NODE_MIME, + createSourceBackedCanvasNode, + describeExternalReference, + extractCanvasIngressPayloads, + getCanvasObjectKindFromSchema, + getMediaRect, + normalizeExternalReferenceUrl, + serializeCanvasInternalNodeDragData +} from '../ingestion' + +describe('canvas ingestion utilities', () => { + it('normalizes bare URLs and strips hashes', () => { + expect(normalizeExternalReferenceUrl('example.com/path#section')).toBe( + 'https://example.com/path' + ) + expect(normalizeExternalReferenceUrl('mailto:test@example.com')).toBeNull() + }) + + it('describes provider-aware URLs', () => { + expect(describeExternalReference('https://github.com/openai/openai/issues/123')).toMatchObject({ + provider: 'github', + kind: 'issue', + refId: 'openai/openai#123', + title: 'openai#123' + }) + + expect(describeExternalReference('https://www.example.com/some/path')).toMatchObject({ + provider: 'generic', + kind: 'link', + title: 'example.com' + }) + }) + + it('extracts internal node and file payloads from data transfer', () => { + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) + const dataTransfer = { + files: [file], + getData(type: string) { + if (type === CANVAS_INTERNAL_NODE_MIME) { + return serializeCanvasInternalNodeDragData({ + nodeId: 'page-1', + schemaId: PageSchema._schemaId, + title: 'Dragged page' + }) + } + + return '' + } + } as unknown as DataTransfer + + expect(extractCanvasIngressPayloads(dataTransfer)).toEqual([ + { + kind: 'internal-node', + data: { + nodeId: 'page-1', + schemaId: PageSchema._schemaId, + title: 'Dragged page' + } + }, + { + kind: 'file', + file + } + ]) + }) + + it('creates source-backed canvas nodes around the viewport center', () => { + const node = createSourceBackedCanvasNode({ + objectKind: 'page', + viewport: { x: 400, y: 300, zoom: 1 }, + sourceNodeId: 'page-1', + sourceSchemaId: PageSchema._schemaId, + title: 'Canvas page' + }) + + expect(node.type).toBe('page') + expect(node.sourceNodeId).toBe('page-1') + expect(node.sourceSchemaId).toBe(PageSchema._schemaId) + expect(node.position.width).toBe(360) + expect(node.position.height).toBe(220) + expect(node.position.x).toBe(220) + expect(node.position.y).toBe(190) + }) + + it('maps schemas and media sizing to canvas primitives', () => { + expect(getCanvasObjectKindFromSchema(PageSchema._schemaId)).toBe('page') + expect(getCanvasObjectKindFromSchema(DatabaseSchema._schemaId)).toBe('database') + expect(getMediaRect({ width: 1920, height: 1080 })).toEqual({ width: 420, height: 236 }) + }) +}) diff --git a/packages/canvas/src/hooks/useCanvasObjectIngestion.ts b/packages/canvas/src/hooks/useCanvasObjectIngestion.ts new file mode 100644 index 000000000..74d6c41f9 --- /dev/null +++ b/packages/canvas/src/hooks/useCanvasObjectIngestion.ts @@ -0,0 +1,427 @@ +/** + * useCanvasObjectIngestion - Source-backed canvas drop/paste helpers. + */ + +import type { + CanvasExternalReferenceDescriptor, + CanvasIngressPayload, + CanvasViewportSnapshot +} from '../ingestion' +import type { CanvasNode, Point } from '../types' +import type { BlobService } from '@xnetjs/data' +import type * as Y from 'yjs' +import { ExternalReferenceSchema, MediaAssetSchema } from '@xnetjs/data' +import { useMutate, useQuery } from '@xnetjs/react' +import { useCallback, useMemo } from 'react' +import { + createSourceBackedCanvasNode, + describeExternalReference, + extractCanvasIngressPayloads, + getCanvasObjectKindFromSchema, + getMediaRect, + inferMediaKind, + readImageDimensions +} from '../ingestion' + +export interface UseCanvasObjectIngestionOptions { + doc: Y.Doc | null + blobService: BlobService | null + getViewportSnapshot: () => CanvasViewportSnapshot + externalReferenceLimit?: number +} + +export interface PlaceCanvasSourceObjectInput { + objectKind: 'page' | 'database' | 'external-reference' | 'media' | 'note' + sourceNodeId: string + sourceSchemaId: string + title: string + canvasPoint?: Point | null + spreadIndex?: number + rect?: Partial<{ width: number; height: number }> + properties?: Record +} + +export interface CanvasIngestionResult { + canvasNodeId: string + sourceNodeId?: string +} + +function getNodesMap(doc: Y.Doc | null): Y.Map | null { + if (!doc) { + return null + } + + return doc.getMap('nodes') +} + +function toExternalReferenceProperties( + descriptor: CanvasExternalReferenceDescriptor, + status: 'resolving' | 'ready' | 'error' = 'ready', + error?: string +): Record { + return { + title: descriptor.title, + url: descriptor.normalizedUrl, + provider: descriptor.provider, + kind: descriptor.kind, + subtitle: descriptor.subtitle, + icon: descriptor.icon, + embedUrl: descriptor.embedUrl, + metadata: JSON.stringify(descriptor.metadata), + status, + ...(error ? { error } : {}) + } +} + +function toMediaProperties(input: { + title: string + mimeType: string + kind: string + size: number + width?: number + height?: number + status: 'uploading' | 'ready' | 'error' + error?: string +}): Record { + return { + title: input.title, + mimeType: input.mimeType, + kind: input.kind, + size: input.size, + width: input.width, + height: input.height, + status: input.status, + ...(input.error ? { error: input.error } : {}) + } +} + +function toExternalReferenceCreateInput(descriptor: CanvasExternalReferenceDescriptor) { + return { + url: descriptor.normalizedUrl, + provider: descriptor.provider, + kind: descriptor.kind, + ...(descriptor.refId ? { refId: descriptor.refId } : {}), + title: descriptor.title, + ...(descriptor.subtitle ? { subtitle: descriptor.subtitle } : {}), + ...(descriptor.icon ? { icon: descriptor.icon } : {}), + ...(descriptor.embedUrl ? { embedUrl: descriptor.embedUrl } : {}), + metadata: JSON.stringify(descriptor.metadata) + } +} + +function updateCanvasNode( + doc: Y.Doc | null, + nodeId: string, + updater: (node: CanvasNode) => CanvasNode +): void { + const nodes = getNodesMap(doc) + const current = nodes?.get(nodeId) + if (!nodes || !current) { + return + } + + doc?.transact(() => { + nodes.set(nodeId, updater(current)) + }) +} + +export function useCanvasObjectIngestion({ + doc, + blobService, + getViewportSnapshot, + externalReferenceLimit = 500 +}: UseCanvasObjectIngestionOptions) { + const { create } = useMutate() + const { data: externalReferences } = useQuery(ExternalReferenceSchema, { + limit: externalReferenceLimit + }) + + const externalReferenceByUrl = useMemo(() => { + const entries = externalReferences + .map((reference) => { + if (typeof reference.url !== 'string') { + return null + } + + const descriptor = describeExternalReference(reference.url) + if (!descriptor) { + return null + } + + return [descriptor.normalizedUrl, reference] as const + }) + .filter( + (entry): entry is readonly [string, (typeof externalReferences)[number]] => entry !== null + ) + + return new Map(entries) + }, [externalReferences]) + + const placeSourceObject = useCallback( + (input: PlaceCanvasSourceObjectInput): CanvasIngestionResult | null => { + const nodes = getNodesMap(doc) + if (!doc || !nodes) { + return null + } + + const node = createSourceBackedCanvasNode({ + objectKind: input.objectKind, + viewport: getViewportSnapshot(), + sourceNodeId: input.sourceNodeId, + sourceSchemaId: input.sourceSchemaId, + title: input.title, + canvasPoint: input.canvasPoint, + spreadIndex: input.spreadIndex, + rect: input.rect, + properties: input.properties + }) + + doc.transact(() => { + nodes.set(node.id, node) + }) + + return { + canvasNodeId: node.id, + sourceNodeId: input.sourceNodeId + } + }, + [doc, getViewportSnapshot] + ) + + const ingestUrlPayload = useCallback( + async ( + url: string, + canvasPoint?: Point | null, + spreadIndex = 0 + ): Promise => { + const nodes = getNodesMap(doc) + const descriptor = describeExternalReference(url) + if (!doc || !nodes || !descriptor) { + return null + } + + const pendingNode = createSourceBackedCanvasNode({ + objectKind: 'external-reference', + viewport: getViewportSnapshot(), + title: descriptor.title, + canvasPoint, + spreadIndex, + properties: toExternalReferenceProperties(descriptor, 'resolving') + }) + + doc.transact(() => { + nodes.set(pendingNode.id, pendingNode) + }) + + try { + const existingReference = externalReferenceByUrl.get(descriptor.normalizedUrl) + const sourceNode = + existingReference ?? + (await create(ExternalReferenceSchema, toExternalReferenceCreateInput(descriptor))) + + if (!sourceNode) { + throw new Error('External reference creation returned no node') + } + + updateCanvasNode(doc, pendingNode.id, (node) => ({ + ...node, + sourceNodeId: sourceNode.id, + sourceSchemaId: ExternalReferenceSchema._schemaId, + properties: toExternalReferenceProperties(descriptor, 'ready') + })) + + return { + canvasNodeId: pendingNode.id, + sourceNodeId: sourceNode.id + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + updateCanvasNode(doc, pendingNode.id, (node) => ({ + ...node, + properties: toExternalReferenceProperties(descriptor, 'error', message) + })) + return { + canvasNodeId: pendingNode.id + } + } + }, + [create, doc, externalReferenceByUrl, getViewportSnapshot] + ) + + const ingestFilePayload = useCallback( + async ( + file: File, + canvasPoint?: Point | null, + spreadIndex = 0 + ): Promise => { + const nodes = getNodesMap(doc) + if (!doc || !nodes || !blobService) { + return null + } + + const mediaKind = inferMediaKind(file) + const pendingNode = createSourceBackedCanvasNode({ + objectKind: 'media', + viewport: getViewportSnapshot(), + title: file.name, + canvasPoint, + spreadIndex, + properties: toMediaProperties({ + title: file.name, + mimeType: file.type || 'application/octet-stream', + kind: mediaKind, + size: file.size, + status: 'uploading' + }) + }) + + doc.transact(() => { + nodes.set(pendingNode.id, pendingNode) + }) + + try { + const dimensions = await readImageDimensions(file) + const fileRef = await blobService.upload(file) + const sourceNode = await create(MediaAssetSchema, { + title: file.name, + file: fileRef, + kind: mediaKind, + ...(dimensions?.width ? { width: dimensions.width } : {}), + ...(dimensions?.height ? { height: dimensions.height } : {}) + }) + + if (!sourceNode) { + throw new Error('Media asset creation returned no node') + } + + const rect = getMediaRect(dimensions) + updateCanvasNode(doc, pendingNode.id, (node) => ({ + ...node, + sourceNodeId: sourceNode.id, + sourceSchemaId: MediaAssetSchema._schemaId, + position: { + ...node.position, + width: rect.width, + height: rect.height + }, + properties: toMediaProperties({ + title: file.name, + mimeType: file.type || 'application/octet-stream', + kind: mediaKind, + size: file.size, + width: dimensions?.width, + height: dimensions?.height, + status: 'ready' + }) + })) + + return { + canvasNodeId: pendingNode.id, + sourceNodeId: sourceNode.id + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + updateCanvasNode(doc, pendingNode.id, (node) => ({ + ...node, + properties: toMediaProperties({ + title: file.name, + mimeType: file.type || 'application/octet-stream', + kind: mediaKind, + size: file.size, + status: 'error', + error: message + }) + })) + return { + canvasNodeId: pendingNode.id + } + } + }, + [blobService, create, doc, getViewportSnapshot] + ) + + const ingestPayload = useCallback( + async ( + payload: CanvasIngressPayload, + options: { canvasPoint?: Point | null; spreadIndex?: number } = {} + ): Promise => { + const spreadIndex = options.spreadIndex ?? 0 + + if (payload.kind === 'internal-node') { + const objectKind = getCanvasObjectKindFromSchema( + payload.data.schemaId, + payload.data.canvasKind + ) + if (!objectKind) { + return null + } + + return placeSourceObject({ + objectKind, + sourceNodeId: payload.data.nodeId, + sourceSchemaId: payload.data.schemaId, + title: payload.data.title, + canvasPoint: options.canvasPoint, + spreadIndex + }) + } + + if (payload.kind === 'url') { + return await ingestUrlPayload(payload.url, options.canvasPoint, spreadIndex) + } + + if (payload.kind === 'file') { + return await ingestFilePayload(payload.file, options.canvasPoint, spreadIndex) + } + + const descriptor = describeExternalReference(payload.text) + if (!descriptor) { + return null + } + + return await ingestUrlPayload(descriptor.normalizedUrl, options.canvasPoint, spreadIndex) + }, + [ingestFilePayload, ingestUrlPayload, placeSourceObject] + ) + + const ingestDataTransfer = useCallback( + async ( + dataTransfer: DataTransfer, + options: { canvasPoint?: Point | null } = {} + ): Promise => { + const payloads = extractCanvasIngressPayloads(dataTransfer) + const results: CanvasIngestionResult[] = [] + + for (const [index, payload] of payloads.entries()) { + const result = await ingestPayload(payload, { + canvasPoint: options.canvasPoint, + spreadIndex: index + }) + + if (result) { + results.push(result) + } + } + + return results + }, + [ingestPayload] + ) + + const ingestText = useCallback( + async ( + text: string, + options: { canvasPoint?: Point | null } = {} + ): Promise => { + return await ingestPayload({ kind: 'text', text }, options) + }, + [ingestPayload] + ) + + return { + placeSourceObject, + ingestPayload, + ingestDataTransfer, + ingestText + } +} diff --git a/packages/canvas/src/index.ts b/packages/canvas/src/index.ts index dcff5473e..b8e9e7e60 100644 --- a/packages/canvas/src/index.ts +++ b/packages/canvas/src/index.ts @@ -46,6 +46,31 @@ export type { export { DEFAULT_CANVAS_CONFIG } from './types' +export { + CANVAS_INTERNAL_NODE_MIME, + serializeCanvasInternalNodeDragData, + parseCanvasInternalNodeDragData, + normalizeExternalReferenceUrl, + describeExternalReference, + inferMediaKind, + getMediaRect, + readImageDimensions, + getCanvasObjectKindFromSchema, + resolveCanvasPlacementRect, + createSourceBackedCanvasNode, + extractCanvasIngressPayloads +} from './ingestion' +export type { + CanvasViewportSnapshot, + CanvasInternalNodeDragData, + CanvasIngressPayload, + CanvasExternalReferenceProvider, + CanvasExternalReferenceKind, + CanvasExternalReferenceDescriptor, + CanvasMediaKind, + CanvasSourceBackedNodeInput +} from './ingestion' + // Rendering layers export { WebGLGridLayer, @@ -134,6 +159,7 @@ export type { CanvasProps, CanvasHandle, CanvasSelectionSnapshot, + CanvasSurfaceEventContext, CanvasRemoteUser, CanvasNodeRenderContext } from './renderer/Canvas' @@ -205,6 +231,13 @@ export type { UseCursorTrackingOptions } from './hooks/useCursorTracking' export { useCanvasKeyboard } from './hooks/useCanvasKeyboard' export type { UseCanvasKeyboardOptions } from './hooks/useCanvasKeyboard' +export { useCanvasObjectIngestion } from './hooks/useCanvasObjectIngestion' +export type { + UseCanvasObjectIngestionOptions, + PlaceCanvasSourceObjectInput, + CanvasIngestionResult +} from './hooks/useCanvasObjectIngestion' + export { useSpacePan } from './hooks/useSpacePan' export type { UseSpacePanOptions } from './hooks/useSpacePan' diff --git a/packages/canvas/src/ingestion.ts b/packages/canvas/src/ingestion.ts new file mode 100644 index 000000000..4f755bd76 --- /dev/null +++ b/packages/canvas/src/ingestion.ts @@ -0,0 +1,575 @@ +/** + * Canvas ingestion utilities. + * + * Normalizes drag/drop, paste, and command-driven payloads into source-backed + * canvas object creation inputs. + */ + +import type { CanvasNode, CanvasObjectKind, Point, Rect } from './types' +import { DatabaseSchema, ExternalReferenceSchema, MediaAssetSchema, PageSchema } from '@xnetjs/data' +import { createNode } from './store' + +export const CANVAS_INTERNAL_NODE_MIME = 'application/x-xnet-canvas-node' +const CANVAS_STACK_OFFSET = 28 +const DEFAULT_MEDIA_RECT = { width: 320, height: 240 } +const MAX_MEDIA_PREVIEW_WIDTH = 420 +const MAX_MEDIA_PREVIEW_HEIGHT = 320 + +export type CanvasViewportSnapshot = { + x: number + y: number + zoom: number +} + +export type CanvasInternalNodeDragData = { + nodeId: string + schemaId: string + title: string + canvasKind?: Extract +} + +export type CanvasIngressPayload = + | { kind: 'internal-node'; data: CanvasInternalNodeDragData } + | { kind: 'url'; url: string } + | { kind: 'file'; file: File } + | { kind: 'text'; text: string } + +export type CanvasExternalReferenceProvider = + | 'github' + | 'figma' + | 'youtube' + | 'loom' + | 'vimeo' + | 'codesandbox' + | 'spotify' + | 'twitter' + | 'generic' + +export type CanvasExternalReferenceKind = + | 'issue' + | 'pull-request' + | 'design' + | 'video' + | 'sandbox' + | 'social' + | 'audio' + | 'link' + +export type CanvasExternalReferenceDescriptor = { + normalizedUrl: string + provider: CanvasExternalReferenceProvider + kind: CanvasExternalReferenceKind + refId?: string + title: string + subtitle?: string + icon?: string + embedUrl?: string + metadata: Record +} + +export type CanvasMediaKind = 'image' | 'video' | 'audio' | 'document' | 'file' + +export type CanvasSourceBackedNodeInput = { + objectKind: Extract< + CanvasObjectKind, + 'page' | 'database' | 'external-reference' | 'media' | 'note' + > + viewport: CanvasViewportSnapshot + sourceNodeId?: string + sourceSchemaId?: string + title?: string + canvasPoint?: Point | null + spreadIndex?: number + rect?: Partial + properties?: Record +} + +export function serializeCanvasInternalNodeDragData(data: CanvasInternalNodeDragData): string { + return JSON.stringify(data) +} + +export function parseCanvasInternalNodeDragData( + value: string | null | undefined +): CanvasInternalNodeDragData | null { + if (!value) { + return null + } + + try { + const parsed = JSON.parse(value) as Partial + if ( + typeof parsed.nodeId !== 'string' || + typeof parsed.schemaId !== 'string' || + typeof parsed.title !== 'string' + ) { + return null + } + + if ( + parsed.canvasKind && + parsed.canvasKind !== 'page' && + parsed.canvasKind !== 'database' && + parsed.canvasKind !== 'note' + ) { + return null + } + + return { + nodeId: parsed.nodeId, + schemaId: parsed.schemaId, + title: parsed.title, + ...(parsed.canvasKind ? { canvasKind: parsed.canvasKind } : {}) + } + } catch { + return null + } +} + +export function normalizeExternalReferenceUrl(input: string): string | null { + const trimmed = input.trim() + if (trimmed.length === 0) { + return null + } + + const candidate = /^https?:\/\//i.test(trimmed) + ? trimmed + : /^[a-z0-9.-]+\.[a-z]{2,}(?:[/?#].*)?$/i.test(trimmed) + ? `https://${trimmed}` + : null + + if (!candidate) { + return null + } + + try { + const url = new URL(candidate) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + + url.username = '' + url.password = '' + url.hash = '' + + const pathname = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '') + return `${url.protocol}//${url.host}${pathname}${url.search}` + } catch { + return null + } +} + +function createGenericExternalReferenceDescriptor( + normalizedUrl: string +): CanvasExternalReferenceDescriptor { + const url = new URL(normalizedUrl) + const hostname = url.hostname.replace(/^www\./i, '') + const pathLabel = `${url.pathname}${url.search}`.trim() || normalizedUrl + + return { + normalizedUrl, + provider: 'generic', + kind: 'link', + title: hostname || normalizedUrl, + subtitle: pathLabel === normalizedUrl ? undefined : pathLabel, + icon: 'LINK', + metadata: { + hostname, + path: url.pathname + } + } +} + +export function describeExternalReference(input: string): CanvasExternalReferenceDescriptor | null { + const normalizedUrl = normalizeExternalReferenceUrl(input) + if (!normalizedUrl) { + return null + } + + const githubIssueMatch = normalizedUrl.match( + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/?].*)?$/i + ) + if (githubIssueMatch) { + const [, owner, repo, number] = githubIssueMatch + return { + normalizedUrl, + provider: 'github', + kind: 'issue', + refId: `${owner}/${repo}#${number}`, + title: `${repo}#${number}`, + subtitle: owner, + icon: 'GH', + metadata: { + owner, + repo, + number, + entity: 'issue' + } + } + } + + const githubPrMatch = normalizedUrl.match( + /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?].*)?$/i + ) + if (githubPrMatch) { + const [, owner, repo, number] = githubPrMatch + return { + normalizedUrl, + provider: 'github', + kind: 'pull-request', + refId: `${owner}/${repo}#${number}`, + title: `${repo} PR #${number}`, + subtitle: owner, + icon: 'PR', + metadata: { + owner, + repo, + number, + entity: 'pull-request' + } + } + } + + const figmaMatch = normalizedUrl.match( + /^https?:\/\/(?:www\.)?figma\.com\/(file|proto)\/([a-z0-9]+)(?:[/?].*)?$/i + ) + if (figmaMatch) { + const [, entity, fileId] = figmaMatch + return { + normalizedUrl, + provider: 'figma', + kind: 'design', + refId: `${entity}/${fileId}`, + title: `Figma ${entity}`, + subtitle: fileId, + icon: 'FG', + embedUrl: `https://www.figma.com/embed?embed_host=xnet&url=https://www.figma.com/${entity}/${fileId}`, + metadata: { + entity, + fileId + } + } + } + + const youtubeMatch = normalizedUrl.match( + /^https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtu\.be\/)([a-z0-9_-]+)/i + ) + if (youtubeMatch) { + const [, videoId] = youtubeMatch + return { + normalizedUrl, + provider: 'youtube', + kind: 'video', + refId: videoId, + title: `YouTube ${videoId}`, + subtitle: 'YouTube', + icon: 'YT', + embedUrl: `https://www.youtube.com/embed/${videoId}`, + metadata: { + videoId + } + } + } + + const vimeoMatch = normalizedUrl.match( + /^https?:\/\/(?:player\.)?vimeo\.com\/(?:video\/)?(\d+)(?:[/?].*)?$/i + ) + if (vimeoMatch) { + const [, videoId] = vimeoMatch + return { + normalizedUrl, + provider: 'vimeo', + kind: 'video', + refId: videoId, + title: `Vimeo ${videoId}`, + subtitle: 'Vimeo', + icon: 'VI', + embedUrl: `https://player.vimeo.com/video/${videoId}`, + metadata: { + videoId + } + } + } + + const loomMatch = normalizedUrl.match( + /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([a-f0-9]+)(?:[/?].*)?$/i + ) + if (loomMatch) { + const [, loomId] = loomMatch + return { + normalizedUrl, + provider: 'loom', + kind: 'video', + refId: loomId, + title: `Loom ${loomId.slice(0, 8)}`, + subtitle: 'Loom', + icon: 'LO', + embedUrl: `https://www.loom.com/embed/${loomId}`, + metadata: { + loomId + } + } + } + + const sandboxMatch = normalizedUrl.match( + /^https?:\/\/(?:www\.)?codesandbox\.io\/(?:s|embed)\/([a-z0-9-]+)(?:[/?].*)?$/i + ) + if (sandboxMatch) { + const [, sandboxId] = sandboxMatch + return { + normalizedUrl, + provider: 'codesandbox', + kind: 'sandbox', + refId: sandboxId, + title: `Sandbox ${sandboxId}`, + subtitle: 'CodeSandbox', + icon: 'CS', + embedUrl: `https://codesandbox.io/embed/${sandboxId}?fontsize=14&hidenavigation=1&theme=dark`, + metadata: { + sandboxId + } + } + } + + const spotifyMatch = normalizedUrl.match( + /^https?:\/\/open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-z0-9]+)(?:[/?].*)?$/i + ) + if (spotifyMatch) { + const [, entity, mediaId] = spotifyMatch + return { + normalizedUrl, + provider: 'spotify', + kind: 'audio', + refId: `${entity}/${mediaId}`, + title: `Spotify ${entity}`, + subtitle: mediaId, + icon: 'SP', + embedUrl: `https://open.spotify.com/embed/${entity}/${mediaId}`, + metadata: { + entity, + mediaId + } + } + } + + const twitterMatch = normalizedUrl.match( + /^https?:\/\/(?:www\.)?(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)(?:[/?].*)?$/i + ) + if (twitterMatch) { + const [, postId] = twitterMatch + return { + normalizedUrl, + provider: 'twitter', + kind: 'social', + refId: postId, + title: `Post ${postId}`, + subtitle: 'X', + icon: 'X', + embedUrl: `https://platform.twitter.com/embed/Tweet.html?id=${postId}`, + metadata: { + postId + } + } + } + + return createGenericExternalReferenceDescriptor(normalizedUrl) +} + +export function inferMediaKind(file: File): CanvasMediaKind { + if (file.type.startsWith('image/')) { + return 'image' + } + + if (file.type.startsWith('video/')) { + return 'video' + } + + if (file.type.startsWith('audio/')) { + return 'audio' + } + + if ( + file.type === 'application/pdf' || + file.type.startsWith('text/') || + file.type.includes('document') || + file.type.includes('presentation') || + file.type.includes('spreadsheet') + ) { + return 'document' + } + + return 'file' +} + +export function getMediaRect(dimensions?: { width?: number; height?: number } | null): { + width: number + height: number +} { + const width = dimensions?.width + const height = dimensions?.height + + if (!width || !height || width <= 0 || height <= 0) { + return DEFAULT_MEDIA_RECT + } + + const scale = Math.min(MAX_MEDIA_PREVIEW_WIDTH / width, MAX_MEDIA_PREVIEW_HEIGHT / height, 1) + return { + width: Math.max(180, Math.round(width * scale)), + height: Math.max(140, Math.round(height * scale)) + } +} + +export async function readImageDimensions( + file: File +): Promise<{ width: number; height: number } | null> { + if (!file.type.startsWith('image/')) { + return null + } + + return await new Promise((resolve) => { + const image = new Image() + const objectUrl = URL.createObjectURL(file) + + image.onload = () => { + URL.revokeObjectURL(objectUrl) + resolve({ + width: image.naturalWidth, + height: image.naturalHeight + }) + } + + image.onerror = () => { + URL.revokeObjectURL(objectUrl) + resolve(null) + } + + image.src = objectUrl + }) +} + +export function getCanvasObjectKindFromSchema( + schemaId: string, + canvasKind?: Extract +): Extract | null { + if (canvasKind === 'note') { + return 'note' + } + + if (schemaId === PageSchema._schemaId) { + return 'page' + } + + if (schemaId === DatabaseSchema._schemaId) { + return 'database' + } + + if (schemaId === ExternalReferenceSchema._schemaId) { + return 'external-reference' + } + + if (schemaId === MediaAssetSchema._schemaId) { + return 'media' + } + + return null +} + +export function resolveCanvasPlacementRect(input: { + objectKind: Extract< + CanvasObjectKind, + 'page' | 'database' | 'external-reference' | 'media' | 'note' + > + viewport: CanvasViewportSnapshot + canvasPoint?: Point | null + spreadIndex?: number + rect?: Partial +}): Rect { + const spreadIndex = input.spreadIndex ?? 0 + const offset = spreadIndex * CANVAS_STACK_OFFSET + const baseNode = createNode(input.objectKind, input.rect) + const width = input.rect?.width ?? baseNode.position.width + const height = input.rect?.height ?? baseNode.position.height + const centerX = input.canvasPoint?.x ?? input.viewport.x + const centerY = input.canvasPoint?.y ?? input.viewport.y + + return { + x: Math.round(centerX - width / 2 + offset), + y: Math.round(centerY - height / 2 + offset), + width, + height + } +} + +export function createSourceBackedCanvasNode(input: CanvasSourceBackedNodeInput): CanvasNode { + const rect = resolveCanvasPlacementRect({ + objectKind: input.objectKind, + viewport: input.viewport, + canvasPoint: input.canvasPoint, + spreadIndex: input.spreadIndex, + rect: input.rect + }) + + const node = createNode(input.objectKind, rect, { + ...(input.title ? { title: input.title } : {}), + ...(input.properties ?? {}) + }) + + if (input.sourceNodeId) { + node.sourceNodeId = input.sourceNodeId + } + + if (input.sourceSchemaId) { + node.sourceSchemaId = input.sourceSchemaId + } + + return node +} + +function getUriListCandidate(dataTransfer: DataTransfer): string | null { + const uriList = dataTransfer.getData('text/uri-list') + if (!uriList) { + return null + } + + const candidate = uriList + .split('\n') + .map((value) => value.trim()) + .find((value) => value.length > 0 && !value.startsWith('#')) + + return candidate ?? null +} + +export function extractCanvasIngressPayloads(dataTransfer: DataTransfer): CanvasIngressPayload[] { + const payloads: CanvasIngressPayload[] = [] + const internalData = parseCanvasInternalNodeDragData( + dataTransfer.getData(CANVAS_INTERNAL_NODE_MIME) + ) + + if (internalData) { + payloads.push({ kind: 'internal-node', data: internalData }) + } + + const files = Array.from(dataTransfer.files ?? []) + if (files.length > 0) { + payloads.push(...files.map((file) => ({ kind: 'file', file }) satisfies CanvasIngressPayload)) + } + + const uriCandidate = getUriListCandidate(dataTransfer) + if (uriCandidate) { + payloads.push({ kind: 'url', url: uriCandidate }) + return payloads + } + + const text = dataTransfer.getData('text/plain').trim() + if (text.length === 0) { + return payloads + } + + const normalizedUrl = normalizeExternalReferenceUrl(text) + if (normalizedUrl) { + payloads.push({ kind: 'url', url: normalizedUrl }) + return payloads + } + + payloads.push({ kind: 'text', text }) + return payloads +} diff --git a/packages/canvas/src/renderer/Canvas.tsx b/packages/canvas/src/renderer/Canvas.tsx index bf113de49..d36833fb2 100644 --- a/packages/canvas/src/renderer/Canvas.tsx +++ b/packages/canvas/src/renderer/Canvas.tsx @@ -61,6 +61,8 @@ export interface CanvasHandle { setViewportSnapshot: (snapshot: { x: number; y: number; zoom: number }) => void /** Clear the current selection */ clearSelection: () => void + /** Convert a client-space point to canvas coordinates */ + screenToCanvas: (clientX: number, clientY: number) => Point } export interface CanvasSelectionSnapshot { @@ -68,6 +70,11 @@ export interface CanvasSelectionSnapshot { edgeIds: string[] } +export interface CanvasSurfaceEventContext { + viewportSnapshot: { x: number; y: number; zoom: number } + screenToCanvas: (clientX: number, clientY: number) => Point +} + export interface CanvasProps { /** Y.Doc containing the canvas data */ doc: Y.Doc @@ -91,6 +98,18 @@ export interface CanvasProps { onToggleShortcutHelp?: () => void /** Callback when transient canvas UI should be dismissed before clearing selection */ onDismissTransientUi?: () => boolean | void + /** Callback when content is dropped on the canvas surface */ + onSurfaceDrop?: ( + event: React.DragEvent, + context: CanvasSurfaceEventContext + ) => void + /** Callback when the user pastes content into the focused canvas surface */ + onSurfacePaste?: ( + event: React.ClipboardEvent, + context: CanvasSurfaceEventContext + ) => void + /** Callback during drag-over for custom drop affordances */ + onSurfaceDragOver?: (event: React.DragEvent) => void /** Yjs Awareness instance for presence (optional) */ awareness?: AwarenessLike | null /** CSS class name */ @@ -219,6 +238,9 @@ export const Canvas = forwardRef(function Canvas( onOpenSelection, onToggleShortcutHelp, onDismissTransientUi, + onSurfaceDrop, + onSurfacePaste, + onSurfaceDragOver, awareness, className, style, @@ -282,6 +304,19 @@ export const Canvas = forwardRef(function Canvas( zoomAt } = canvas + const clientToCanvas = useCallback( + (clientX: number, clientY: number): Point => { + const container = containerRef.current + if (!container) { + return { x: viewport.x, y: viewport.y } + } + + const rect = container.getBoundingClientRect() + return viewport.screenToCanvas(clientX - rect.left, clientY - rect.top) + }, + [viewport] + ) + // Expose imperative methods via ref useImperativeHandle( ref, @@ -292,9 +327,18 @@ export const Canvas = forwardRef(function Canvas( getViewportSnapshot: () => canvas.getViewportSnapshot(), setViewportSnapshot: (snapshot: { x: number; y: number; zoom: number }) => canvas.setViewportSnapshot(snapshot), - clearSelection: () => clearSelection() + clearSelection: () => clearSelection(), + screenToCanvas: (clientX: number, clientY: number) => clientToCanvas(clientX, clientY) }), - [canvas, clearSelection] + [canvas, clearSelection, clientToCanvas] + ) + + const createSurfaceEventContext = useCallback( + (): CanvasSurfaceEventContext => ({ + viewportSnapshot: canvas.getViewportSnapshot(), + screenToCanvas: clientToCanvas + }), + [canvas, clientToCanvas] ) // === Presence: track remote users' selected nodes === @@ -433,6 +477,41 @@ export const Canvas = forwardRef(function Canvas( [clearSelection, onBackgroundClick, pan] ) + const handleSurfaceDragOver = useCallback( + (event: React.DragEvent) => { + if (onSurfaceDrop) { + event.preventDefault() + } + + onSurfaceDragOver?.(event) + }, + [onSurfaceDrop, onSurfaceDragOver] + ) + + const handleSurfaceDrop = useCallback( + (event: React.DragEvent) => { + if (!onSurfaceDrop) { + return + } + + event.preventDefault() + containerRef.current?.focus() + onSurfaceDrop(event, createSurfaceEventContext()) + }, + [createSurfaceEventContext, onSurfaceDrop] + ) + + const handleSurfacePaste = useCallback( + (event: React.ClipboardEvent) => { + if (!onSurfacePaste) { + return + } + + onSurfacePaste(event, createSurfaceEventContext()) + }, + [createSurfaceEventContext, onSurfacePaste] + ) + const handleStepSelection = useCallback( (direction: -1 | 1) => { if (nodes.length === 0) { @@ -723,6 +802,9 @@ export const Canvas = forwardRef(function Canvas( data-viewport-width={viewport.width} data-viewport-height={viewport.height} onMouseDown={handleMouseDown} + onDragOver={handleSurfaceDragOver} + onDrop={handleSurfaceDrop} + onPaste={handleSurfacePaste} tabIndex={0} // Make container focusable for keyboard shortcuts > {/* Grid background is rendered via WebGL/CSS layer (useWebGLGrid hook) */} diff --git a/tests/e2e/src/web-canvas-ingestion.spec.ts b/tests/e2e/src/web-canvas-ingestion.spec.ts new file mode 100644 index 000000000..da45c1357 --- /dev/null +++ b/tests/e2e/src/web-canvas-ingestion.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@playwright/test' +import { setupTestAuth } from '../helpers/test-auth' + +async function advanceOnboarding(page: import('@playwright/test').Page): Promise { + for (let index = 0; index < 4; index += 1) { + const start = page.getByRole('button', { name: /get started with/i }) + if ((await start.count()) > 0 && (await start.first().isVisible())) { + await start.first().click() + await page.waitForTimeout(750) + continue + } + + const ready = page.getByRole('button', { name: /create your first page/i }) + if ((await ready.count()) > 0 && (await ready.first().isVisible())) { + await ready.first().click() + await page.waitForTimeout(750) + continue + } + + break + } +} + +test.describe('Web canvas ingestion', () => { + test('creates source-backed URL and media objects from drops', async ({ page }) => { + await setupTestAuth(page) + await advanceOnboarding(page) + + await expect(page.getByRole('heading', { name: /all documents/i })).toBeVisible({ + timeout: 30_000 + }) + + const main = page.getByRole('main') + await main.getByRole('button', { name: /^New$/i }).click() + await main.getByRole('button', { name: /^Canvas$/ }).click() + + await page.waitForURL(/\/canvas\//, { timeout: 30_000 }) + + const surface = page.locator('[data-canvas-surface="true"]') + await expect(surface).toBeVisible({ timeout: 30_000 }) + + const urlTransfer = await page.evaluateHandle(() => { + const dataTransfer = new DataTransfer() + dataTransfer.setData('text/plain', 'https://github.com/openai/openai/issues/123') + dataTransfer.setData('text/uri-list', 'https://github.com/openai/openai/issues/123') + return dataTransfer + }) + + await surface.dispatchEvent('drop', { + dataTransfer: urlTransfer, + clientX: 360, + clientY: 260 + }) + + const externalReferenceNode = page.locator('.canvas-node[data-node-type="external-reference"]') + await expect(externalReferenceNode).toHaveCount(1, { timeout: 30_000 }) + await expect(externalReferenceNode.first()).toContainText('openai#123', { timeout: 30_000 }) + + const imageTransfer = await page.evaluateHandle(() => { + const dataTransfer = new DataTransfer() + const svg = [ + '', + '', + 'Canvas Drop', + '' + ].join('') + const file = new File([svg], 'canvas-drop.svg', { type: 'image/svg+xml' }) + dataTransfer.items.add(file) + return dataTransfer + }) + + await surface.dispatchEvent('drop', { + dataTransfer: imageTransfer, + clientX: 540, + clientY: 340 + }) + + const mediaNode = page.locator('.canvas-node[data-node-type="media"]') + await expect(mediaNode).toHaveCount(1, { timeout: 30_000 }) + await expect(mediaNode.first()).toContainText('canvas-drop.svg', { timeout: 30_000 }) + + await page.screenshot({ + path: 'tmp/playwright/web-canvas-ingestion.png', + fullPage: true + }) + }) +}) From 2bc2da7f0c463f953f79abb4a6aff5ab3091ed8c Mon Sep 17 00:00:00 2001 From: crs48 Date: Tue, 10 Mar 2026 03:05:16 -0700 Subject: [PATCH 12/42] feat(canvas): add database split preview workflows - keep database previews bounded with a virtualized row window - cap preview loads so split surfaces stay light under dense tables - add an electron split-view workflow from the hud, shortcut, and command palette - extend canvas and electron coverage for split interactions and updated rollout docs --- apps/electron/src/renderer/App.tsx | 81 ++++++++- .../CanvasDatabasePreviewSurface.test.tsx | 169 ++++++++++++++++++ .../CanvasDatabasePreviewSurface.tsx | 158 +++++++++++++--- .../src/renderer/components/CanvasView.tsx | 40 ++++- ...-database-cards-preview-focus-and-split.md | 9 +- ...n-rollout-workbenches-and-release-gates.md | 10 +- docs/plans/plan03_9_83CanvasV2/README.md | 8 +- .../canvas-navigation-shell.test.tsx | 4 +- .../canvas/src/hooks/useCanvasKeyboard.ts | 6 +- packages/canvas/src/renderer/Canvas.tsx | 2 +- tests/e2e/src/electron-canvas.spec.ts | 24 +++ 11 files changed, 466 insertions(+), 45 deletions(-) create mode 100644 apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.test.tsx diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index cfe1466e7..84899b14d 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -35,6 +35,7 @@ type ShellState = | { kind: 'canvas-home' } | { kind: 'page-focus'; docId: string; returnViewport: ViewportSnapshot | null } | { kind: 'database-focus'; docId: string; returnViewport: ViewportSnapshot | null } + | { kind: 'database-split'; docId: string } | { kind: 'settings' } | { kind: 'stories' } @@ -350,6 +351,17 @@ export function App(): React.ReactElement { if (shellState.kind === 'stories') return 'Stories' return null }, [shellState.kind]) + const isCanvasInteractiveShell = + shellState.kind === 'canvas-home' || shellState.kind === 'database-split' + + const openDatabaseSplit = useCallback( + (docId: string) => { + clearTransitionTimer() + setShellState({ kind: 'database-split', docId }) + setActiveNodeId(docId) + }, + [clearTransitionTimer, setActiveNodeId] + ) const handleOpenSettings = useCallback(() => { clearTransitionTimer() @@ -423,13 +435,33 @@ export function App(): React.ReactElement { group: 'Canvas', keywords: ['open', 'focus', 'selection', 'canvas'], when: () => - shellState.kind === 'canvas-home' && + isCanvasInteractiveShell && canvasCommandState.selectionCount === 1 && Boolean(canvasCommandState.selectedSourceId && canvasCommandState.selectedSourceType), execute: () => { canvasViewRef.current?.openSelection('focus') } }, + { + id: 'canvas-open-database-split', + name: 'Open Database in Split View', + description: + canvasCommandState.selectedTitle && canvasCommandState.selectionCount === 1 + ? `Keep ${canvasCommandState.selectedTitle} open beside the canvas` + : 'Open the selected database in a split view beside the canvas', + icon: 'columns', + shortcut: 'Alt+Enter', + group: 'Canvas', + keywords: ['split', 'database', 'canvas', 'preview'], + when: () => + isCanvasInteractiveShell && + canvasCommandState.selectionCount === 1 && + canvasCommandState.selectedDisplayType === 'database' && + Boolean(canvasCommandState.selectedSourceId), + execute: () => { + canvasViewRef.current?.openSelection('split') + } + }, { id: 'canvas-fit-selection', name: 'Fit Selected Object', @@ -437,7 +469,7 @@ export function App(): React.ReactElement { icon: 'layout', group: 'Canvas', keywords: ['fit', 'selection', 'zoom', 'canvas'], - when: () => shellState.kind === 'canvas-home' && canvasCommandState.selectionCount > 0, + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 0, execute: () => { canvasViewRef.current?.fitSelection() } @@ -450,7 +482,7 @@ export function App(): React.ReactElement { shortcut: 'Esc', group: 'Canvas', keywords: ['clear', 'selection', 'canvas'], - when: () => shellState.kind === 'canvas-home' && canvasCommandState.selectionCount > 0, + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 0, execute: () => { canvasViewRef.current?.clearSelection() } @@ -465,7 +497,7 @@ export function App(): React.ReactElement { shortcut: '?', group: 'Canvas', keywords: ['help', 'shortcuts', 'canvas', 'hotkeys'], - when: () => shellState.kind === 'canvas-home', + when: () => isCanvasInteractiveShell, execute: () => { canvasViewRef.current?.toggleShortcutHelp() } @@ -510,6 +542,7 @@ export function App(): React.ReactElement { handleOpenSettings, handleOpenStories, canvasCommandState, + isCanvasInteractiveShell, recentDocuments ] ) @@ -544,6 +577,41 @@ export function App(): React.ReactElement { ) } + if (shellState.kind === 'database-split') { + return ( +
    +
    +
    +
    +
    + + Canvas + Database + + +
    +
    + +
    + +
    +
    +
    +
    + ) + } + return (
    @@ -619,7 +687,7 @@ export function App(): React.ReactElement { className={[ 'absolute inset-0', prefersReducedMotion ? '' : 'transition-all duration-200', - shellState.kind === 'canvas-home' + isCanvasInteractiveShell ? 'opacity-100' : prefersReducedMotion ? 'pointer-events-none opacity-70' @@ -641,13 +709,14 @@ export function App(): React.ReactElement { ) }} onOpenDocument={(docId, docType) => focusDocument(docId, docType, true)} + onOpenDatabaseSplit={openDatabaseSplit} />
    {renderOverlay()} void handleCreateLinkedDocument('page')} onCreateDatabase={() => void handleCreateLinkedDocument('database')} onCreateNote={handleCreateCanvasNote} diff --git a/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.test.tsx b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.test.tsx new file mode 100644 index 000000000..9a33e29b8 --- /dev/null +++ b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.test.tsx @@ -0,0 +1,169 @@ +/** + * @vitest-environment jsdom + */ + +import type { CanvasNode } from '@xnetjs/canvas' +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { CanvasDatabasePreviewSurface } from './CanvasDatabasePreviewSurface' + +const mockUseNode = vi.fn() +const mockUseDatabaseDoc = vi.fn() +const mockUseDatabase = vi.fn() +const mockUseIdentity = vi.fn() + +vi.mock('@xnetjs/data', () => ({ + DatabaseSchema: { + _schemaId: 'xnet://xnet.fyi/Database' + } +})) + +vi.mock('@xnetjs/react', () => ({ + useNode: (...args: unknown[]) => mockUseNode(...args), + useDatabaseDoc: (...args: unknown[]) => mockUseDatabaseDoc(...args), + useDatabase: (...args: unknown[]) => mockUseDatabase(...args), + useIdentity: (...args: unknown[]) => mockUseIdentity(...args) +})) + +beforeAll(() => { + class ResizeObserverMock { + observe() {} + disconnect() {} + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock) +}) + +function createRows(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `row-${index + 1}`, + sortKey: String(index + 1).padStart(4, '0'), + cells: { + title: `Task ${index + 1}`, + status: index % 2 === 0 ? 'todo' : 'done', + owner: `Owner ${index + 1}` + }, + createdAt: 0, + createdBy: 'did:key:test' + })) +} + +function createNode(overrides: Partial = {}): CanvasNode { + return { + id: 'canvas-db', + type: 'database', + position: { x: 0, y: 0, width: 420, height: 320 }, + properties: { title: 'Roadmap DB' }, + ...overrides + } as CanvasNode +} + +describe('CanvasDatabasePreviewSurface', () => { + beforeEach(() => { + mockUseIdentity.mockReturnValue({ did: 'did:key:test' }) + mockUseNode.mockReturnValue({ + data: { + id: 'db-1', + title: 'Roadmap DB', + rowCount: 40, + defaultView: 'table' + }, + loading: false, + update: vi.fn() + }) + mockUseDatabaseDoc.mockReturnValue({ + columns: [ + { id: 'title', name: 'Title', type: 'text', isTitle: true, width: 240, config: {} }, + { + id: 'status', + name: 'Status', + type: 'select', + width: 140, + config: { + options: [ + { id: 'todo', name: 'Todo' }, + { id: 'done', name: 'Done' } + ] + } + }, + { id: 'owner', name: 'Owner', type: 'text', width: 180, config: {} } + ], + views: [{ id: 'table-view', name: 'Table', type: 'table' }], + loading: false, + createColumn: vi.fn(), + createView: vi.fn() + }) + }) + + it('renders a bounded virtual preview window and exposes split/open actions', () => { + mockUseDatabase.mockReturnValue({ + rows: createRows(24), + loading: false, + loadingMore: false, + hasMore: false, + loadMore: vi.fn(), + activeView: { id: 'table-view', name: 'Table', type: 'table' } + }) + + const onOpenDocument = vi.fn() + const onSplitDocument = vi.fn() + + const { container } = render( + + ) + + expect(screen.getByRole('button', { name: 'Split' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Open' })).toBeTruthy() + expect(container.querySelectorAll('[data-canvas-database-row="true"]').length).toBeLessThan(24) + expect( + container + .querySelector('[data-canvas-database-rows="true"]') + ?.getAttribute('data-canvas-database-preview-total') + ).toBe('24') + expect(screen.getByText('Showing 24 of 40')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Split' })) + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + + expect(onSplitDocument).toHaveBeenCalledWith('db-1') + expect(onOpenDocument).toHaveBeenCalledWith('db-1') + }) + + it('loads more preview rows when scrolling near the bottom of the bounded window', () => { + const loadMore = vi.fn() + mockUseDatabase.mockReturnValue({ + rows: createRows(12), + loading: false, + loadingMore: false, + hasMore: true, + loadMore, + activeView: { id: 'table-view', name: 'Table', type: 'table' } + }) + + const { container } = render( + + ) + + const rowsContainer = container.querySelector('[data-canvas-database-rows="true"]') + expect(rowsContainer).toBeTruthy() + + Object.defineProperty(rowsContainer as HTMLElement, 'clientHeight', { + configurable: true, + value: 220 + }) + Object.defineProperty(rowsContainer as HTMLElement, 'scrollHeight', { + configurable: true, + value: 620 + }) + + fireEvent.scroll(rowsContainer as HTMLElement, { target: { scrollTop: 420 } }) + + expect(loadMore).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx index b46dcceaa..ab16b145a 100644 --- a/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx +++ b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx @@ -9,8 +9,15 @@ type CanvasDatabasePreviewSurfaceProps = { node: CanvasNode docId: string onOpenDocument?: (docId: string) => void + onSplitDocument?: (docId: string) => void } +const PREVIEW_INITIAL_ROWS = 12 +const PREVIEW_MAX_ROWS = 24 +const PREVIEW_ROW_HEIGHT = 44 +const PREVIEW_OVERSCAN = 3 +const PREVIEW_DEFAULT_VIEWPORT_HEIGHT = PREVIEW_ROW_HEIGHT * 5 + function useStableTitle(initialTitle: string, onCommit: (title: string) => Promise) { const [localTitle, setLocalTitle] = useState(initialTitle) const isEditingRef = useRef(false) @@ -108,7 +115,8 @@ function formatCellValue(value: CellValue, column: ColumnDefinition): string { export function CanvasDatabasePreviewSurface({ node, docId, - onOpenDocument + onOpenDocument, + onSplitDocument }: CanvasDatabasePreviewSurfaceProps): React.ReactElement { const { did } = useIdentity() const { @@ -127,10 +135,16 @@ export function CanvasDatabasePreviewSurface({ const { rows, loading: rowsLoading, + loadingMore, + hasMore, + loadMore, activeView } = useDatabase(docId, { - pageSize: 8 + pageSize: PREVIEW_INITIAL_ROWS }) + const scrollContainerRef = useRef(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewportHeight, setViewportHeight] = useState(PREVIEW_DEFAULT_VIEWPORT_HEIGHT) const orderedColumns = useMemo( () => [ @@ -140,11 +154,12 @@ export function CanvasDatabasePreviewSurface({ [columns] ) const previewColumns = useMemo(() => orderedColumns.slice(0, 3), [orderedColumns]) - const previewRows = useMemo(() => rows.slice(0, 5), [rows]) + const previewRows = useMemo(() => rows.slice(0, PREVIEW_MAX_ROWS), [rows]) const rowCount = Math.max( typeof database?.rowCount === 'number' ? database.rowCount : 0, rows.length ) + const previewCap = Math.min(rowCount, PREVIEW_MAX_ROWS) const title = database?.title ?? node.alias ?? (node.properties.title as string) ?? 'Untitled Database' const commitTitle = useCallback( @@ -161,6 +176,14 @@ export function CanvasDatabasePreviewSurface({ [docId, onOpenDocument] ) + const handleSplitDocument = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + onSplitDocument?.(docId) + }, + [docId, onSplitDocument] + ) + const handleStartTable = useCallback( (event: React.MouseEvent) => { event.stopPropagation() @@ -209,6 +232,76 @@ export function CanvasDatabasePreviewSurface({ const activeViewType = activeView?.type ?? database?.defaultView ?? 'table' const isEmpty = columns.length === 0 const isLoading = nodeLoading || (!isEmpty && (docLoading || rowsLoading)) + const canLoadMorePreviewRows = hasMore && previewRows.length < PREVIEW_MAX_ROWS + + useEffect(() => { + const scrollContainer = scrollContainerRef.current + if (!scrollContainer) { + return + } + + const updateViewportHeight = () => { + setViewportHeight( + Math.max( + scrollContainer.clientHeight || PREVIEW_DEFAULT_VIEWPORT_HEIGHT, + PREVIEW_ROW_HEIGHT + ) + ) + } + + updateViewportHeight() + + const resizeObserver = new ResizeObserver(updateViewportHeight) + resizeObserver.observe(scrollContainer) + + return () => { + resizeObserver.disconnect() + } + }, [previewRows.length]) + + const virtualWindow = useMemo(() => { + const totalRows = previewRows.length + if (totalRows === 0) { + return { + startIndex: 0, + endIndex: 0, + items: [] as typeof previewRows, + paddingTop: 0, + paddingBottom: 0 + } + } + + const boundedViewportHeight = Math.max(viewportHeight, PREVIEW_ROW_HEIGHT) + const startIndex = Math.max(0, Math.floor(scrollTop / PREVIEW_ROW_HEIGHT) - PREVIEW_OVERSCAN) + const endIndex = Math.min( + totalRows, + Math.ceil((scrollTop + boundedViewportHeight) / PREVIEW_ROW_HEIGHT) + PREVIEW_OVERSCAN + ) + + return { + startIndex, + endIndex, + items: previewRows.slice(startIndex, endIndex), + paddingTop: startIndex * PREVIEW_ROW_HEIGHT, + paddingBottom: Math.max(0, (totalRows - endIndex) * PREVIEW_ROW_HEIGHT) + } + }, [previewRows, scrollTop, viewportHeight]) + + const handleRowsScroll = useCallback( + (event: React.UIEvent) => { + const nextScrollTop = event.currentTarget.scrollTop + setScrollTop(nextScrollTop) + + const nearBottom = + nextScrollTop + event.currentTarget.clientHeight >= + event.currentTarget.scrollHeight - PREVIEW_ROW_HEIGHT * 2 + + if (nearBottom && canLoadMorePreviewRows && !loadingMore) { + void loadMore() + } + }, + [canLoadMorePreviewRows, loadMore, loadingMore] + ) return (
    + ) : null} + {selectedCanvasObject.displayType === 'database' && + selectedCanvasObject.sourceId ? ( + + ) : null} ) : null} @@ -674,6 +706,7 @@ export const CanvasView = forwardRef(function ['Tab', 'Step through canvas objects'], ['Arrow keys', 'Pan the board or nudge the selection'], ['Enter', 'Peek or edit the selected object'], + ['Alt+Enter', 'Open the selected database beside the canvas'], ['Mod+Enter', 'Open the focused page or database view'], ['Mod+Shift+P', 'Open the command palette'], ['Mod+1 / Mod+0', 'Fit content or reset the camera'], @@ -762,6 +795,7 @@ export const CanvasView = forwardRef(function node={node} docId={sourceNodeId} onOpenDocument={(targetDocId) => onOpenDocument?.(targetDocId, 'database')} + onSplitDocument={onOpenDatabaseSplit} /> ) } diff --git a/docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md b/docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md index af9f005f6..ce4d3e0c9 100644 --- a/docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md +++ b/docs/plans/plan03_9_83CanvasV2/06-database-cards-preview-focus-and-split.md @@ -104,8 +104,9 @@ const rows = preview.rows.slice(0, 8) Suggested commands: ```bash -pnpm --filter @xnetjs/react test -pnpm --filter @xnetjs/views test +pnpm --filter xnet-desktop exec vitest run src/renderer/components/CanvasDatabasePreviewSurface.test.tsx +pnpm --filter xnet-desktop build +pnpm --filter @xnetjs/e2e-tests exec playwright test src/electron-canvas.spec.ts --project=chromium ``` ## Risks and Edge Cases @@ -118,7 +119,7 @@ pnpm --filter @xnetjs/views test - [x] Add database object creation and placement using real `Database` nodes. - [x] Render bounded live preview cards backed by `useDatabase` and `useDatabaseDoc`. -- [ ] Limit preview density and virtualize heavy preview surfaces when needed. +- [x] Limit preview density and virtualize heavy preview surfaces when needed. - [x] Preserve focused full-database workflows. -- [ ] Add optional split canvas + database workflows after focus/open is stable. +- [x] Add optional split canvas + database workflows after focus/open is stable. - [ ] Add alias/backlink support once preview/focus behavior is solid. diff --git a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md index 40e1a7242..2f08ecdb8 100644 --- a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md +++ b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md @@ -140,10 +140,10 @@ Suggested commands: ```bash pnpm --filter @xnetjs/canvas test -pnpm --filter @xnetjs/react test -pnpm --filter @xnetjs/data test +pnpm --filter xnet-desktop exec vitest run src/renderer/components/CanvasDatabasePreviewSurface.test.tsx +pnpm --filter xnet-desktop build pnpm --filter @xnetjs/e2e-tests exec playwright test src/web-canvas-ingestion.spec.ts --project=chromium -cd tests/e2e && pnpm exec playwright test src/electron-canvas.spec.ts --project=chromium +pnpm --filter @xnetjs/e2e-tests exec playwright test src/electron-canvas.spec.ts --project=chromium pnpm dev:stories cd apps/electron && pnpm dev cd apps/electron && pnpm dev:both @@ -162,12 +162,16 @@ Manual validation should include: Automated validation should include: +- Electron component coverage for: + - bounded database preview virtualization + - split/open actions on the canvas database surface - Electron CDP smoke coverage for: - shell boot - dock creation - command-palette creation - minimap toggle - page/database focus-return flows + - database split-view open/close flows - Web Playwright smoke coverage for: - URL drops creating source-backed `ExternalReference` cards - image/file drops creating source-backed `MediaAsset` cards diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md index ff0e38eb0..65e45e375 100644 --- a/docs/plans/plan03_9_83CanvasV2/README.md +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -267,12 +267,12 @@ flowchart LR - [ ] Route the main runtime through chunking, culling, and explicit layer display lists. - [x] Add universal drop ingestion for internal drags, URLs, text, images, and files. - [ ] Ship live page cards with inline editing and peek behavior. -- [ ] Ship database preview cards with focus/open and split workflows. +- [x] Ship database preview cards with focus/open and split workflows. - [ ] Add connector bindings, shapes, groups, locks, align/tidy operations, and aliases/backlinks. - [ ] Define and implement the full shortcut/command surface for Canvas V2. - [ ] Integrate collaboration, undo, comments, and accessibility into the new scene/runtime model. - [ ] Build Storybook and manual validation scenes that reflect the real Canvas V2 object model. -- [ ] Add Electron CDP e2e coverage for canvas creation, minimap, command palette, drag/drop, and focused-surface transitions. +- [x] Add Electron CDP e2e coverage for canvas creation, minimap, command palette, drag/drop, and focused-surface transitions. - [x] Add large-scene performance harnesses with DOM-count, query-churn, and frame-budget assertions. - [ ] Validate Electron-first performance and interaction budgets before web rollout. @@ -280,8 +280,8 @@ flowchart LR - [x] Creating a page on the canvas immediately creates a real `Page` node and supports inline editing. - [x] Creating a database on the canvas immediately creates a real `Database` node and shows a bounded live preview. -- [ ] Dropping a URL creates or reuses an `ExternalReference` node and renders the correct fallback chain. -- [ ] Dropping an image or file creates a reusable media node and preserves it after reload. +- [x] Dropping a URL creates or reuses an `ExternalReference` node and renders the correct fallback chain. +- [x] Dropping an image or file creates a reusable media node and preserves it after reload. - [x] Web Playwright smoke coverage verifies URL and image drops create source-backed canvas objects. - [ ] Pan/zoom remains smooth on large scenes with chunk load/evict active. - [x] The background grid and minimap remain outside the main DOM path. diff --git a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx index 253386cda..d667fb74c 100644 --- a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx +++ b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx @@ -318,12 +318,14 @@ describe('Canvas navigation shell', () => { fireEvent.keyDown(window, { key: 'Tab' }) fireEvent.keyDown(window, { key: 'P' }) fireEvent.keyDown(window, { key: '/', shiftKey: true }) + fireEvent.keyDown(window, { key: 'Enter', altKey: true }) fireEvent.keyDown(window, { key: 'Enter', metaKey: true }) expect(canvasMock.selectNode).toHaveBeenCalledWith('page-2') expect(onCreateObject).toHaveBeenCalledWith('page') expect(onToggleShortcutHelp).toHaveBeenCalledOnce() - expect(onOpenSelection).toHaveBeenCalledWith('focus') + expect(onOpenSelection).toHaveBeenNthCalledWith(1, 'split') + expect(onOpenSelection).toHaveBeenNthCalledWith(2, 'focus') }) it('nudges the current selection instead of panning when arrow shortcuts are used', () => { diff --git a/packages/canvas/src/hooks/useCanvasKeyboard.ts b/packages/canvas/src/hooks/useCanvasKeyboard.ts index ab76bd45e..84712271c 100644 --- a/packages/canvas/src/hooks/useCanvasKeyboard.ts +++ b/packages/canvas/src/hooks/useCanvasKeyboard.ts @@ -9,7 +9,7 @@ * - Arrow keys: Pan viewport or nudge selection * - Tab / Shift+Tab: Step selection * - P / D / N: Create page, database, note - * - Enter / Ctrl+Enter: Peek or open selection + * - Enter / Alt+Enter / Ctrl+Enter: Peek, split, or open selection * - ?: Toggle shortcut help */ @@ -23,7 +23,7 @@ import { Viewport } from '../spatial/index' export type CanvasCreationShortcut = 'page' | 'database' | 'note' -export type CanvasOpenShortcutMode = 'peek' | 'focus' +export type CanvasOpenShortcutMode = 'peek' | 'focus' | 'split' export interface UseCanvasKeyboardOptions { /** Canvas surface element used to scope shortcuts */ @@ -210,7 +210,7 @@ export function useCanvasKeyboard({ if (e.key === 'Enter' && selectedNodeCount > 0) { e.preventDefault() - onOpenSelection?.(isMod ? 'focus' : 'peek') + onOpenSelection?.(isMod ? 'focus' : e.altKey ? 'split' : 'peek') return } diff --git a/packages/canvas/src/renderer/Canvas.tsx b/packages/canvas/src/renderer/Canvas.tsx index d36833fb2..ce6453e28 100644 --- a/packages/canvas/src/renderer/Canvas.tsx +++ b/packages/canvas/src/renderer/Canvas.tsx @@ -93,7 +93,7 @@ export interface CanvasProps { /** Callback when the user triggers a canvas creation shortcut */ onCreateObject?: (kind: 'page' | 'database' | 'note') => void /** Callback when the user triggers a selection open/peek shortcut */ - onOpenSelection?: (mode: 'peek' | 'focus') => void + onOpenSelection?: (mode: 'peek' | 'focus' | 'split') => void /** Callback when the user toggles canvas shortcut help */ onToggleShortcutHelp?: () => void /** Callback when transient canvas UI should be dismissed before clearing selection */ diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts index cce8b652b..de6ae3b60 100644 --- a/tests/e2e/src/electron-canvas.spec.ts +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -13,6 +13,7 @@ const ELECTRON_CDP_URL = `http://127.0.0.1:${ELECTRON_CDP_PORT}` const RENDERER_URLS = [`http://localhost:${RENDERER_PORT}`, `http://127.0.0.1:${RENDERER_PORT}`] const COMMAND_PALETTE_SHORTCUT = process.platform === 'darwin' ? 'Meta+Shift+P' : 'Control+Shift+P' const FOCUSED_OPEN_SHORTCUT = process.platform === 'darwin' ? 'Meta+Enter' : 'Control+Enter' +const SPLIT_OPEN_SHORTCUT = 'Alt+Enter' const PNPM_BIN = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const ELECTRON_PROFILE_PATH = join( homedir(), @@ -814,6 +815,29 @@ test.describe('Electron canvas shell', () => { }) } + const splitButton = page.locator('[data-canvas-database-split="true"]').first() + await expect(splitButton).toBeVisible({ timeout: 30_000 }) + await splitButton.evaluate((button: HTMLButtonElement) => button.click()) + await expect(page.locator('[data-database-split-panel="true"]')).toBeVisible({ + timeout: 30_000 + }) + await expect(page.locator('[data-canvas-surface="true"]')).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: 'Close split' }).click({ force: true }) + await expect(page.locator('[data-database-split-panel="true"]')).toHaveCount(0, { + timeout: 30_000 + }) + await expect(databaseSurface).toBeVisible({ timeout: 30_000 }) + + await page.locator('[data-canvas-surface="true"]').focus() + await page.keyboard.press(SPLIT_OPEN_SHORTCUT) + await expect(page.locator('[data-database-split-panel="true"]')).toBeVisible({ + timeout: 30_000 + }) + await page.getByRole('button', { name: 'Close split' }).click({ force: true }) + await expect(page.locator('[data-database-split-panel="true"]')).toHaveCount(0, { + timeout: 30_000 + }) + await page .locator('[data-canvas-database-open="true"]') .first() From fa2ab39f1f4cf7972ceb57516d06b34813a65b6b Mon Sep 17 00:00:00 2001 From: crs48 Date: Tue, 10 Mar 2026 03:16:51 -0700 Subject: [PATCH 13/42] feat(canvas): add hybrid overview display lists - build shared canvas display lists for visible, DOM, and overview object sets - render far-field objects through a canvas overview layer and keep dense DOM islands bounded - aggregate dense minimap rendering and extend web/Electron verification for the new runtime metrics --- .../02-hybrid-shell-and-renderer-runtime.md | 8 +- .../03-spatial-runtime-and-query-evolution.md | 12 +- .../canvas-navigation-shell.test.tsx | 52 +++++- .../canvas/src/__tests__/display-list.test.ts | 88 +++++++++ packages/canvas/src/components/Minimap.tsx | 100 +++++++++- packages/canvas/src/renderer/Canvas.tsx | 76 ++++---- .../src/renderer/OverviewCanvasLayer.tsx | 174 ++++++++++++++++++ packages/canvas/src/renderer/display-list.ts | 139 ++++++++++++++ tests/e2e/src/electron-canvas.spec.ts | 42 ++++- tests/e2e/src/web-canvas-ingestion.spec.ts | 7 + 10 files changed, 648 insertions(+), 50 deletions(-) create mode 100644 packages/canvas/src/__tests__/display-list.test.ts create mode 100644 packages/canvas/src/renderer/OverviewCanvasLayer.tsx create mode 100644 packages/canvas/src/renderer/display-list.ts diff --git a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md index 9769dc7cd..4423ef9b7 100644 --- a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md +++ b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md @@ -144,8 +144,10 @@ function CanvasRuntime(props: CanvasRuntimeProps): React.ReactElement { Suggested commands: ```bash -pnpm --filter @xnetjs/canvas test -pnpm dev:stories +pnpm --filter @xnetjs/canvas exec vitest run src/__tests__/display-list.test.ts src/__tests__/canvas-navigation-shell.test.tsx src/__tests__/minimap.test.ts +pnpm --filter xnet-desktop build +pnpm --filter @xnetjs/e2e-tests exec playwright test src/electron-canvas.spec.ts --project=chromium +PLAYWRIGHT_TEST_BASE_URL=http://localhost:5173 pnpm --filter @xnetjs/e2e-tests exec playwright test src/web-canvas-ingestion.spec.ts --project=chromium ``` ## Risks and Edge Cases @@ -158,7 +160,7 @@ pnpm dev:stories - [ ] Introduce the Canvas V2 runtime host and make it the primary render entry. - [x] Move the grid and minimap into the default shell path. -- [ ] Define explicit responsibilities for background, overview, DOM, and overlay layers. +- [x] Define explicit responsibilities for background, overview, DOM, and overlay layers. - [x] Replace the current custom linked-card shell rendering with runtime-fed object rendering. - [ ] Keep persistent shell chrome minimal and contextual. - [ ] Centralize frame scheduling and redraw ownership. diff --git a/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md b/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md index 517f93435..9643c2a5c 100644 --- a/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md +++ b/docs/plans/plan03_9_83CanvasV2/03-spatial-runtime-and-query-evolution.md @@ -147,8 +147,10 @@ type SpatialQueryDescriptor = QueryDescriptor & { Suggested commands: ```bash -pnpm --filter @xnetjs/canvas test -pnpm --filter @xnetjs/react test +pnpm --filter @xnetjs/canvas exec vitest run src/__tests__/display-list.test.ts src/__tests__/canvas-navigation-shell.test.tsx src/__tests__/minimap.test.ts +pnpm --filter xnet-desktop build +pnpm --filter @xnetjs/e2e-tests exec playwright test src/electron-canvas.spec.ts --project=chromium +PLAYWRIGHT_TEST_BASE_URL=http://localhost:5173 pnpm --filter @xnetjs/e2e-tests exec playwright test src/web-canvas-ingestion.spec.ts --project=chromium ``` ## Risks and Edge Cases @@ -160,9 +162,9 @@ pnpm --filter @xnetjs/react test ## Step Checklist - [ ] Promote chunk loading/eviction into the primary Canvas V2 runtime. -- [ ] Route visible-object selection through the existing R-tree search path. -- [ ] Build overview and interactive display lists from a shared visibility pipeline. -- [ ] Gate DOM mounts behind visibility, zoom, and interaction state. +- [x] Route visible-object selection through the existing R-tree search path. +- [x] Build overview and interactive display lists from a shared visibility pipeline. +- [x] Gate DOM mounts behind visibility, zoom, and interaction state. - [ ] Extend `useQuery`/`QueryDescriptor` only where Canvas V2 genuinely benefits. - [ ] Add viewport-window and future geospatial query coverage around `useQuery`. - [ ] Add telemetry and benchmark coverage for display-list and query behavior. diff --git a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx index d667fb74c..31b4ab59d 100644 --- a/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx +++ b/packages/canvas/src/__tests__/canvas-navigation-shell.test.tsx @@ -53,11 +53,16 @@ beforeAll(() => { Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { configurable: true, value: vi.fn(() => ({ + setTransform: vi.fn(), + clearRect: vi.fn(), scale: vi.fn(), fillRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), + quadraticCurveTo: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), stroke: vi.fn(), strokeRect: vi.fn() })) @@ -253,7 +258,7 @@ describe('Canvas navigation shell', () => { clusterColumns: 6, clusterRows: 4 }) - const visibleNodes = scene.nodes.slice(0, 28) + const visibleNodes = scene.nodes.slice(0, 84) const canvasMock = createCanvasMock() canvasMock.nodes = scene.nodes canvasMock.edges = scene.edges @@ -270,13 +275,54 @@ describe('Canvas navigation shell', () => { expect(surface?.dataset.nodeCount).toBe(String(scene.nodeCount)) expect(surface?.dataset.visibleNodeCount).toBe(String(visibleNodes.length)) + expect(surface?.dataset.canvasRenderMode).toBe('hybrid') + expect(surface?.dataset.domNodeCount).toBe('48') + expect(surface?.dataset.overviewNodeCount).toBe(String(visibleNodes.length - 48)) expect(Number(surface?.dataset.visibleEdgeCount ?? 0)).toBeLessThanOrEqual(scene.edgeCount) - expect(document.querySelectorAll('.canvas-node')).toHaveLength(visibleNodes.length) - expect(renderNode).toHaveBeenCalledTimes(visibleNodes.length) + expect(document.querySelectorAll('.canvas-node')).toHaveLength(48) + expect(renderNode).toHaveBeenCalledTimes(48) expect(minimap?.dataset.canvasMinimapNodeCount).toBe(String(scene.nodeCount)) expect(minimap?.dataset.canvasMinimapEdgeCount).toBe(String(scene.edgeCount)) }) + it('selects far-field overview nodes via hit testing before mounting a DOM island', () => { + const farFieldNode = { + id: 'page-1', + type: 'page', + position: { x: 60, y: 80, width: 320, height: 220 }, + properties: { title: 'Canvas Page' } + } + const canvasMock = createCanvasMock() + canvasMock.nodes = Array.from({ length: 96 }, (_, index) => ({ + id: `node-${index}`, + type: 'page', + position: { + x: index * 40, + y: index * 30, + width: 220, + height: 160 + }, + properties: { title: `Node ${index}` } + })) + canvasMock.store.getVisibleNodes = vi.fn(() => canvasMock.nodes) + canvasMock.findNodeAt = vi.fn(() => farFieldNode) + + mockUseCanvas.mockReturnValue(canvasMock) + + render() + + const surface = document.querySelector('[data-canvas-surface="true"]') + expect(surface).toBeTruthy() + + fireEvent.mouseDown(surface as HTMLElement, { + clientX: 320, + clientY: 240, + button: 0 + }) + + expect(canvasMock.selectNode).toHaveBeenCalledWith('page-1', false) + }) + it('dispatches canvas creation, help, and selection-open shortcuts when focused', () => { const nodes = [ { diff --git a/packages/canvas/src/__tests__/display-list.test.ts b/packages/canvas/src/__tests__/display-list.test.ts new file mode 100644 index 000000000..f6e73a00f --- /dev/null +++ b/packages/canvas/src/__tests__/display-list.test.ts @@ -0,0 +1,88 @@ +import type { CanvasEdge, CanvasNode } from '../types' +import { describe, expect, it } from 'vitest' +import { createCanvasDisplayList } from '../renderer/display-list' +import { createViewport } from '../spatial' + +function createNode(id: string, x: number, y: number, width = 240, height = 160): CanvasNode { + return { + id, + type: 'page', + position: { + x, + y, + width, + height, + zIndex: 0 + }, + properties: { + title: id + } + } +} + +function createEdge(id: string, sourceId: string, targetId: string): CanvasEdge { + return { + id, + sourceId, + targetId + } +} + +describe('createCanvasDisplayList', () => { + it('keeps all visible nodes in the DOM for sparse scenes', () => { + const viewport = createViewport({ + x: 400, + y: 300, + zoom: 1, + width: 800, + height: 600 + }) + const nodes = [createNode('node-1', 100, 120), createNode('node-2', 420, 220)] + const edges = [createEdge('edge-1', 'node-1', 'node-2')] + + const result = createCanvasDisplayList({ + viewport, + nodes, + edges, + store: { + getVisibleNodes: () => nodes + }, + selectedNodeIds: new Set() + }) + + expect(result.visibleNodes).toHaveLength(2) + expect(result.domNodes).toHaveLength(2) + expect(result.overviewNodes).toHaveLength(0) + expect(result.visibleEdges).toHaveLength(1) + }) + + it('bounds DOM mounts for dense scenes while keeping selected nodes interactive', () => { + const viewport = createViewport({ + x: 1200, + y: 900, + zoom: 1, + width: 1280, + height: 720 + }) + const nodes = Array.from({ length: 96 }, (_, index) => + createNode(`node-${index + 1}`, (index % 12) * 220, Math.floor(index / 12) * 190) + ) + const selectedNodeIds = new Set(['node-96']) + + const result = createCanvasDisplayList({ + viewport, + nodes, + edges: [], + store: { + getVisibleNodes: () => nodes + }, + selectedNodeIds, + domNodeLimit: 24 + }) + + expect(result.visibleNodes).toHaveLength(96) + expect(result.domNodes).toHaveLength(24) + expect(result.overviewNodes).toHaveLength(72) + expect(result.domNodeIds.has('node-96')).toBe(true) + }) +}) diff --git a/packages/canvas/src/components/Minimap.tsx b/packages/canvas/src/components/Minimap.tsx index a1c710375..e519922cd 100644 --- a/packages/canvas/src/components/Minimap.tsx +++ b/packages/canvas/src/components/Minimap.tsx @@ -11,6 +11,21 @@ import { Viewport } from '../spatial/index' // ─── Types ──────────────────────────────────────────────────────────────────── +const MAX_DIRECT_MINIMAP_NODES = 1200 +const MINIMAP_BUCKET_SIZE_PX = 6 + +type MinimapRenderNode = { + id: string + type: CanvasNode['type'] + position: { + x: number + y: number + width: number + height: number + zIndex?: number + } +} + export interface MinimapProps { /** All canvas nodes */ nodes: CanvasNode[] @@ -34,7 +49,7 @@ export interface MinimapProps { // ─── Color Helpers ──────────────────────────────────────────────────────────── -function getNodeMinimapColor(node: CanvasNode): string { +function getNodeMinimapColor(node: Pick): string { switch (node.type) { case 'page': return 'rgba(59, 130, 246, 0.7)' @@ -125,6 +140,78 @@ export function Minimap({ } }, [canvasBounds, scale, width, height]) + const { renderNodes, renderMode } = useMemo(() => { + if (nodes.length <= MAX_DIRECT_MINIMAP_NODES) { + return { + renderNodes: nodes as MinimapRenderNode[], + renderMode: 'full' as const + } + } + + const buckets = new Map< + string, + { + id: string + minX: number + minY: number + maxX: number + maxY: number + typeCounts: Map + } + >() + + for (const node of nodes) { + const centerX = (node.position.x + node.position.width / 2) * scale + offset.x + const centerY = (node.position.y + node.position.height / 2) * scale + offset.y + const bucketX = Math.floor(centerX / MINIMAP_BUCKET_SIZE_PX) + const bucketY = Math.floor(centerY / MINIMAP_BUCKET_SIZE_PX) + const bucketKey = `${bucketX}:${bucketY}` + const bucket = buckets.get(bucketKey) + + if (bucket) { + bucket.minX = Math.min(bucket.minX, node.position.x) + bucket.minY = Math.min(bucket.minY, node.position.y) + bucket.maxX = Math.max(bucket.maxX, node.position.x + node.position.width) + bucket.maxY = Math.max(bucket.maxY, node.position.y + node.position.height) + bucket.typeCounts.set(node.type, (bucket.typeCounts.get(node.type) ?? 0) + 1) + } else { + buckets.set(bucketKey, { + id: `bucket:${bucketKey}`, + minX: node.position.x, + minY: node.position.y, + maxX: node.position.x + node.position.width, + maxY: node.position.y + node.position.height, + typeCounts: new Map([[node.type, 1]]) + }) + } + } + + const aggregatedNodes = Array.from(buckets.values()).map((bucket) => { + const dominantType = + Array.from(bucket.typeCounts.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? + 'group' + + return { + id: bucket.id, + type: dominantType, + position: { + x: bucket.minX, + y: bucket.minY, + width: Math.max(bucket.maxX - bucket.minX, 40), + height: Math.max(bucket.maxY - bucket.minY, 30), + zIndex: 0 + } + } satisfies MinimapRenderNode + }) + + return { + renderNodes: aggregatedNodes, + renderMode: 'aggregated' as const + } + }, [nodes, offset.x, offset.y, scale]) + + const shouldRenderEdges = showEdges && renderMode === 'full' + // Render minimap useEffect(() => { const canvas = canvasRef.current @@ -147,7 +234,7 @@ export function Minimap({ ctx.fillRect(0, 0, width, height) // Draw edges - if (showEdges && edges.length > 0) { + if (shouldRenderEdges && edges.length > 0) { ctx.strokeStyle = 'rgba(156, 163, 175, 0.4)' ctx.lineWidth = 1 ctx.beginPath() @@ -172,7 +259,7 @@ export function Minimap({ } // Draw nodes (frames/groups first, then regular nodes on top) - const sortedNodes = [...nodes].sort((a, b) => { + const sortedNodes = [...renderNodes].sort((a, b) => { const aIsContainer = a.type === 'frame' || a.type === 'group' const bIsContainer = b.type === 'frame' || b.type === 'group' if (aIsContainer && !bIsContainer) return -1 @@ -219,16 +306,16 @@ export function Minimap({ ctx.lineWidth = 1 ctx.strokeRect(0.5, 0.5, width - 1, height - 1) }, [ - nodes, edges, viewport, canvasBounds, scale, offset, + renderNodes, width, height, backgroundColor, - showEdges + shouldRenderEdges ]) // Convert minimap coordinates to canvas coordinates @@ -306,8 +393,11 @@ export function Minimap({ }} data-canvas-minimap="true" data-canvas-minimap-node-count={nodes.length} + data-canvas-minimap-rendered-node-count={renderNodes.length} data-canvas-minimap-edge-count={edges.length} data-canvas-minimap-show-edges={showEdges ? 'true' : 'false'} + data-canvas-minimap-render-mode={renderMode} + data-canvas-minimap-edge-mode={shouldRenderEdges ? 'full' : 'hidden'} > (function Canvas( return () => container.removeEventListener('wheel', handleWheel) }, [pan, zoomAt]) - // Handle background mouse down for pan + // Handle background mouse down for pan and far-field hit testing const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return @@ -450,6 +452,14 @@ export const Canvas = forwardRef(function Canvas( containerRef.current?.focus() + const canvasPoint = clientToCanvas(e.clientX, e.clientY) + const hitNode = canvas.findNodeAt(canvasPoint.x, canvasPoint.y) + + if (hitNode) { + selectNode(hitNode.id, e.shiftKey || e.metaKey) + return + } + // Clicked on background clearSelection() onBackgroundClick?.() @@ -474,7 +484,22 @@ export const Canvas = forwardRef(function Canvas( window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) }, - [clearSelection, onBackgroundClick, pan] + [canvas, clearSelection, clientToCanvas, onBackgroundClick, pan, selectNode] + ) + + const handleBackgroundDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (e.target !== containerRef.current) { + return + } + + const canvasPoint = clientToCanvas(e.clientX, e.clientY) + const hitNode = canvas.findNodeAt(canvasPoint.x, canvasPoint.y) + if (hitNode) { + onNodeDoubleClick?.(hitNode.id) + } + }, + [canvas, clientToCanvas, onNodeDoubleClick] ) const handleSurfaceDragOver = useCallback( @@ -682,39 +707,22 @@ export const Canvas = forwardRef(function Canvas( [onNodeDoubleClick] ) - // Build node map for edge rendering (memoized to avoid recreating on every render) - const nodeMap = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]) - - // PERF-01: Viewport culling - only render nodes visible in the viewport - // Add a buffer (200px in canvas coordinates) to avoid nodes popping in/out at edges - const visibleNodes = useMemo(() => { - const visibleRect = viewport.getVisibleRect() - // Expand rect by buffer to include nodes just outside viewport - const buffer = 200 / viewport.zoom // Buffer in canvas coordinates - const expandedRect = { - x: visibleRect.x - buffer, - y: visibleRect.y - buffer, - width: visibleRect.width + buffer * 2, - height: visibleRect.height + buffer * 2 - } - return canvas.store.getVisibleNodes(expandedRect) - }, [canvas.store, nodes, viewport]) - - // PERF-01: Set of visible node IDs for fast edge culling lookup - const visibleNodeIds = useMemo(() => new Set(visibleNodes.map((n) => n.id)), [visibleNodes]) - // PERF-02: Calculate LOD (Level of Detail) based on zoom level // This reduces DOM complexity at low zoom levels for better performance const lod = useMemo(() => calculateLOD(viewport.zoom), [viewport.zoom]) - // PERF-01: Filter edges to only those with at least one visible endpoint - const visibleEdges = useMemo( + const displayList = useMemo( () => - edges.filter( - (edge) => visibleNodeIds.has(edge.sourceId) || visibleNodeIds.has(edge.targetId) - ), - [edges, visibleNodeIds] + createCanvasDisplayList({ + viewport, + nodes, + edges, + store: canvas.store, + selectedNodeIds + }), + [canvas.store, edges, nodes, selectedNodeIds, viewport] ) + const { nodeMap, visibleNodes, visibleEdges, domNodes, overviewNodes } = displayList // Build comment objects map (memoized for CommentOverlay) // Note: Uses all nodes for comments, not just visible ones @@ -794,6 +802,9 @@ export const Canvas = forwardRef(function Canvas( data-canvas-surface="true" data-node-count={nodes.length} data-visible-node-count={visibleNodes.length} + data-dom-node-count={domNodes.length} + data-overview-node-count={overviewNodes.length} + data-canvas-render-mode={overviewNodes.length > 0 ? 'hybrid' : 'dom'} data-edge-count={edges.length} data-visible-edge-count={visibleEdges.length} data-viewport-x={viewport.x} @@ -802,6 +813,7 @@ export const Canvas = forwardRef(function Canvas( data-viewport-width={viewport.width} data-viewport-height={viewport.height} onMouseDown={handleMouseDown} + onDoubleClick={handleBackgroundDoubleClick} onDragOver={handleSurfaceDragOver} onDrop={handleSurfaceDrop} onPaste={handleSurfacePaste} @@ -841,10 +853,12 @@ export const Canvas = forwardRef(function Canvas( - {/* Nodes layer - PERF-01: Only render nodes visible in viewport */} + + + {/* Nodes layer - PERF-01: Only render DOM islands for the interactive subset */} {/* PERF-02: LOD reduces detail at low zoom levels */}
    - {visibleNodes.map((node) => { + {domNodes.map((node) => { const selected = selectedNodeIds.has(node.id) const renderContext: CanvasNodeRenderContext = { selected, diff --git a/packages/canvas/src/renderer/OverviewCanvasLayer.tsx b/packages/canvas/src/renderer/OverviewCanvasLayer.tsx new file mode 100644 index 000000000..a3bde6b7f --- /dev/null +++ b/packages/canvas/src/renderer/OverviewCanvasLayer.tsx @@ -0,0 +1,174 @@ +/** + * Canvas overview layer. + * + * Draws simplified far-field objects on a canvas so dense scenes do not + * require every visible object to mount as a DOM island. + */ + +import type { Viewport } from '../spatial' +import type { CanvasNode } from '../types' +import React, { useEffect, useMemo, useRef } from 'react' + +export interface OverviewCanvasLayerProps { + nodes: CanvasNode[] + viewport: Viewport +} + +function getNodeFill(node: CanvasNode): string { + switch (node.type) { + case 'page': + return 'rgba(59, 130, 246, 0.18)' + case 'database': + return 'rgba(16, 185, 129, 0.18)' + case 'external-reference': + return 'rgba(236, 72, 153, 0.18)' + case 'media': + return 'rgba(139, 92, 246, 0.18)' + case 'note': + return 'rgba(245, 158, 11, 0.2)' + case 'group': + return 'rgba(107, 114, 128, 0.1)' + case 'shape': + return 'rgba(14, 165, 233, 0.16)' + case 'frame': + return 'rgba(16, 185, 129, 0.08)' + default: + return 'rgba(148, 163, 184, 0.16)' + } +} + +function getNodeStroke(node: CanvasNode): string { + switch (node.type) { + case 'page': + return 'rgba(37, 99, 235, 0.55)' + case 'database': + return 'rgba(5, 150, 105, 0.55)' + case 'external-reference': + return 'rgba(219, 39, 119, 0.55)' + case 'media': + return 'rgba(124, 58, 237, 0.55)' + case 'note': + return 'rgba(217, 119, 6, 0.6)' + case 'group': + return 'rgba(100, 116, 139, 0.35)' + case 'shape': + return 'rgba(2, 132, 199, 0.45)' + case 'frame': + return 'rgba(5, 150, 105, 0.3)' + default: + return 'rgba(100, 116, 139, 0.45)' + } +} + +function drawRoundedRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number +): void { + const clampedRadius = Math.max(0, Math.min(radius, width / 2, height / 2)) + + ctx.beginPath() + ctx.moveTo(x + clampedRadius, y) + ctx.lineTo(x + width - clampedRadius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + clampedRadius) + ctx.lineTo(x + width, y + height - clampedRadius) + ctx.quadraticCurveTo(x + width, y + height, x + width - clampedRadius, y + height) + ctx.lineTo(x + clampedRadius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - clampedRadius) + ctx.lineTo(x, y + clampedRadius) + ctx.quadraticCurveTo(x, y, x + clampedRadius, y) + ctx.closePath() +} + +export function OverviewCanvasLayer({ + nodes, + viewport +}: OverviewCanvasLayerProps): React.ReactElement { + const canvasRef = useRef(null) + const sortedNodes = useMemo( + () => + [...nodes].sort((left, right) => { + const leftZ = left.position.zIndex ?? 0 + const rightZ = right.position.zIndex ?? 0 + if (leftZ !== rightZ) { + return leftZ - rightZ + } + + return left.id.localeCompare(right.id) + }), + [nodes] + ) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) { + return + } + + const dpr = window.devicePixelRatio || 1 + const width = Math.max(1, Math.round(viewport.width * dpr)) + const height = Math.max(1, Math.round(viewport.height * dpr)) + + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width + canvas.height = height + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + } + + const ctx = canvas.getContext('2d') + if (!ctx) { + return + } + + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, canvas.width, canvas.height) + + if (sortedNodes.length === 0) { + return + } + + const centerX = canvas.width / 2 + const centerY = canvas.height / 2 + ctx.setTransform( + viewport.zoom * dpr, + 0, + 0, + viewport.zoom * dpr, + -viewport.x * viewport.zoom * dpr + centerX, + -viewport.y * viewport.zoom * dpr + centerY + ) + + for (const node of sortedNodes) { + const { x, y, width: nodeWidth, height: nodeHeight } = node.position + const radius = Math.max(8 / Math.max(viewport.zoom, 1), 4) + + ctx.fillStyle = getNodeFill(node) + ctx.strokeStyle = getNodeStroke(node) + ctx.lineWidth = 1 / Math.max(viewport.zoom, 1) + + drawRoundedRect(ctx, x, y, nodeWidth, nodeHeight, radius) + ctx.fill() + ctx.stroke() + } + }, [sortedNodes, viewport]) + + return ( +
    + ) : null} + {!hasNodes ? (
    @@ -774,8 +946,13 @@ export const CanvasView = forwardRef(function const sourceNodeId = getCanvasShellSourceId(node) const linkedDocument = sourceNodeId ? documentMap.get(sourceNodeId) : undefined const displayType = getCanvasViewDisplayType(node, linkedDocument) + const isPeekedNode = peekedCanvasObject?.node.id === node.id - if (sourceNodeId && shouldActivateInlinePageSurface(node, context, linkedDocument)) { + if ( + sourceNodeId && + !isPeekedNode && + shouldActivateInlinePageSurface(node, context, linkedDocument) + ) { return ( (function if ( sourceNodeId && + !isPeekedNode && shouldActivateDatabasePreviewSurface(node, context, linkedDocument) ) { return ( diff --git a/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md b/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md index 46aaec08a..7590a3774 100644 --- a/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md +++ b/docs/plans/plan03_9_83CanvasV2/05-page-cards-inline-editing-and-peek.md @@ -134,6 +134,6 @@ pnpm --filter @xnetjs/react test - [x] Add page creation directly on the canvas using real `Page` nodes. - [x] Implement page-backed note objects as a display preset, not a separate editor primitive. - [x] Define page render modes and mount gates for preview/editing. -- [ ] Add center-peek behavior before full route transitions. +- [x] Add center-peek behavior before full route transitions. - [x] Preserve full `PageView` open/focus behavior for deep work. -- [ ] Validate smooth transitions between preview, peek, editing, and full focus. +- [x] Validate smooth transitions between preview, peek, editing, and full focus. diff --git a/docs/plans/plan03_9_83CanvasV2/README.md b/docs/plans/plan03_9_83CanvasV2/README.md index b6bc8ffba..01b1bd1f7 100644 --- a/docs/plans/plan03_9_83CanvasV2/README.md +++ b/docs/plans/plan03_9_83CanvasV2/README.md @@ -266,7 +266,7 @@ flowchart LR - [x] Replace the current linked-card shell with a hybrid renderer shell. - [x] Route the main runtime through chunking, culling, and explicit layer display lists. - [x] Add universal drop ingestion for internal drags, URLs, text, images, and files. -- [ ] Ship live page cards with inline editing and peek behavior. +- [x] Ship live page cards with inline editing and peek behavior. - [x] Ship database preview cards with focus/open and split workflows. - [ ] Add connector bindings, shapes, groups, locks, align/tidy operations, and aliases/backlinks. - [ ] Define and implement the full shortcut/command surface for Canvas V2. diff --git a/tests/e2e/src/electron-canvas.spec.ts b/tests/e2e/src/electron-canvas.spec.ts index b9457b7d5..f99cacdc8 100644 --- a/tests/e2e/src/electron-canvas.spec.ts +++ b/tests/e2e/src/electron-canvas.spec.ts @@ -704,6 +704,73 @@ test.describe('Electron canvas shell', () => { }) }) + test('supports centered page peek before full focus transitions', async () => { + test.skip(!electronPage, 'Electron page did not initialize') + const page = electronPage! + + await selectCanvasNode(page, '.canvas-node[data-node-type="page"]') + await page.locator('[data-canvas-surface="true"]').focus() + await page.keyboard.press('Enter') + + const peekSurface = page.locator( + '[data-canvas-peek-surface="true"][data-canvas-peek-kind="page"]' + ) + await expect(peekSurface).toBeVisible({ timeout: 30_000 }) + await expect( + page.locator('[data-canvas-page-surface="true"][data-canvas-page-surface-mode="peek"]') + ).toHaveCount(1) + await expect( + page.locator('[data-canvas-page-surface="true"][data-canvas-page-surface-mode="inline"]') + ).toHaveCount(0) + await expect + .poll(async () => getContentEditableCount(page), { + timeout: 15_000 + }) + .toBe(1) + + const titleInput = page + .locator('[data-canvas-peek-surface="true"] [data-canvas-page-title="true"]') + .first() + await titleInput.fill('Peek Draft') + + const editor = page.locator( + '[data-canvas-peek-surface="true"] [data-canvas-page-editor="true"] [contenteditable="true"]' + ) + await editor.focus() + await page.keyboard.type('Peek body text') + await expect(peekSurface).toContainText('Peek body text') + + await page.keyboard.press('Escape') + await expect(peekSurface).toHaveCount(0, { timeout: 15_000 }) + await expect( + page.locator('[data-canvas-page-surface="true"][data-canvas-page-surface-mode="inline"]') + ).toHaveCount(1, { + timeout: 30_000 + }) + + await page.locator('[data-canvas-surface="true"]').focus() + await page.keyboard.press('Enter') + await expect(peekSurface).toBeVisible({ timeout: 30_000 }) + await page + .locator('[data-canvas-peek-surface="true"] [data-canvas-page-open="true"]') + .first() + .evaluate((button: HTMLButtonElement) => button.click()) + await expect( + page.locator('[data-page-view="true"][data-page-view-chrome="minimal"]') + ).toBeVisible({ + timeout: 30_000 + }) + + await page.getByRole('button', { name: 'Canvas' }).click({ force: true }) + await expect(peekSurface).toHaveCount(0, { timeout: 30_000 }) + await expect(page.getByText('Peek Draft')).toBeVisible({ timeout: 30_000 }) + + await page.screenshot({ + path: `${ROOT}/tmp/playwright/electron-canvas-page-peek.png`, + fullPage: true + }) + }) + test('supports canvas-scoped hotkeys, command commands, and typing guards', async () => { test.skip(!electronPage, 'Electron page did not initialize') const page = electronPage! From 2faa9364070321bab2aea229e31111052f1081a6 Mon Sep 17 00:00:00 2001 From: crs48 Date: Tue, 10 Mar 2026 04:12:26 -0700 Subject: [PATCH 16/42] feat(canvas): add theme-aware canvas chrome - resolve shared light/dark tokens for the canvas surface, grid, minimap, and navigation chrome - add package tests and Electron/web Playwright coverage for canvas theme regressions - record release-gate updates and include a PR artifact screenshot for review comments --- .../src/renderer/components/CanvasView.tsx | 5 +- .../02-hybrid-shell-and-renderer-runtime.md | 3 + ...n-rollout-workbenches-and-release-gates.md | 3 + .../2026-03-10-electron-canvas-shell.png | Bin 0 -> 91076 bytes .../src/__tests__/canvas-theme.test.tsx | 128 +++++++++ .../canvas/src/__tests__/webgl-grid.test.ts | 18 ++ packages/canvas/src/comments/CommentPin.tsx | 4 +- packages/canvas/src/components/Minimap.tsx | 47 ++-- .../canvas/src/components/NavigationTools.tsx | 57 ++-- .../canvas/src/layers/css-grid-fallback.ts | 7 +- packages/canvas/src/layers/webgl-grid.ts | 12 +- packages/canvas/src/renderer/Canvas.tsx | 37 ++- packages/canvas/src/theme/canvas-theme.ts | 257 ++++++++++++++++++ tests/e2e/src/electron-canvas.spec.ts | 98 +++++++ tests/e2e/src/web-canvas-ingestion.spec.ts | 75 +++++ 15 files changed, 700 insertions(+), 51 deletions(-) create mode 100644 docs/pr-artifacts/canvas-v2/2026-03-10-electron-canvas-shell.png create mode 100644 packages/canvas/src/__tests__/canvas-theme.test.tsx create mode 100644 packages/canvas/src/theme/canvas-theme.ts diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index 0ec85018a..60249d1d4 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -937,10 +937,7 @@ export const CanvasView = forwardRef(function bottom: 24, right: 24, borderRadius: 24, - background: 'rgba(255, 255, 255, 0.8)', - backdropFilter: 'blur(16px)', - boxShadow: '0 18px 38px rgba(15, 23, 42, 0.12)', - border: '1px solid rgba(148, 163, 184, 0.28)' + backdropFilter: 'blur(16px)' }} renderNode={(node, context) => { const sourceNodeId = getCanvasShellSourceId(node) diff --git a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md index 4423ef9b7..2fa2025b1 100644 --- a/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md +++ b/docs/plans/plan03_9_83CanvasV2/02-hybrid-shell-and-renderer-runtime.md @@ -87,6 +87,7 @@ The shell should keep always-visible UI small: - collapsible minimap/navigation cluster - optional selection HUD only when something is selected - command palette trigger via shortcut rather than a large toolbar +- shared light/dark-aware canvas chrome so grid, minimap, and navigation controls stay legible in both themes Do not add a permanent inspector-first layout in the initial cut. @@ -137,6 +138,7 @@ function CanvasRuntime(props: CanvasRuntimeProps): React.ReactElement { - shell boot - minimap visibility/toggle - dock + command-palette creation flows + - light/dark theme transitions across surface background, navigation tools, and minimap controls - hybrid-layer smoke assertions (`canvas` overview layers + DOM object cards) - Verify that minimap, grid, DOM objects, and overlays continue to stack correctly. - Manually verify viewport updates and overlay alignment in Electron. @@ -162,5 +164,6 @@ PLAYWRIGHT_TEST_BASE_URL=http://localhost:5173 pnpm --filter @xnetjs/e2e-tests e - [x] Move the grid and minimap into the default shell path. - [x] Define explicit responsibilities for background, overview, DOM, and overlay layers. - [x] Replace the current custom linked-card shell rendering with runtime-fed object rendering. +- [x] Keep shared canvas chrome readable in both light and dark themes. - [ ] Keep persistent shell chrome minimal and contextual. - [ ] Centralize frame scheduling and redraw ownership. diff --git a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md index 2f08ecdb8..cc960f4a2 100644 --- a/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md +++ b/docs/plans/plan03_9_83CanvasV2/10-electron-rollout-workbenches-and-release-gates.md @@ -170,9 +170,11 @@ Automated validation should include: - dock creation - command-palette creation - minimap toggle + - theme transitions across the shared canvas surface, navigation cluster, and minimap controls - page/database focus-return flows - database split-view open/close flows - Web Playwright smoke coverage for: + - light/dark theme transitions across the shared canvas surface, navigation cluster, and minimap controls - URL drops creating source-backed `ExternalReference` cards - image/file drops creating source-backed `MediaAsset` cards - Electron CDP performance coverage for: @@ -194,6 +196,7 @@ Automated validation should include: - [ ] Build realistic Storybook/workbench scenes for every major object family and density class. - [x] Add repeatable performance scenes and capture frame/DOM/query metrics. - [x] Add Electron CDP e2e coverage for canvas-home workflows and shortcuts. +- [x] Add Electron and web theme-regression coverage for shared canvas chrome. - [x] Add Electron CDP large-scene performance coverage and record thresholds. - [ ] Run manual Electron validation for editing, navigation, collaboration, and shortcuts. - [ ] Document and enforce release gates before web rollout. diff --git a/docs/pr-artifacts/canvas-v2/2026-03-10-electron-canvas-shell.png b/docs/pr-artifacts/canvas-v2/2026-03-10-electron-canvas-shell.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f432a8829f478392e14b5a9e9b32695d3eded7 GIT binary patch literal 91076 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�E-x>SyK!yzYpib+&Nx(I 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 | null = null + +beforeEach(() => { + canvasGetContextSpy = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation((contextId: string) => { + if (contextId !== '2d') { + return null + } + + return { + scale: () => undefined, + fillRect: () => undefined, + beginPath: () => undefined, + moveTo: () => undefined, + lineTo: () => undefined, + stroke: () => undefined, + strokeRect: () => undefined, + clearRect: () => undefined, + setTransform: () => undefined, + fill: () => undefined, + closePath: () => undefined, + quadraticCurveTo: () => undefined + } as unknown as CanvasRenderingContext2D + }) +}) + +afterEach(() => { + cleanup() + canvasGetContextSpy?.mockRestore() + canvasGetContextSpy = null + document.documentElement.className = '' + document.documentElement.removeAttribute('style') +}) + +function createViewport(): Viewport { + return new Viewport({ + x: 0, + y: 0, + zoom: 1, + width: 1280, + height: 720 + }) +} + +describe('canvas theme tokens', () => { + it('resolves light mode fallbacks without CSS variables', () => { + document.documentElement.className = 'light' + + const tokens = resolveCanvasThemeTokens(document.documentElement) + + expect(tokens.mode).toBe('light') + expect(tokens.surfaceBackground).toContain('hsl(') + expect(tokens.gridColor[3]).toBeGreaterThan(0) + }) + + it('resolves dark mode fallbacks without CSS variables', () => { + document.documentElement.className = 'dark' + + const tokens = resolveCanvasThemeTokens(document.documentElement) + + expect(tokens.mode).toBe('dark') + expect(tokens.panelBackground).toContain('hsl(') + expect(tokens.majorGridColor[3]).toBeGreaterThan(tokens.gridColor[3]) + }) +}) + +describe('theme-aware canvas chrome', () => { + it('updates navigation tools when the document theme changes', async () => { + document.documentElement.className = 'light' + + render( + undefined} + /> + ) + + const zoomInButton = screen.getByRole('button', { name: 'Zoom in' }) + const toolbar = zoomInButton.closest('.navigation-tools') + + expect(toolbar?.dataset.canvasTheme).toBe('light') + + document.documentElement.className = 'dark' + + await waitFor(() => { + expect(toolbar?.dataset.canvasTheme).toBe('dark') + }) + }) + + it('updates the minimap shell when the document theme changes', async () => { + document.documentElement.className = 'dark' + + const viewport = createViewport() + const { container } = render( + undefined} + /> + ) + + const minimap = container.querySelector('[data-canvas-minimap="true"]') + expect(minimap?.dataset.canvasTheme).toBe('dark') + + document.documentElement.className = 'light' + + await waitFor(() => { + expect(minimap?.dataset.canvasTheme).toBe('light') + }) + }) +}) diff --git a/packages/canvas/src/__tests__/webgl-grid.test.ts b/packages/canvas/src/__tests__/webgl-grid.test.ts index f39701fb7..ea5581af3 100644 --- a/packages/canvas/src/__tests__/webgl-grid.test.ts +++ b/packages/canvas/src/__tests__/webgl-grid.test.ts @@ -111,6 +111,7 @@ describe('WebGLGridLayer', () => { gridSpacing: 40, majorEvery: 10, gridColor: [0.2, 0.2, 0.8, 0.3], + axisColor: [0.8, 0.3, 0.2, 0.4], type: 'lines' } @@ -249,6 +250,23 @@ describe('CSSGridFallback', () => { grid.destroy() }) + it('uses the major grid color for emphasized CSS lines', () => { + const grid = new CSSGridFallback(container, { + ...DEFAULT_GRID_CONFIG, + type: 'lines', + gridColor: [0.1, 0.2, 0.3, 0.2], + majorGridColor: [0.7, 0.6, 0.2, 0.4] + }) + + grid.render({ x: 0, y: 0, zoom: 1 }) + + const gridElement = container.querySelector('div') as HTMLDivElement + expect(gridElement.style.backgroundImage).toContain('rgba(26, 51, 77, 0.1)') + expect(gridElement.style.backgroundImage).toContain('rgba(179, 153, 51, 0.4)') + + grid.destroy() + }) + it('removes element on destroy', () => { const grid = new CSSGridFallback(container, DEFAULT_GRID_CONFIG) expect(container.children.length).toBeGreaterThan(0) diff --git a/packages/canvas/src/comments/CommentPin.tsx b/packages/canvas/src/comments/CommentPin.tsx index 14aa83070..d24c08cee 100644 --- a/packages/canvas/src/comments/CommentPin.tsx +++ b/packages/canvas/src/comments/CommentPin.tsx @@ -105,7 +105,9 @@ export function CommentPin({ {authorInitial} diff --git a/packages/canvas/src/components/Minimap.tsx b/packages/canvas/src/components/Minimap.tsx index e519922cd..e90c979b2 100644 --- a/packages/canvas/src/components/Minimap.tsx +++ b/packages/canvas/src/components/Minimap.tsx @@ -8,6 +8,7 @@ import type { CanvasNode, CanvasEdge } from '../types' import { useRef, useEffect, useCallback, useMemo, useState } from 'react' import { Viewport } from '../spatial/index' +import { useCanvasThemeTokens } from '../theme/canvas-theme' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -88,12 +89,14 @@ export function Minimap({ height = 150, onViewportChange, className, - backgroundColor = 'rgba(249, 250, 251, 0.95)', + backgroundColor, showEdges = true }: MinimapProps) { + const theme = useCanvasThemeTokens() const canvasRef = useRef(null) const isDraggingRef = useRef(false) const containerRef = useRef(null) + const resolvedBackgroundColor = backgroundColor ?? theme.minimapBackground // Calculate bounds of all content const canvasBounds = useMemo(() => { @@ -230,12 +233,12 @@ export function Minimap({ ctx.scale(dpr, dpr) // Clear background - ctx.fillStyle = backgroundColor + ctx.fillStyle = resolvedBackgroundColor ctx.fillRect(0, 0, width, height) // Draw edges if (shouldRenderEdges && edges.length > 0) { - ctx.strokeStyle = 'rgba(156, 163, 175, 0.4)' + ctx.strokeStyle = theme.minimapEdge ctx.lineWidth = 1 ctx.beginPath() @@ -293,16 +296,16 @@ export function Minimap({ const vh = visibleRect.height * scale // Viewport fill - ctx.fillStyle = 'rgba(59, 130, 246, 0.1)' + ctx.fillStyle = theme.minimapViewportFill ctx.fillRect(vx, vy, vw, vh) // Viewport border - ctx.strokeStyle = 'rgba(59, 130, 246, 0.8)' + ctx.strokeStyle = theme.minimapViewportStroke ctx.lineWidth = 2 ctx.strokeRect(vx, vy, vw, vh) // Minimap border - ctx.strokeStyle = 'rgba(209, 213, 219, 1)' + ctx.strokeStyle = theme.minimapBorder ctx.lineWidth = 1 ctx.strokeRect(0.5, 0.5, width - 1, height - 1) }, [ @@ -314,7 +317,11 @@ export function Minimap({ renderNodes, width, height, - backgroundColor, + resolvedBackgroundColor, + theme.minimapBorder, + theme.minimapEdge, + theme.minimapViewportFill, + theme.minimapViewportStroke, shouldRenderEdges ]) @@ -387,11 +394,12 @@ export function Minimap({ right: 16, borderRadius: 8, overflow: 'hidden', - boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + boxShadow: theme.panelShadow, cursor: 'crosshair', userSelect: 'none' }} data-canvas-minimap="true" + data-canvas-theme={theme.mode} data-canvas-minimap-node-count={nodes.length} data-canvas-minimap-rendered-node-count={renderNodes.length} data-canvas-minimap-edge-count={edges.length} @@ -417,10 +425,10 @@ export function Minimap({ function MapIcon() { return ( - - - - + ) } @@ -433,6 +441,7 @@ export interface CollapsibleMinimapProps extends MinimapProps { } export function CollapsibleMinimap({ defaultExpanded = true, ...props }: CollapsibleMinimapProps) { + const theme = useCanvasThemeTokens() const [isExpanded, setIsExpanded] = useState(defaultExpanded) return ( @@ -462,14 +471,14 @@ export function CollapsibleMinimap({ defaultExpanded = true, ...props }: Collaps width: 20, height: 20, border: 'none', - background: 'rgba(255,255,255,0.9)', + background: theme.minimapOverlayBackground, borderRadius: 4, cursor: 'pointer', fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', - color: '#6b7280', + color: theme.panelMutedText, zIndex: 1 }} title="Hide minimap" @@ -487,14 +496,16 @@ export function CollapsibleMinimap({ defaultExpanded = true, ...props }: Collaps style={{ width: 32, height: 32, - border: '1px solid #e5e7eb', - background: 'white', + border: `1px solid ${theme.panelBorder}`, + background: theme.panelBackground, borderRadius: 8, cursor: 'pointer', - boxShadow: '0 2px 4px rgba(0,0,0,0.1)', + boxShadow: theme.panelShadow, display: 'flex', alignItems: 'center', - justifyContent: 'center' + justifyContent: 'center', + color: theme.panelMutedText, + backdropFilter: 'blur(16px)' }} title="Show minimap" aria-label="Show minimap" diff --git a/packages/canvas/src/components/NavigationTools.tsx b/packages/canvas/src/components/NavigationTools.tsx index 276835b7a..6eb7e2007 100644 --- a/packages/canvas/src/components/NavigationTools.tsx +++ b/packages/canvas/src/components/NavigationTools.tsx @@ -7,6 +7,7 @@ import type { Rect } from '../types' import { useCallback } from 'react' import { Viewport } from '../spatial/index' +import { useCanvasThemeTokens } from '../theme/canvas-theme' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -41,6 +42,8 @@ export function NavigationTools({ style, insetRight = 16 }: NavigationToolsProps) { + const theme = useCanvasThemeTokens() + const zoomIn = useCallback(() => { const newZoom = Math.min(viewport.zoom * 1.5, 4) onViewportChange({ zoom: newZoom }) @@ -81,15 +84,35 @@ export function NavigationTools({ const zoomPercent = Math.round(viewport.zoom * 100) const positionStyles = { - ...getPositionStyles(position, insetRight), + ...getPositionStyles(position, insetRight, theme), ...style } + const getButtonStyle = (disabled: boolean): React.CSSProperties => ({ + ...styles.button, + color: disabled ? theme.panelButtonDisabled : theme.panelIconColor, + cursor: disabled ? 'not-allowed' : 'pointer' + }) + + const dividerStyle: React.CSSProperties = { + ...styles.divider, + background: theme.panelDivider + } + + const zoomLabelStyle: React.CSSProperties = { + ...styles.zoomLabel, + color: theme.panelMutedText + } + return ( -
    +
    -
    +
    + + {selection.nodeIds.length > 1 ? ( + <> + + + + + + + ) : null} + + {selection.nodeIds.length > 0 ? ( + <> + + + + + ) : null} + ) : null} + {selectedCanvasObject.sourceId ? ( + + ) : null} + {selectedCanvasObject.sourceId ? ( + + ) : null} + ) : null} @@ -1071,6 +1463,224 @@ export const CanvasView = forwardRef(function
    ) : null} + {selectionPanel && selectedCanvasObject ? ( +
    +
    + {selectionPanel === 'alias' ? ( +
    +
    +
    +

    Canvas alias

    +

    + This renames the canvas object without touching the underlying page or + database title. +

    +
    + + +
    + +
    + setAliasDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + setSelectedSourceAlias(aliasDraft) + return + } + + if (event.key === 'Escape') { + event.preventDefault() + closeSelectionPanel() + } + }} + placeholder={selectedCanvasObject.title} + className="min-w-0 flex-1 rounded-2xl border border-border/60 bg-background px-4 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground" + data-canvas-alias-input="true" + /> + + + + +
    +
    + ) : selectionPanel === 'comment' ? ( +
    +
    +
    +

    Canvas comment

    +

    + Anchor a thread to this object. The pin follows the object as it moves, and + deleted anchors fall back to the orphan tray. +

    +
    + + +
    + +
    + {selectedObjectCommentCount > 0 + ? `${selectedObjectCommentCount} existing thread${ + selectedObjectCommentCount === 1 ? '' : 's' + } on this object` + : 'No existing threads on this object yet'} +
    + +
    +