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' } }) } diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index df93ff898..9725e8f22 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' @@ -11,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' @@ -30,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' } @@ -43,6 +49,18 @@ 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, + selectionAllLocked: false, + selectionAnyLocked: false, + shortcutHelpOpen: false +} function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) @@ -52,6 +70,13 @@ 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 [canvasCommandState, setCanvasCommandState] = useState( + EMPTY_CANVAS_COMMAND_STATE + ) const [showAddSharedDialog, setShowAddSharedDialog] = useState(false) const [prefilledShareValue, setPrefilledShareValue] = useState('') const { setActiveNodeId } = useDevTools() @@ -237,10 +262,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 +280,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() @@ -305,6 +353,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() @@ -325,6 +384,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') }, { @@ -332,15 +394,362 @@ 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') }, { 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', + shortcut: 'N', + group: 'Canvas', + keywords: ['note', 'canvas', 'create'], execute: () => handleCreateCanvasNote() }, + { + id: 'create-rectangle', + name: 'Create Rectangle', + description: 'Create a canvas-native rectangle on the current board', + icon: 'square', + shortcut: 'R', + group: 'Canvas', + keywords: ['shape', 'rectangle', 'canvas', 'create'], + when: () => isCanvasInteractiveShell, + execute: () => { + canvasViewRef.current?.createShape('rectangle') + } + }, + { + id: 'create-frame', + name: 'Create Frame', + description: 'Create an empty frame container on the current board', + icon: 'layout', + shortcut: 'F', + group: 'Canvas', + keywords: ['frame', 'group', 'canvas', 'create'], + when: () => isCanvasInteractiveShell, + execute: () => { + canvasViewRef.current?.createFrame() + } + }, + { + id: 'frame-selection', + name: 'Frame Selection', + description: 'Wrap the selected canvas objects in a frame container', + icon: 'layout', + shortcut: 'Mod+Shift+F', + group: 'Canvas', + keywords: ['frame', 'group', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 0, + execute: () => { + canvasViewRef.current?.wrapSelectionInFrame() + } + }, + { + id: 'canvas-connect-selection', + name: 'Connect Selection', + description: 'Create a connector between the two selected canvas objects', + icon: 'link', + shortcut: 'Mod+Shift+K', + group: 'Canvas', + keywords: ['connect', 'connector', 'edge', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount === 2, + execute: () => { + canvasViewRef.current?.connectSelection() + } + }, + { + id: 'canvas-rename-alias', + name: 'Rename Canvas Alias', + description: + canvasCommandState.selectedTitle && canvasCommandState.selectionCount === 1 + ? `Rename the canvas copy of ${canvasCommandState.selectedTitle}` + : 'Rename the selected canvas object without changing the source title', + icon: 'pencil', + shortcut: 'Mod+Shift+A', + group: 'Canvas', + keywords: ['alias', 'rename', 'selection', 'canvas'], + when: () => + isCanvasInteractiveShell && + canvasCommandState.selectionCount === 1 && + Boolean(canvasCommandState.selectedSourceId), + execute: () => { + canvasViewRef.current?.openAliasEditor() + } + }, + { + id: 'canvas-clear-alias', + name: 'Clear Canvas Alias', + description: 'Remove the canvas-local alias from the selected object', + icon: 'x', + group: 'Canvas', + keywords: ['alias', 'clear', 'selection', 'canvas'], + when: () => + isCanvasInteractiveShell && + canvasCommandState.selectionCount === 1 && + Boolean(canvasCommandState.selectedSourceId), + execute: () => { + canvasViewRef.current?.clearSelectionAlias() + } + }, + { + id: 'canvas-comment-selection', + name: 'Comment on Selection', + description: + canvasCommandState.selectedTitle && canvasCommandState.selectionCount === 1 + ? `Add a canvas-anchored comment to ${canvasCommandState.selectedTitle}` + : 'Add a canvas-anchored comment to the selected object', + icon: 'message-square', + shortcut: 'Mod+Shift+C', + group: 'Canvas', + keywords: ['comment', 'selection', 'canvas', 'feedback'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount === 1, + execute: () => { + canvasViewRef.current?.openCommentComposer() + } + }, + { + id: 'canvas-show-linked-copies', + name: 'Show Linked Copies', + description: 'Inspect other canvas objects that point at the same source node', + icon: 'copy', + group: 'Canvas', + keywords: ['references', 'copies', 'linked', 'canvas'], + when: () => + isCanvasInteractiveShell && + canvasCommandState.selectionCount === 1 && + Boolean(canvasCommandState.selectedSourceId), + execute: () => { + canvasViewRef.current?.toggleSourceReferences(true) + } + }, + { + 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: () => + 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', + description: 'Center the current canvas selection in view', + icon: 'layout', + group: 'Canvas', + keywords: ['fit', 'selection', 'zoom', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 0, + execute: () => { + canvasViewRef.current?.fitSelection() + } + }, + { + id: 'canvas-toggle-lock', + name: canvasCommandState.selectionAllLocked ? 'Unlock Selection' : 'Lock Selection', + description: canvasCommandState.selectionAllLocked + ? 'Allow the current selection to move and resize again' + : 'Protect the current selection from accidental moves and nudges', + icon: 'lock', + shortcut: 'Mod+Shift+L', + group: 'Canvas', + keywords: ['lock', 'unlock', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 0, + execute: () => { + canvasViewRef.current?.toggleSelectionLock() + } + }, + { + id: 'canvas-align-left', + name: 'Align Selection Left', + description: 'Snap the selected objects to a shared left edge', + icon: 'align-start-horizontal', + shortcut: 'Mod+Shift+Left', + group: 'Canvas', + keywords: ['align', 'left', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 1, + execute: () => { + canvasViewRef.current?.alignSelection('left') + } + }, + { + id: 'canvas-align-right', + name: 'Align Selection Right', + description: 'Snap the selected objects to a shared right edge', + icon: 'align-end-horizontal', + shortcut: 'Mod+Shift+Right', + group: 'Canvas', + keywords: ['align', 'right', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 1, + execute: () => { + canvasViewRef.current?.alignSelection('right') + } + }, + { + id: 'canvas-align-top', + name: 'Align Selection Top', + description: 'Snap the selected objects to a shared top edge', + icon: 'align-start-vertical', + shortcut: 'Mod+Shift+Up', + group: 'Canvas', + keywords: ['align', 'top', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 1, + execute: () => { + canvasViewRef.current?.alignSelection('top') + } + }, + { + id: 'canvas-align-bottom', + name: 'Align Selection Bottom', + description: 'Snap the selected objects to a shared bottom edge', + icon: 'align-end-vertical', + shortcut: 'Mod+Shift+Down', + group: 'Canvas', + keywords: ['align', 'bottom', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 1, + execute: () => { + canvasViewRef.current?.alignSelection('bottom') + } + }, + { + id: 'canvas-distribute-horizontal', + name: 'Distribute Selection Horizontally', + description: 'Even out the horizontal spacing between selected objects', + icon: 'columns', + group: 'Canvas', + keywords: ['distribute', 'horizontal', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 2, + execute: () => { + canvasViewRef.current?.distributeSelection('horizontal') + } + }, + { + id: 'canvas-distribute-vertical', + name: 'Distribute Selection Vertically', + description: 'Even out the vertical spacing between selected objects', + icon: 'rows', + group: 'Canvas', + keywords: ['distribute', 'vertical', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 2, + execute: () => { + canvasViewRef.current?.distributeSelection('vertical') + } + }, + { + id: 'canvas-tidy-selection', + name: 'Tidy Selection', + description: 'Pack the selected objects into a clean reading grid', + icon: 'sparkles', + group: 'Canvas', + keywords: ['tidy', 'arrange', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 1, + execute: () => { + canvasViewRef.current?.tidySelection() + } + }, + { + id: 'canvas-send-backward', + name: 'Send Selection Backward', + description: 'Move the selected objects back one layer', + icon: 'minus', + shortcut: '[', + group: 'Canvas', + keywords: ['backward', 'z-index', 'layer', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 0, + execute: () => { + canvasViewRef.current?.shiftSelectionLayer('backward') + } + }, + { + id: 'canvas-bring-forward', + name: 'Bring Selection Forward', + description: 'Move the selected objects forward one layer', + icon: 'plus', + shortcut: ']', + group: 'Canvas', + keywords: ['forward', 'z-index', 'layer', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && canvasCommandState.selectionCount > 0, + execute: () => { + canvasViewRef.current?.shiftSelectionLayer('forward') + } + }, + { + id: 'canvas-clear-selection', + name: 'Clear Selection', + description: 'Clear the current canvas selection', + icon: 'x', + shortcut: 'Esc', + group: 'Canvas', + keywords: ['clear', 'selection', 'canvas'], + when: () => isCanvasInteractiveShell && 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: () => isCanvasInteractiveShell, + execute: () => { + canvasViewRef.current?.toggleShortcutHelp() + } + }, { id: 'open-settings', name: 'Open Settings', @@ -380,6 +789,8 @@ export function App(): React.ReactElement { handleOpenDocument, handleOpenSettings, handleOpenStories, + canvasCommandState, + isCanvasInteractiveShell, recentDocuments ] ) @@ -414,6 +825,41 @@ export function App(): React.ReactElement { ) } + if (shellState.kind === 'database-split') { + return ( +
+
+
+
+
+ + Canvas + Database + + +
+
+ +
+ +
+
+
+
+ ) + } + return (
@@ -489,7 +935,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' @@ -500,20 +946,42 @@ export function App(): React.ReactElement { ref={canvasViewRef} 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 + ) + }} onOpenDocument={(docId, docType) => focusDocument(docId, docType, true)} + onOpenDatabaseSplit={openDatabaseSplit} />
{renderOverlay()} void handleCreateLinkedDocument('page')} onCreateDatabase={() => void handleCreateLinkedDocument('database')} onCreateNote={handleCreateCanvasNote} - onOpenRecent={showPalette} onOpenSearch={showPalette} onReturnHome={handleReturnHome} + onZoomOut={() => { + canvasViewRef.current?.zoomOut() + }} + onZoomIn={() => { + canvasViewRef.current?.zoomIn() + }} + onFitToContent={() => { + canvasViewRef.current?.fitCanvasContent() + }} + onResetView={() => { + canvasViewRef.current?.resetCanvasView() + }} /> diff --git a/apps/electron/src/renderer/components/ActionDock.tsx b/apps/electron/src/renderer/components/ActionDock.tsx index 16448e083..3b0a6fec0 100644 --- a/apps/electron/src/renderer/components/ActionDock.tsx +++ b/apps/electron/src/renderer/components/ActionDock.tsx @@ -2,8 +2,18 @@ * ActionDock - Bottom-centered action dock for the minimal shell. */ -import { IconButton } from '@xnetjs/ui' -import { ArrowLeft, Clock3, Database, FileText, Search, Sparkles } from 'lucide-react' +import { useCanvasThemeTokens } from '@xnetjs/canvas' +import { + ArrowLeft, + Database, + FileText, + Minus, + Plus, + RotateCcw, + Search, + Sparkles, + Target +} from 'lucide-react' import React from 'react' export type DockMode = 'canvas-home' | 'focused' @@ -14,37 +24,47 @@ interface ActionDockProps { onCreateDatabase: () => void onCreateNote: () => void onOpenSearch: () => void - onOpenRecent: () => void onReturnHome: () => void + onZoomOut?: () => void + onZoomIn?: () => void + onFitToContent?: () => void + onResetView?: () => void } function DockButton({ + id, icon, label, + shortcut, onClick, highlight = false }: { + id: string icon: React.ReactNode label: string + shortcut?: string onClick: () => void highlight?: boolean }): React.ReactElement { + const tooltip = shortcut ? `${label} (${shortcut})` : label + return ( ) } @@ -55,41 +75,112 @@ export function ActionDock({ onCreateDatabase, onCreateNote, onOpenSearch, - onOpenRecent, - onReturnHome + onReturnHome, + onZoomOut, + onZoomIn, + onFitToContent, + onResetView }: ActionDockProps): React.ReactElement { + const theme = useCanvasThemeTokens() + const showNavigationCluster = + mode === 'canvas-home' && + typeof onZoomOut === 'function' && + typeof onZoomIn === 'function' && + typeof onFitToContent === 'function' && + typeof onResetView === 'function' + return ( -
-
+
+
{mode === 'focused' ? ( } + id="canvas" + icon={} label="Canvas" onClick={onReturnHome} highlight /> ) : ( <> - } label="Page" onClick={onCreatePage} /> - } label="Database" onClick={onCreateDatabase} /> - } label="Note" onClick={onCreateNote} /> + } + label="Page" + shortcut="P" + onClick={onCreatePage} + /> + } + label="Database" + shortcut="D" + onClick={onCreateDatabase} + /> + } + label="Note" + shortcut="N" + onClick={onCreateNote} + /> )} -
+
- } - label="Open recent items" - onClick={onOpenRecent} - className="h-11 w-11 rounded-2xl bg-background/90 text-foreground shadow-sm" - /> - } - label="Open command palette" + } + label="Command palette" + shortcut="Mod+Shift+P" onClick={onOpenSearch} - className="h-11 w-11 rounded-2xl bg-background/90 text-foreground shadow-sm" /> + + {showNavigationCluster ? ( + <> +
+ } + label="Zoom out" + shortcut="Ctrl/Cmd -" + onClick={onZoomOut} + /> + } + label="Zoom in" + shortcut="Ctrl/Cmd +" + onClick={onZoomIn} + /> + } + label="Fit to content" + shortcut="Ctrl/Cmd 1" + onClick={onFitToContent} + /> + } + label="Reset view" + shortcut="Ctrl/Cmd 0" + onClick={onResetView} + /> + + ) : null}
) 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..946b18b2a --- /dev/null +++ b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.test.tsx @@ -0,0 +1,170 @@ +/** + * @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() {} + unobserve() {} + 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 new file mode 100644 index 000000000..4fb0b9aec --- /dev/null +++ b/apps/electron/src/renderer/components/CanvasDatabasePreviewSurface.tsx @@ -0,0 +1,490 @@ +import type { CanvasNode } from '@xnetjs/canvas' +import type { CellValue, ColumnDefinition } from '@xnetjs/data' +import { useCanvasThemeTokens } from '@xnetjs/canvas' +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 + mode?: 'inline' | 'peek' + onOpenDocument?: (docId: string) => void + onSplitDocument?: (docId: string) => void + onSourceNodeMutated?: () => void + onSourceDocumentMutated?: () => 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, + onMutationCommitted?: () => void +) { + const [localTitle, setLocalTitle] = useState(initialTitle) + const isEditingRef = useRef(false) + const hasPendingMutationRef = useRef(false) + + useEffect(() => { + if (!isEditingRef.current) { + setLocalTitle(initialTitle) + } + }, [initialTitle]) + + const handleChange = useCallback( + async (event: React.ChangeEvent) => { + const nextTitle = event.target.value + setLocalTitle(nextTitle) + hasPendingMutationRef.current = true + await onCommit(nextTitle) + }, + [onCommit] + ) + + const handleFocus = useCallback(() => { + isEditingRef.current = true + }, []) + + const handleBlur = useCallback(() => { + isEditingRef.current = false + if (hasPendingMutationRef.current) { + hasPendingMutationRef.current = false + onMutationCommitted?.() + } + setLocalTitle(initialTitle) + }, [initialTitle, onMutationCommitted]) + + 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, + mode = 'inline', + onOpenDocument, + onSplitDocument, + onSourceNodeMutated, + onSourceDocumentMutated +}: CanvasDatabasePreviewSurfaceProps): React.ReactElement { + const theme = useCanvasThemeTokens() + 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, + loadingMore, + hasMore, + loadMore, + activeView + } = useDatabase(docId, { + pageSize: PREVIEW_INITIAL_ROWS + }) + const scrollContainerRef = useRef(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewportHeight, setViewportHeight] = useState(PREVIEW_DEFAULT_VIEWPORT_HEIGHT) + + 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, 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( + async (nextTitle: string) => update({ title: nextTitle }), + [update] + ) + const { localTitle, handleChange, handleFocus, handleBlur } = useStableTitle( + title, + commitTitle, + onSourceNodeMutated + ) + + const handleOpenDocument = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + onOpenDocument?.(docId) + }, + [docId, onOpenDocument] + ) + + const handleSplitDocument = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + onSplitDocument?.(docId) + }, + [docId, onSplitDocument] + ) + + 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 + }) + } + + onSourceDocumentMutated?.() + + 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 + }) + + onSourceDocumentMutated?.() + } + }, + [columns, createColumn, createView, onSourceDocumentMutated, views.length] + ) + + 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 ( +
+
+
+ + +
+ + + 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 ? ( +
+ {virtualWindow.items.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/CanvasInlinePageSurface.tsx b/apps/electron/src/renderer/components/CanvasInlinePageSurface.tsx new file mode 100644 index 000000000..e7fb04405 --- /dev/null +++ b/apps/electron/src/renderer/components/CanvasInlinePageSurface.tsx @@ -0,0 +1,224 @@ +import type { CanvasNode } from '@xnetjs/canvas' +import type { TaskMentionSuggestion } from '@xnetjs/editor/react' +import { useCanvasThemeTokens } from '@xnetjs/canvas' +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' + mode?: 'inline' | 'peek' + onOpenDocument?: (docId: string) => void + onSourceNodeMutated?: () => void +} + +type EditorExtensions = NonNullable['extensions']> + +function useStableTitle( + initialTitle: string, + onCommit: (title: string) => Promise, + onMutationCommitted?: () => void +) { + const [localTitle, setLocalTitle] = useState(initialTitle) + const isEditingRef = useRef(false) + const hasPendingMutationRef = useRef(false) + + useEffect(() => { + if (!isEditingRef.current) { + setLocalTitle(initialTitle) + } + }, [initialTitle]) + + const handleChange = useCallback( + async (event: React.ChangeEvent) => { + const nextTitle = event.target.value + setLocalTitle(nextTitle) + hasPendingMutationRef.current = true + await onCommit(nextTitle) + }, + [onCommit] + ) + + const handleFocus = useCallback(() => { + isEditingRef.current = true + }, []) + + const handleBlur = useCallback(() => { + isEditingRef.current = false + if (hasPendingMutationRef.current) { + hasPendingMutationRef.current = false + onMutationCommitted?.() + } + setLocalTitle(initialTitle) + }, [initialTitle, onMutationCommitted]) + + return { + localTitle, + handleChange, + handleFocus, + handleBlur + } +} + +export function CanvasInlinePageSurface({ + node, + docId, + variant, + mode = 'inline', + onOpenDocument, + onSourceNodeMutated +}: CanvasInlinePageSurfaceProps): React.ReactElement { + const theme = useCanvasThemeTokens() + 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, + onSourceNodeMutated + ) + + 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 ea2a0fe0a..e72531fab 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -2,11 +2,48 @@ * Canvas View - Infinite canvas for spatial visualization */ -import type { CanvasHandle, CanvasNode, Rect } from '@xnetjs/canvas' -import { Canvas, createNode } from '@xnetjs/canvas' -import { CanvasSchema } from '@xnetjs/data' -import { useNode, useIdentity } from '@xnetjs/react' -import { Database, FileText, StickyNote } from 'lucide-react' +import type { + CanvasAlignment, + CanvasDistributionAxis, + CanvasHandle, + CanvasLayerDirection, + CanvasNode, + CanvasNodeRenderContext, + CanvasSelectionSnapshot, + Rect, + ShapeType +} from '@xnetjs/canvas' +import { + Canvas, + createCanvasObjectAnchorId, + extractCanvasIngressPayloads, + getCanvasObjectsMap, + getSelectionBounds, + useCanvasThemeTokens, + useCanvasObjectIngestion +} from '@xnetjs/canvas' +import { + CanvasSchema, + DatabaseSchema, + PageSchema, + decodeAnchor, + encodeAnchor, + type CanvasObjectAnchor +} from '@xnetjs/data' +import { CanvasExternalReferenceCard, useBlobService } from '@xnetjs/editor/react' +import { useComments, useDatabaseDoc, useIdentity, useNode, useUndo } from '@xnetjs/react' +import { useUndoScope } from '@xnetjs/react/internal' +import { + Command, + Database, + Eye, + FileImage, + FileText, + Link2, + MessageSquare, + StickyNote, + X +} from 'lucide-react' import React, { forwardRef, useCallback, @@ -16,15 +53,22 @@ import React, { useRef, useState } from 'react' +import * as Y from 'yjs' +import { + useCanvasSourceReferences, + type CanvasSourceReference +} from '../hooks/useCanvasSourceReferences' import { createCanvasShellNoteProperties, - getCanvasShellNotePlacement, - getLinkedDocumentPlacement, - isCanvasShellNote, + getCanvasShellDisplayType, + getCanvasShellSourceId, + getCanvasShellSourceType, shouldRenderCanvasShellCard, type LinkedDocType, type LinkedDocumentItem } from '../lib/canvas-shell' +import { CanvasDatabasePreviewSurface } from './CanvasDatabasePreviewSurface' +import { CanvasInlinePageSurface } from './CanvasInlinePageSurface' type ViewportSnapshot = { x: number @@ -32,24 +76,97 @@ type ViewportSnapshot = { zoom: number } +type PeekableCanvasDisplayType = LinkedDocType | 'note' + +type CanvasPeekState = { + nodeId: string + sourceId: string + displayType: PeekableCanvasDisplayType +} + +type CanvasSelectionPanel = 'alias' | 'references' | 'comment' | null + +type CanvasUndoDomain = 'scene' | 'source-node' | 'source-scope' | 'source-document' + type CanvasViewProps = { docId: string documents?: LinkedDocumentItem[] + pendingInsert?: { + requestId: string + document: LinkedDocumentItem + } | null + onPendingInsertConsumed?: (requestId: string) => void onOpenDocument?: (docId: string, docType: Exclude) => void + onOpenDatabaseSplit?: (docId: string) => void + onCreatePage?: () => void + onCreateDatabase?: () => void + onCreateNote?: () => void + onCommandStateChange?: (state: CanvasViewCommandState) => void +} + +function getYjsStackDepth(manager: Y.UndoManager | null, stack: 'undoStack' | 'redoStack'): number { + if (!manager) { + return 0 + } + + const entries = (manager as unknown as Record<'undoStack' | 'redoStack', unknown[]>)[stack] + return Array.isArray(entries) ? entries.length : 0 +} + +function createUndoOrderMap(): Record { + return { + scene: [], + 'source-node': [], + 'source-scope': [], + 'source-document': [] + } +} + +export type CanvasViewCommandState = { + selectionCount: number + selectedNodeId: string | null + selectedSourceId: string | null + selectedSourceType: Exclude | null + selectedDisplayType: + | LinkedDocType + | 'note' + | 'external-reference' + | 'media' + | 'shape' + | 'frame' + | null + selectedTitle: string | null + selectionAllLocked: boolean + selectionAnyLocked: boolean + shortcutHelpOpen: boolean } 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 + zoomOut: () => boolean + zoomIn: () => boolean + fitCanvasContent: () => boolean + resetCanvasView: () => boolean + clearSelection: () => void + fitSelection: () => boolean + openSelection: (mode?: 'peek' | 'focus' | 'split') => boolean + toggleSelectionLock: () => boolean + alignSelection: ( + alignment: Extract + ) => boolean + distributeSelection: (axis: CanvasDistributionAxis) => boolean + tidySelection: () => boolean + shiftSelectionLayer: (direction: CanvasLayerDirection) => boolean + connectSelection: () => boolean + createShape: (shapeType?: ShapeType) => boolean + createFrame: () => boolean + wrapSelectionInFrame: () => boolean + openAliasEditor: () => boolean + openCommentComposer: () => boolean + clearSelectionAlias: () => boolean + toggleSourceReferences: (open?: boolean) => boolean + toggleShortcutHelp: (open?: boolean) => void } function getNodeRect(node: CanvasNode): Rect { @@ -61,53 +178,230 @@ 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' +function getCanvasViewDisplayType( + node: CanvasNode, + document?: LinkedDocumentItem +): LinkedDocType | 'note' | 'external-reference' | 'media' | 'shape' | 'frame' { + if (node.type === 'shape') { + return 'shape' + } + + if (node.type === 'group' || node.type === 'frame') { + return 'frame' + } + + if (node.type === 'external-reference' || node.type === 'media') { + return node.type + } + + return getCanvasShellDisplayType(node, document) +} + +function getShapeLabel(shapeType: ShapeType): string { + switch (shapeType) { + case 'ellipse': + return 'Ellipse' + case 'diamond': + return 'Diamond' + case 'triangle': + return 'Triangle' + case 'hexagon': + return 'Hexagon' + case 'star': + return 'Star' + case 'arrow': + return 'Arrow' + case 'cylinder': + return 'Cylinder' + case 'cloud': + return 'Cloud' + case 'rounded-rectangle': + return 'Rounded Rectangle' + case 'rectangle': + default: + return 'Rectangle' + } +} + +function sortCanvasSourceReferences( + left: CanvasSourceReference, + right: CanvasSourceReference +): number { + if (left.isCurrentCanvas !== right.isCurrentCanvas) { + return left.isCurrentCanvas ? -1 : 1 + } + + const canvasCompare = left.canvasTitle.localeCompare(right.canvasTitle) + if (canvasCompare !== 0) { + return canvasCompare + } + + const titleCompare = left.title.localeCompare(right.title) + if (titleCompare !== 0) { + return titleCompare + } + + return left.objectId.localeCompare(right.objectId) +} + +function isPeekableCanvasDisplayType( + displayType: LinkedDocType | 'note' | 'external-reference' | 'media' | 'shape' | 'frame' +): displayType is PeekableCanvasDisplayType { + return displayType === 'page' || displayType === 'database' || displayType === 'note' +} + +function renderNodeCard( + node: CanvasNode, + document: LinkedDocumentItem | undefined, + themeMode: 'light' | 'dark' +): React.ReactElement { + const displayType = getCanvasViewDisplayType(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' + : displayType === 'external-reference' + ? 'Link preview' + : 'Media asset' + + const Icon = + 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 + + if (displayType === 'external-reference') { + return ( + + ) + } - const Icon = linkedType === 'page' ? FileText : linkedType === 'database' ? Database : StickyNote + 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.' + : typeof node.properties.mimeType === 'string' + ? `${String(node.properties.kind ?? 'file')} · ${node.properties.mimeType}` + : 'Dropped media or file' return ( -
+
{subtitle} - {node.linkedNodeId && linkedType !== 'canvas' ? ( + {isOpenable ? ( Open + ) : status ? ( + + {status} + ) : null}
{linkedTitle}
-

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

+

{summary}

) } +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 + ) +} + +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, documents = [], onOpenDocument }: CanvasViewProps, + { + docId, + documents = [], + pendingInsert, + onPendingInsertConsumed, + onOpenDocument, + onOpenDatabaseSplit, + onCreatePage, + onCreateDatabase, + onCreateNote, + onCommandStateChange + }: CanvasViewProps, ref ): React.ReactElement { const { did } = useIdentity() + const blobService = useBlobService() const { data: canvas, @@ -118,26 +412,373 @@ export const CanvasView = forwardRef(function createIfMissing: { title: 'Untitled Canvas' }, did: did ?? undefined }) + const theme = useCanvasThemeTokens() + + const canvasRef = useRef(null) + const setCanvasHandle = useCallback( + (handle: CanvasHandle | null) => { + canvasRef.current = handle + + const testHarness = window as Window & { + __xnetCanvasTestHarness?: { + registerCanvasHandle?: (canvasId: string, handle: CanvasHandle | null) => void + } | null + } - const canvasRef = useRef(null) + testHarness.__xnetCanvasTestHarness?.registerCanvasHandle?.(docId, handle) + }, + [docId] + ) + 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 [sceneRevision, setSceneRevision] = useState(0) + const [selection, setSelection] = useState({ + nodeIds: [], + edgeIds: [] + }) + const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false) + const [peekState, setPeekState] = useState(null) + const [selectionPanel, setSelectionPanel] = useState(null) + const [aliasDraft, setAliasDraft] = useState('') + const aliasInputRef = useRef(null) + const [commentDraft, setCommentDraft] = useState('') + const commentInputRef = useRef(null) + const selectedDatabaseUndoManagerRef = useRef(null) + const undoOrderSequenceRef = useRef(0) + const undoOrderRef = useRef>(createUndoOrderMap()) + const redoOrderRef = useRef>(createUndoOrderMap()) + const [activeUndoDomain, setActiveUndoDomain] = useState('scene') const documentMap = useMemo( () => new Map(documents.map((entry) => [entry.id, entry])), [documents] ) + const canvasDocuments = useMemo( + () => + documents + .filter((entry) => entry.type === 'canvas') + .map((entry) => ({ + id: entry.id, + title: entry.title + })), + [documents] + ) + const { placeSourceObject, placePrimitiveObject, ingestDataTransfer } = useCanvasObjectIngestion({ + doc, + blobService, + getViewportSnapshot: () => + canvasRef.current?.getViewportSnapshot() ?? lastViewportSnapshotRef.current + }) + const { threads: canvasObjectCommentThreads, addComment: addCanvasComment } = useComments({ + nodeId: docId, + anchorType: 'canvas-object' + }) + const selectedCanvasObject = useMemo(() => { + void sceneRevision + + if (!doc || selection.nodeIds.length !== 1) { + return null + } + + const node = getCanvasObjectsMap(doc).get(selection.nodeIds[0]) + if (!node) { + return null + } + + const sourceId = getCanvasShellSourceId(node) + const linkedDocument = sourceId ? documentMap.get(sourceId) : undefined + const displayType = getCanvasViewDisplayType(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, sceneRevision, selection.nodeIds]) + + const { + loading: sourceReferencesLoading, + indexedCanvases: indexedReferenceCanvases, + totalCanvases: totalReferenceCanvases, + getReferences + } = useCanvasSourceReferences({ + enabled: Boolean(selectedCanvasObject?.sourceId), + currentCanvasId: docId, + canvases: canvasDocuments + }) + + const selectedNodes = useMemo(() => { + void sceneRevision + + if (!doc || selection.nodeIds.length === 0) { + return [] + } + + const nodes = getCanvasObjectsMap(doc) + return selection.nodeIds + .map((nodeId) => nodes.get(nodeId)) + .filter((node): node is CanvasNode => node !== undefined) + }, [doc, sceneRevision, selection.nodeIds]) + + const selectionAllLocked = selectedNodes.length > 0 && selectedNodes.every((node) => node.locked) + const selectionAnyLocked = selectedNodes.some((node) => node.locked) + const selectedSourceNodeIds = useMemo( + () => + Array.from( + new Set( + selectedNodes + .map((node) => getCanvasShellSourceId(node)) + .filter((sourceId): sourceId is string => typeof sourceId === 'string') + ) + ), + [selectedNodes] + ) + const selectedDatabaseSourceId = + selectedCanvasObject?.displayType === 'database' ? (selectedCanvasObject.sourceId ?? '') : '' + const { doc: selectedDatabaseDoc } = useDatabaseDoc(selectedDatabaseSourceId) + const { + undo: undoSelectedSource, + redo: redoSelectedSource, + canUndo: canUndoSelectedSource, + canRedo: canRedoSelectedSource + } = useUndo(selectedSourceNodeIds.length === 1 ? selectedSourceNodeIds[0] : null, { + localDID: did ?? null, + options: { + mergeInterval: 750 + } + }) + const { + undo: undoSelectedSourceScope, + redo: redoSelectedSourceScope, + canUndo: canUndoSelectedSourceScope, + canRedo: canRedoSelectedSourceScope + } = useUndoScope(selectedSourceNodeIds, { + localDID: did ?? null, + options: { + mergeInterval: 750 + } + }) + + const currentCanvasSourceReferences = useMemo(() => { + void sceneRevision + + if (!doc || !selectedCanvasObject?.sourceId) { + return [] + } + + const refs: CanvasSourceReference[] = [] + const nodesMap = getCanvasObjectsMap(doc) + + nodesMap.forEach((value: unknown, key: string) => { + const node = value as CanvasNode + if (getCanvasShellSourceId(node) !== selectedCanvasObject.sourceId) { + return + } + + if (key === selectedCanvasObject.node.id) { + return + } + + refs.push({ + sourceNodeId: selectedCanvasObject.sourceId, + canvasId: docId, + canvasTitle: canvas?.title || 'Workspace Canvas', + objectId: key, + objectType: node.type, + alias: typeof node.alias === 'string' && node.alias.trim().length > 0 ? node.alias : null, + title: + node.alias ?? + (node.properties.title as string) ?? + documentMap.get(selectedCanvasObject.sourceId)?.title ?? + 'Untitled', + isCurrentCanvas: true + }) + }) + + return refs + }, [canvas?.title, doc, docId, documentMap, sceneRevision, selectedCanvasObject]) + + const selectedSourceReferences = useMemo(() => { + if (!selectedCanvasObject?.sourceId) { + return [] + } + + const merged = new Map() + + currentCanvasSourceReferences.forEach((reference) => { + merged.set(reference.objectId, reference) + }) + + getReferences(selectedCanvasObject.sourceId, { + excludeObjectId: selectedCanvasObject.node.id + }).forEach((reference) => { + merged.set(reference.objectId, reference) + }) + + return Array.from(merged.values()).sort(sortCanvasSourceReferences) + }, [currentCanvasSourceReferences, getReferences, selectedCanvasObject]) + const selectedObjectCommentCount = useMemo(() => { + if (!selectedCanvasObject) { + return 0 + } + + return canvasObjectCommentThreads.filter((thread) => { + try { + return ( + decodeAnchor(thread.root.properties.anchorData).objectId === + selectedCanvasObject.node.id + ) + } catch { + return false + } + }).length + }, [canvasObjectCommentThreads, selectedCanvasObject]) + const recordUndoBoundary = useCallback((domain: CanvasUndoDomain) => { + undoOrderSequenceRef.current += 1 + undoOrderRef.current[domain].push(undoOrderSequenceRef.current) + redoOrderRef.current = createUndoOrderMap() + setActiveUndoDomain(domain) + }, []) + const getUndoBoundaryOrder = useCallback( + (domain: CanvasUndoDomain, direction: 'undo' | 'redo'): number => { + const stack = + direction === 'undo' ? undoOrderRef.current[domain] : redoOrderRef.current[domain] + return stack.length > 0 ? (stack.at(-1) ?? -1) : -1 + }, + [] + ) + const applyUndoBoundary = useCallback((domain: CanvasUndoDomain, direction: 'undo' | 'redo') => { + const sourceStack = + direction === 'undo' ? undoOrderRef.current[domain] : redoOrderRef.current[domain] + const targetStack = + direction === 'undo' ? redoOrderRef.current[domain] : undoOrderRef.current[domain] + const boundaryOrder = sourceStack.pop() + + if (typeof boundaryOrder === 'number') { + targetStack.push(boundaryOrder) + } + + setActiveUndoDomain(domain) + }, []) + const canvasPresenceIntent = useMemo(() => { + if (peekState) { + return { + activity: 'peeking' as const, + editingNodeId: peekState.nodeId + } + } + + if (!selectedCanvasObject) { + return null + } + + if (selectionPanel === 'comment') { + return { + activity: 'commenting' as const, + editingNodeId: selectedCanvasObject.node.id + } + } + + if (selectionPanel === 'alias') { + return { + activity: 'editing' as const, + editingNodeId: selectedCanvasObject.node.id + } + } + + return null + }, [peekState, selectedCanvasObject, selectionPanel]) + + const peekedCanvasObject = useMemo(() => { + if (!peekState || !selectedCanvasObject) { + return null + } + + return selectedCanvasObject.node.id === peekState.nodeId && + selectedCanvasObject.sourceId === peekState.sourceId && + selectedCanvasObject.displayType === peekState.displayType + ? selectedCanvasObject + : null + }, [peekState, selectedCanvasObject]) + + useEffect(() => { + if (!selectedDatabaseDoc) { + selectedDatabaseUndoManagerRef.current = null + return + } + + const dataMap = selectedDatabaseDoc.getMap('data') + const manager = new Y.UndoManager([dataMap], { captureTimeout: 300 }) + selectedDatabaseUndoManagerRef.current = manager + + return () => { + manager.destroy() + if (selectedDatabaseUndoManagerRef.current === manager) { + selectedDatabaseUndoManagerRef.current = null + } + } + }, [selectedDatabaseDoc]) useEffect(() => { if (!doc) return setCanvasReady(true) }, [doc]) + useEffect(() => { + if (!canvasReady || !canvasRef.current) return + lastViewportSnapshotRef.current = canvasRef.current.getViewportSnapshot() + }, [canvasReady]) + + useEffect(() => { + if (!peekState) { + return + } + + if ( + !selectedCanvasObject || + selectedCanvasObject.node.id !== peekState.nodeId || + selectedCanvasObject.sourceId !== peekState.sourceId || + selectedCanvasObject.displayType !== peekState.displayType + ) { + setPeekState(null) + } + }, [peekState, selectedCanvasObject]) + + useEffect(() => { + if (!selectedCanvasObject) { + setSelectionPanel(null) + setAliasDraft('') + setCommentDraft('') + return + } + + if ( + (selectionPanel === 'alias' || selectionPanel === 'references') && + !selectedCanvasObject.sourceId + ) { + setSelectionPanel(null) + } + + setAliasDraft(selectedCanvasObject.node.alias ?? '') + }, [selectedCanvasObject, selectionPanel]) + useEffect(() => { if (!doc) return - const nodesMap = doc.getMap('nodes') + const nodesMap = getCanvasObjectsMap(doc) const syncHasNodes = () => { setHasNodes(nodesMap.size > 0) + setSceneRevision((current) => current + 1) } syncHasNodes() @@ -148,47 +789,85 @@ export const CanvasView = forwardRef(function } }, [doc]) - const addCanvasNote = useCallback(() => { - if (!doc || !canvasRef.current) return + useEffect(() => { + const testHarness = window as Window & { + __xnetCanvasTestHarness?: { + registerCanvasDoc?: (canvasId: string, doc: import('yjs').Doc | null) => void + registerCanvasAwareness?: (canvasId: string, awareness: unknown | null) => void + } | null + } - const viewport = canvasRef.current.getViewportSnapshot() - const nodesMap = doc.getMap('nodes') - const noteNode = createNode( - 'card', - getCanvasShellNotePlacement(viewport), - createCanvasShellNoteProperties() - ) + testHarness.__xnetCanvasTestHarness?.registerCanvasDoc?.(docId, doc) + testHarness.__xnetCanvasTestHarness?.registerCanvasAwareness?.(docId, awareness ?? null) - nodesMap.set(noteNode.id, noteNode) - }, [doc]) + return () => { + testHarness.__xnetCanvasTestHarness?.registerCanvasDoc?.(docId, null) + testHarness.__xnetCanvasTestHarness?.registerCanvasAwareness?.(docId, null) + } + }, [awareness, doc, docId]) - const addLinkedDocumentNode = useCallback( - (document: LinkedDocumentItem) => { - if (!doc || !canvasRef.current) return + const placeLinkedDocumentNode = useCallback( + (document: LinkedDocumentItem): boolean => { + if (document.type === 'canvas') { + return false + } - const viewport = canvasRef.current.getViewportSnapshot() - const nodesMap = doc.getMap('nodes') - const linkedNode = createNode('embed', getLinkedDocumentPlacement(viewport, document.type), { - title: document.title, - linkedType: document.type - }) - linkedNode.linkedNodeId = document.id - nodesMap.set(linkedNode.id, linkedNode) + const canvasKind = document.canvasKind ?? document.type + const properties = + canvasKind === 'note' + ? { + ...createCanvasShellNoteProperties(), + title: document.title + } + : { title: document.title } + + const placed = Boolean( + placeSourceObject({ + objectKind: canvasKind, + sourceNodeId: document.id, + sourceSchemaId: + document.type === 'page' ? PageSchema._schemaId : DatabaseSchema._schemaId, + title: document.title, + properties + }) + ) + + if (placed) { + recordUndoBoundary('scene') + } + + return placed }, - [doc] + [placeSourceObject, recordUndoBoundary] ) + useEffect(() => { + if (!pendingInsert || handledInsertIdsRef.current.has(pendingInsert.requestId)) { + return + } + + const inserted = placeLinkedDocumentNode(pendingInsert.document) + + if (!inserted) { + return + } + + handledInsertIdsRef.current.add(pendingInsert.requestId) + onPendingInsertConsumed?.(pendingInsert.requestId) + }, [onPendingInsertConsumed, pendingInsert, placeLinkedDocumentNode]) + const focusLinkedDocument = useCallback( (linkedDocumentId: string): ViewportSnapshot | null => { if (!doc || !canvasRef.current) return null - const nodesMap = doc.getMap('nodes') + const nodesMap = getCanvasObjectsMap(doc) 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 +875,706 @@ export const CanvasView = forwardRef(function ) const restoreViewport = useCallback((snapshot: ViewportSnapshot) => { + lastViewportSnapshotRef.current = snapshot canvasRef.current?.setViewportSnapshot(snapshot) }, []) + const focusCanvasSurface = useCallback(() => { + window.requestAnimationFrame(() => { + document.querySelector('[data-canvas-surface="true"]')?.focus() + }) + }, []) + + const closePeekSurface = useCallback(() => { + setPeekState(null) + focusCanvasSurface() + }, [focusCanvasSurface]) + + const closeSelectionPanel = useCallback(() => { + setSelectionPanel(null) + focusCanvasSurface() + }, [focusCanvasSurface]) + + const clearCanvasSelection = useCallback(() => { + closeSelectionPanel() + closePeekSurface() + canvasRef.current?.clearSelection() + }, [closePeekSurface, closeSelectionPanel]) + + const zoomCanvas = useCallback((direction: 'out' | 'in'): boolean => { + const handle = canvasRef.current + if (!handle) { + return false + } + + const snapshot = handle.getViewportSnapshot() + const nextZoom = + direction === 'in' ? Math.min(snapshot.zoom * 1.5, 4) : Math.max(snapshot.zoom / 1.5, 0.1) + + if (nextZoom === snapshot.zoom) { + return false + } + + const nextSnapshot = { + ...snapshot, + zoom: nextZoom + } + + lastViewportSnapshotRef.current = nextSnapshot + handle.setViewportSnapshot(nextSnapshot) + return true + }, []) + + const fitCanvasContent = useCallback((): boolean => { + const handle = canvasRef.current + if (!handle) { + return false + } + + handle.fitToContent(50) + lastViewportSnapshotRef.current = handle.getViewportSnapshot() + return true + }, []) + + const resetCanvasView = useCallback((): boolean => { + const handle = canvasRef.current + if (!handle) { + return false + } + + handle.resetView() + lastViewportSnapshotRef.current = handle.getViewportSnapshot() + return true + }, []) + + const fitSelection = useCallback((): boolean => { + if (selectedNodes.length === 0) { + return false + } + + if (selectedNodes.length === 1) { + canvasRef.current?.fitToRect(getNodeRect(selectedNodes[0]), 140) + return true + } + + const selectionBounds = getSelectionBounds(selectedNodes) + if (!selectionBounds) { + return false + } + + canvasRef.current?.fitToRect(selectionBounds, 140) + return true + }, [selectedNodes]) + + const toggleSelectionLock = useCallback((): boolean => { + return canvasRef.current?.toggleSelectionLock() ?? false + }, []) + + const alignSelection = useCallback( + (alignment: Extract): boolean => { + return canvasRef.current?.alignSelection(alignment) ?? false + }, + [] + ) + + const distributeSelection = useCallback((axis: CanvasDistributionAxis): boolean => { + return canvasRef.current?.distributeSelection(axis) ?? false + }, []) + + const tidySelection = useCallback((): boolean => { + return canvasRef.current?.tidySelection() ?? false + }, []) + + const shiftSelectionLayer = useCallback((direction: CanvasLayerDirection): boolean => { + return canvasRef.current?.shiftSelectionLayer(direction) ?? false + }, []) + + const connectSelection = useCallback((): boolean => { + return canvasRef.current?.connectSelection() ?? false + }, []) + + const focusSelectionSurface = useCallback( + ( + sourceId: string, + displayType: PeekableCanvasDisplayType, + scope: 'peek' | 'inline' = 'inline' + ) => { + window.requestAnimationFrame(() => { + const targetSelector = + displayType === 'database' + ? `[data-canvas-source-id="${sourceId}"] [data-canvas-database-title="true"]` + : `[data-canvas-source-id="${sourceId}"] [data-canvas-page-title="true"]` + const scopeSelector = + scope === 'peek' ? `[data-canvas-peek-surface="true"] ${targetSelector}` : targetSelector + const target = + document.querySelector(scopeSelector) ?? + document.querySelector(targetSelector) + target?.focus() + if (target instanceof HTMLInputElement) { + target.select() + } + }) + }, + [] + ) + + useEffect(() => { + if (!peekState?.sourceId || !isPeekableCanvasDisplayType(peekState.displayType)) { + return + } + + focusSelectionSurface(peekState.sourceId, peekState.displayType, 'peek') + }, [focusSelectionSurface, peekState]) + + const openSelection = useCallback( + (mode: 'peek' | 'focus' | 'split' = 'focus'): boolean => { + if (!selectedCanvasObject) { + return false + } + + if (mode === 'peek') { + const didFit = fitSelection() + + if ( + selectedCanvasObject.sourceId && + isPeekableCanvasDisplayType(selectedCanvasObject.displayType) + ) { + setPeekState({ + nodeId: selectedCanvasObject.node.id, + sourceId: selectedCanvasObject.sourceId, + displayType: selectedCanvasObject.displayType + }) + focusSelectionSurface( + selectedCanvasObject.sourceId, + selectedCanvasObject.displayType, + 'peek' + ) + return true + } + + return didFit + } + + if ( + mode === 'split' && + selectedCanvasObject.displayType === 'database' && + selectedCanvasObject.sourceId + ) { + if (!onOpenDatabaseSplit) { + return false + } + + closePeekSurface() + onOpenDatabaseSplit?.(selectedCanvasObject.sourceId) + return true + } + + if (!selectedCanvasObject.sourceId || !selectedCanvasObject.sourceType) { + return false + } + + closePeekSurface() + onOpenDocument?.(selectedCanvasObject.sourceId, selectedCanvasObject.sourceType) + return true + }, + [ + closePeekSurface, + fitSelection, + focusSelectionSurface, + onOpenDatabaseSplit, + 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] + ) + + useEffect(() => { + if (!peekedCanvasObject) { + return + } + + const handleWindowKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return + } + + event.preventDefault() + closePeekSurface() + } + + window.addEventListener('keydown', handleWindowKeyDown, true) + return () => { + window.removeEventListener('keydown', handleWindowKeyDown, true) + } + }, [closePeekSurface, peekedCanvasObject]) + + const toggleShortcutHelp = useCallback( + (open?: boolean) => { + const nextOpen = typeof open === 'boolean' ? open : !shortcutHelpOpen + setShortcutHelpOpen(nextOpen) + + if (!nextOpen) { + focusCanvasSurface() + } + }, + [focusCanvasSurface, shortcutHelpOpen] + ) + + const runCanvasScopedUndo = useCallback( + (direction: 'undo' | 'redo'): boolean => { + const canSelectedSource = direction === 'undo' ? canUndoSelectedSource : canRedoSelectedSource + const canSelectedSourceScope = + direction === 'undo' ? canUndoSelectedSourceScope : canRedoSelectedSourceScope + const canSelectedSourceDocument = + getYjsStackDepth( + selectedDatabaseUndoManagerRef.current, + direction === 'undo' ? 'undoStack' : 'redoStack' + ) > 0 + + const runScene = (): boolean => { + const handled = + direction === 'undo' + ? (canvasRef.current?.undo() ?? false) + : (canvasRef.current?.redo() ?? false) + + if (handled) { + applyUndoBoundary('scene', direction) + } + + return handled + } + + const runSelectedSource = (): boolean => { + if (!canSelectedSource) { + return false + } + + applyUndoBoundary('source-node', direction) + void (direction === 'undo' ? undoSelectedSource() : redoSelectedSource()) + return true + } + + const runSelectedSourceScope = (): boolean => { + if (!canSelectedSourceScope) { + return false + } + + applyUndoBoundary('source-scope', direction) + void (direction === 'undo' ? undoSelectedSourceScope() : redoSelectedSourceScope()) + return true + } + + const runSelectedSourceDocument = (): boolean => { + if (!canSelectedSourceDocument || !selectedDatabaseUndoManagerRef.current) { + return false + } + + applyUndoBoundary('source-document', direction) + if (direction === 'undo') { + selectedDatabaseUndoManagerRef.current.undo() + } else { + selectedDatabaseUndoManagerRef.current.redo() + } + return true + } + + const orderedDomains = ( + [ + { domain: 'scene', available: true, run: runScene }, + { + domain: 'source-document', + available: canSelectedSourceDocument, + run: runSelectedSourceDocument + }, + { + domain: 'source-scope', + available: canSelectedSourceScope, + run: runSelectedSourceScope + }, + { domain: 'source-node', available: canSelectedSource, run: runSelectedSource } + ] as const + ) + .filter((entry) => entry.available) + .sort( + (left, right) => + getUndoBoundaryOrder(right.domain, direction) - + getUndoBoundaryOrder(left.domain, direction) + ) + + for (const entry of orderedDomains) { + if (entry.run()) { + return true + } + } + + return false + }, + [ + applyUndoBoundary, + canRedoSelectedSource, + canRedoSelectedSourceScope, + canUndoSelectedSource, + canUndoSelectedSourceScope, + getUndoBoundaryOrder, + redoSelectedSource, + redoSelectedSourceScope, + undoSelectedSource, + undoSelectedSourceScope + ] + ) + + const handleDismissTransientUi = useCallback((): boolean => { + if (selectionPanel) { + closeSelectionPanel() + return true + } + + if (peekedCanvasObject) { + closePeekSurface() + return true + } + + if (!shortcutHelpOpen) { + return false + } + + setShortcutHelpOpen(false) + return true + }, [closePeekSurface, closeSelectionPanel, peekedCanvasObject, selectionPanel, shortcutHelpOpen]) + + const setSelectedSourceAlias = useCallback( + (nextAlias: string | null): boolean => { + if (!doc || !selectedCanvasObject?.sourceId) { + return false + } + + const nodesMap = getCanvasObjectsMap(doc) + const current = nodesMap.get(selectedCanvasObject.node.id) + if (!current) { + return false + } + + const normalized = nextAlias?.trim() ?? '' + const resolvedAlias = normalized.length > 0 ? normalized : undefined + + if ((current.alias ?? undefined) === resolvedAlias) { + closeSelectionPanel() + return true + } + + doc.transact(() => { + nodesMap.set(current.id, { + ...current, + alias: resolvedAlias + }) + }) + + recordUndoBoundary('scene') + + closeSelectionPanel() + return true + }, + [closeSelectionPanel, doc, recordUndoBoundary, selectedCanvasObject] + ) + + const openAliasEditor = useCallback((): boolean => { + if (!selectedCanvasObject?.sourceId) { + return false + } + + setAliasDraft(selectedCanvasObject.node.alias ?? '') + setSelectionPanel('alias') + return true + }, [selectedCanvasObject]) + + const openCommentComposer = useCallback((): boolean => { + if (!selectedCanvasObject) { + return false + } + + setCommentDraft('') + setSelectionPanel('comment') + return true + }, [selectedCanvasObject]) + + const clearSelectionAlias = useCallback((): boolean => { + return setSelectedSourceAlias(null) + }, [setSelectedSourceAlias]) + + const toggleSourceReferences = useCallback( + (open?: boolean): boolean => { + if (!selectedCanvasObject?.sourceId) { + return false + } + + setSelectionPanel((current) => { + const nextOpen = typeof open === 'boolean' ? open : current !== 'references' + return nextOpen ? 'references' : null + }) + return true + }, + [selectedCanvasObject] + ) + + const handleRevealSourceReference = useCallback( + (objectId: string): boolean => { + if (!doc) { + return false + } + + const node = getCanvasObjectsMap(doc).get(objectId) + if (!node) { + return false + } + + closeSelectionPanel() + closePeekSurface() + canvasRef.current?.selectNodes([objectId]) + canvasRef.current?.fitToRect(getNodeRect(node), 140) + return true + }, + [closePeekSurface, closeSelectionPanel, doc] + ) + + useEffect(() => { + if (selectionPanel !== 'alias') { + return + } + + window.requestAnimationFrame(() => { + aliasInputRef.current?.focus() + aliasInputRef.current?.select() + }) + }, [selectionPanel]) + + useEffect(() => { + if (selectionPanel !== 'comment') { + return + } + + window.requestAnimationFrame(() => { + commentInputRef.current?.focus() + }) + }, [selectionPanel]) + + const submitSelectionComment = useCallback(async (): Promise => { + if (!selectedCanvasObject) { + return false + } + + const content = commentDraft.trim() + if (!content) { + return false + } + + const anchor: CanvasObjectAnchor = { + objectId: selectedCanvasObject.node.id, + anchorId: createCanvasObjectAnchorId({ + objectId: selectedCanvasObject.node.id, + placement: 'right' + }), + placement: 'right' + } + + const createdCommentId = await addCanvasComment({ + content, + anchorType: 'canvas-object', + anchorData: encodeAnchor(anchor), + targetSchema: CanvasSchema._schemaId + }) + + if (!createdCommentId) { + return false + } + + setCommentDraft('') + closeSelectionPanel() + return true + }, [addCanvasComment, closeSelectionPanel, commentDraft, selectedCanvasObject]) + + const createShape = useCallback( + (shapeType: ShapeType = 'rectangle'): boolean => { + const created = Boolean( + placePrimitiveObject({ + objectKind: 'shape', + title: getShapeLabel(shapeType), + properties: { + title: getShapeLabel(shapeType), + label: getShapeLabel(shapeType), + shapeType + } + }) + ) + + if (created) { + recordUndoBoundary('scene') + } + + return created + }, + [placePrimitiveObject, recordUndoBoundary] + ) + + const createFrame = useCallback((): boolean => { + const created = Boolean( + placePrimitiveObject({ + objectKind: 'group', + title: 'Frame', + rect: { + width: 640, + height: 420 + }, + properties: { + title: 'Frame', + containerRole: 'frame', + memberIds: [], + memberCount: 0 + } + }) + ) + + if (created) { + recordUndoBoundary('scene') + } + + return created + }, [placePrimitiveObject, recordUndoBoundary]) + + const wrapSelectionInFrame = useCallback((): boolean => { + return canvasRef.current?.wrapSelectionInFrame() ?? false + }, []) + + const handleCreateObject = useCallback( + (kind: 'page' | 'database' | 'note' | 'shape' | 'frame') => { + if (kind === 'page') { + onCreatePage?.() + return + } + + if (kind === 'database') { + onCreateDatabase?.() + return + } + + if (kind === 'shape') { + createShape() + return + } + + if (kind === 'frame') { + createFrame() + return + } + + onCreateNote?.() + }, + [createFrame, createShape, 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, + selectionAllLocked, + selectionAnyLocked, + shortcutHelpOpen + }) + }, [ + onCommandStateChange, + selectedCanvasObject, + selection.nodeIds.length, + selectionAllLocked, + selectionAnyLocked, + shortcutHelpOpen + ]) + useImperativeHandle( ref, () => ({ - addLinkedDocumentNode, - addCanvasNote, focusLinkedDocument, - restoreViewport + restoreViewport, + zoomOut: () => zoomCanvas('out'), + zoomIn: () => zoomCanvas('in'), + fitCanvasContent, + resetCanvasView, + clearSelection: clearCanvasSelection, + fitSelection, + openSelection, + toggleSelectionLock, + alignSelection, + distributeSelection, + tidySelection, + shiftSelectionLayer, + connectSelection, + createShape, + createFrame, + wrapSelectionInFrame, + openAliasEditor, + openCommentComposer, + clearSelectionAlias, + toggleSourceReferences, + toggleShortcutHelp }), - [addCanvasNote, addLinkedDocumentNode, focusLinkedDocument, restoreViewport] + [ + alignSelection, + clearCanvasSelection, + createFrame, + createShape, + connectSelection, + clearSelectionAlias, + distributeSelection, + fitCanvasContent, + fitSelection, + focusLinkedDocument, + openAliasEditor, + openCommentComposer, + openSelection, + resetCanvasView, + restoreViewport, + shiftSelectionLayer, + tidySelection, + toggleSourceReferences, + toggleSelectionLock, + toggleShortcutHelp, + wrapSelectionInFrame, + zoomCanvas + ] ) if (loading || !doc) { @@ -227,18 +1594,634 @@ export const CanvasView = forwardRef(function } return ( -
-
+
+
{canvas?.title || 'Workspace Canvas'}
+ {selection.nodeIds.length > 0 ? ( +
+
+ + {selectedCanvasObject + ? `${ + selectedCanvasObject.displayType === 'note' + ? 'Note' + : selectedCanvasObject.displayType === 'database' + ? 'Database' + : selectedCanvasObject.displayType === 'external-reference' + ? 'Link' + : selectedCanvasObject.displayType === 'media' + ? 'Media' + : selectedCanvasObject.displayType === 'shape' + ? 'Shape' + : selectedCanvasObject.displayType === 'frame' + ? 'Frame' + : 'Page' + } · ${selectedCanvasObject.title}` + : `${selection.nodeIds.length} selected`} + + + {selectedCanvasObject ? ( + <> + + {selectedCanvasObject.sourceId && selectedCanvasObject.sourceType ? ( + + ) : null} + {selectedCanvasObject.displayType === 'database' && + selectedCanvasObject.sourceId ? ( + + ) : null} + {selectedCanvasObject.sourceId ? ( + + ) : null} + {selectedCanvasObject.sourceId ? ( + + ) : null} + + + ) : null} + + + + {selection.nodeIds.length > 1 ? ( + <> + {selection.nodeIds.length === 2 ? ( + + ) : null} + + + + + + + + ) : null} + + {selection.nodeIds.length > 0 ? ( + <> + + + + + ) : null} + + +
+
+ ) : 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'} +
+ +
+