diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index d3e962e05..584917d3d 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -1,28 +1,37 @@ /** * Electron App - Main component - * - * Uses @xnetjs/react hooks for data management: - * - useQuery for listing documents - * - useNode for editing documents - * - useMutate for creating/deleting */ +import type { PaletteCommand } from '@xnetjs/ui' import { PageSchema, DatabaseSchema, CanvasSchema } from '@xnetjs/data' import { useDevTools } from '@xnetjs/devtools' import { useQuery, useMutate } from '@xnetjs/react' -import { ThemeToggle } from '@xnetjs/ui' -import React, { useCallback, useEffect, useState } from 'react' +import { CommandPalette, useCommandPalette, usePrefersReducedMotion } from '@xnetjs/ui' +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 } from './components/CanvasView' +import { CanvasView, type CanvasViewHandle } from './components/CanvasView' import { DatabaseView } from './components/DatabaseView' import { PageView } from './components/PageView' import { SettingsView } from './components/SettingsView' -import { Sidebar } from './components/Sidebar' +import { SystemMenu } from './components/SystemMenu' type DocType = 'page' | 'database' | 'canvas' -interface DocumentItem { +type ViewportSnapshot = { + x: number + y: number + zoom: number +} + +type ShellState = + | { kind: 'canvas-home' } + | { kind: 'page-focus'; docId: string; returnViewport: ViewportSnapshot | null } + | { kind: 'database-focus'; docId: string; returnViewport: ViewportSnapshot | null } + | { kind: 'settings' } + +type DocumentItem = { id: string title: string type: DocType @@ -30,48 +39,70 @@ interface DocumentItem { updatedAt?: number } -export function App() { - const [selectedDocId, setSelectedDocId] = useState(null) - const [selectedDocType, setSelectedDocType] = useState('page') +const OVERLAY_OPEN_DELAY_MS = 180 + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +export function App(): React.ReactElement { + const [homeCanvasId, setHomeCanvasId] = useState(null) + const [homeCanvasBootstrapError, setHomeCanvasBootstrapError] = useState(null) + const [shellState, setShellState] = useState({ kind: 'canvas-home' }) const [showAddSharedDialog, setShowAddSharedDialog] = useState(false) const [prefilledShareValue, setPrefilledShareValue] = useState('') - const [showSettings, setShowSettings] = useState(false) const { setActiveNodeId } = useDevTools() + const { create } = useMutate() + const { open: paletteOpen, setOpen: setPaletteOpen, show: showPalette } = useCommandPalette() + const prefersReducedMotion = usePrefersReducedMotion() + const canvasViewRef = useRef(null) + const creatingHomeCanvasRef = useRef(false) + const transitionTimerRef = useRef(null) - // Query all document types const { data: pages, loading: pagesLoading } = useQuery(PageSchema, { limit: 100 }) const { data: databases, loading: databasesLoading } = useQuery(DatabaseSchema, { limit: 100 }) const { data: canvases, loading: canvasesLoading } = useQuery(CanvasSchema, { limit: 100 }) - // Mutations - const { create, remove } = useMutate() - - // Combine all documents into a single list - const documents: DocumentItem[] = [ - ...pages.map((p) => ({ - id: p.id, - title: p.title || 'Untitled', - type: 'page' as DocType, - createdAt: p.createdAt, - updatedAt: p.updatedAt - })), - ...databases.map((d) => ({ - id: d.id, - title: d.title || 'Untitled', - type: 'database' as DocType, - createdAt: d.createdAt, - updatedAt: d.updatedAt - })), - ...canvases.map((c) => ({ - id: c.id, - title: c.title || 'Untitled', - type: 'canvas' as DocType, - createdAt: c.createdAt, - updatedAt: c.updatedAt - })) - ].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)) + const documents: DocumentItem[] = useMemo( + () => + [ + ...pages.map((page) => ({ + id: page.id, + title: page.title || 'Untitled Page', + type: 'page' as const, + createdAt: page.createdAt, + updatedAt: page.updatedAt + })), + ...databases.map((database) => ({ + id: database.id, + title: database.title || 'Untitled Database', + type: 'database' as const, + createdAt: database.createdAt, + updatedAt: database.updatedAt + })), + ...canvases.map((canvas) => ({ + id: canvas.id, + title: canvas.title || 'Workspace Canvas', + type: 'canvas' as const, + createdAt: canvas.createdAt, + updatedAt: canvas.updatedAt + })) + ].sort( + (left, right) => + (right.updatedAt || right.createdAt || 0) - (left.updatedAt || left.createdAt || 0) + ), + [canvases, databases, pages] + ) const isLoading = pagesLoading || databasesLoading || canvasesLoading + const recentDocuments = useMemo(() => documents.slice(0, 6), [documents]) + + const clearTransitionTimer = useCallback(() => { + if (transitionTimerRef.current !== null) { + window.clearTimeout(transitionTimerRef.current) + transitionTimerRef.current = null + } + }, []) useEffect(() => { const cleanup = window.xnet.onSharePayload((payload) => { @@ -81,112 +112,316 @@ export function App() { return cleanup }, []) - // Handle document selection - const handleSelect = useCallback( - (id: string) => { - const doc = documents.find((d) => d.id === id) - if (doc) { - setSelectedDocId(id) - setSelectedDocType(doc.type) - setActiveNodeId(id) + useEffect(() => { + return () => { + clearTransitionTimer() + } + }, [clearTransitionTimer]) + + const bootstrapHomeCanvas = useCallback(async () => { + if (creatingHomeCanvasRef.current) return + + creatingHomeCanvasRef.current = true + setHomeCanvasBootstrapError(null) + + try { + const canvas = await create(CanvasSchema, { title: 'Workspace Canvas' }) + if (!canvas) { + throw new Error('Home canvas was not created') } - }, - [documents, setActiveNodeId] - ) - // Handle document creation - const handleCreate = useCallback( - async (type: DocType) => { - const titleMap: Record = { - page: 'Untitled Page', - database: 'Untitled Database', - canvas: 'Untitled Canvas' + setHomeCanvasId(canvas.id) + setActiveNodeId(canvas.id) + } catch (error) { + const normalizedError = toError(error) + console.error('Failed to create home canvas', normalizedError) + setHomeCanvasBootstrapError(normalizedError) + } finally { + creatingHomeCanvasRef.current = false + } + }, [create, setActiveNodeId]) + + useEffect(() => { + if (isLoading) return + + if (canvases.length === 0) { + if (homeCanvasBootstrapError) return + if (homeCanvasId) { + setHomeCanvasId(null) + } + void bootstrapHomeCanvas() + return + } + + if (homeCanvasBootstrapError) { + setHomeCanvasBootstrapError(null) + } + + if (!homeCanvasId || !canvases.some((canvas) => canvas.id === homeCanvasId)) { + const defaultCanvas = [...canvases].sort( + (left, right) => + (right.updatedAt || right.createdAt || 0) - (left.updatedAt || left.createdAt || 0) + )[0] + + if (defaultCanvas) { + setHomeCanvasId(defaultCanvas.id) + setActiveNodeId(defaultCanvas.id) } + } + }, [ + bootstrapHomeCanvas, + canvases, + homeCanvasBootstrapError, + homeCanvasId, + isLoading, + setActiveNodeId + ]) + + const focusDocument = useCallback( + (docId: string, docType: Exclude, animateFromCanvas: boolean) => { + clearTransitionTimer() + + const shouldAnimateFromCanvas = animateFromCanvas && !prefersReducedMotion + const returnViewport = + shouldAnimateFromCanvas && canvasViewRef.current + ? canvasViewRef.current.focusLinkedDocument(docId) + : null - let newDoc - switch (type) { - case 'page': - newDoc = await create(PageSchema, { title: titleMap[type] }) - break - case 'database': - newDoc = await create(DatabaseSchema, { title: titleMap[type] }) - break - case 'canvas': - newDoc = await create(CanvasSchema, { title: titleMap[type] }) - break + const openOverlay = () => { + setShellState( + docType === 'page' + ? { kind: 'page-focus', docId, returnViewport } + : { kind: 'database-focus', docId, returnViewport } + ) + setActiveNodeId(docId) } - if (newDoc) { - setSelectedDocId(newDoc.id) - setSelectedDocType(type) - setActiveNodeId(newDoc.id) + if (returnViewport && !prefersReducedMotion) { + transitionTimerRef.current = window.setTimeout(openOverlay, OVERLAY_OPEN_DELAY_MS) + return } + + openOverlay() }, - [create, setActiveNodeId] + [clearTransitionTimer, prefersReducedMotion, setActiveNodeId] ) - // Handle document deletion - const handleDelete = useCallback( - async (id: string) => { - await remove(id) - if (selectedDocId === id) { - setSelectedDocId(null) - setActiveNodeId(null) + const handleOpenDocument = useCallback( + (docId: string) => { + const document = documents.find((entry) => entry.id === docId) + if (!document) return + + if (document.type === 'canvas') { + clearTransitionTimer() + setHomeCanvasId(document.id) + setShellState({ kind: 'canvas-home' }) + setActiveNodeId(document.id) + return } + + focusDocument(document.id, document.type, true) }, - [remove, selectedDocId, setActiveNodeId] + [clearTransitionTimer, documents, focusDocument, setActiveNodeId] ) - // Handle adding a shared document + const handleCreateLinkedDocument = useCallback( + async (type: Exclude) => { + clearTransitionTimer() + + try { + const schema = type === 'page' ? PageSchema : DatabaseSchema + const title = type === 'page' ? 'Untitled Page' : 'Untitled Database' + const newDocument = await create(schema, { title }) + if (!newDocument) return + + canvasViewRef.current?.addLinkedDocumentNode({ + id: newDocument.id, + title, + type + }) + setShellState({ kind: 'canvas-home' }) + setActiveNodeId(homeCanvasId) + } catch (error) { + console.error('Failed to create linked document', toError(error)) + } + }, + [clearTransitionTimer, create, homeCanvasId, setActiveNodeId] + ) + + const handleCreateCanvasNote = useCallback(() => { + clearTransitionTimer() + canvasViewRef.current?.addCanvasNote() + setShellState({ kind: 'canvas-home' }) + setActiveNodeId(homeCanvasId) + }, [clearTransitionTimer, homeCanvasId, setActiveNodeId]) + + const handleReturnHome = useCallback(() => { + clearTransitionTimer() + if (shellState.kind === 'page-focus' || shellState.kind === 'database-focus') { + if (shellState.returnViewport) { + canvasViewRef.current?.restoreViewport(shellState.returnViewport) + } + } + + setShellState({ kind: 'canvas-home' }) + setActiveNodeId(homeCanvasId) + }, [clearTransitionTimer, homeCanvasId, setActiveNodeId, shellState]) + const handleAddShared = useCallback( async (input: AddSharedInput) => { if (input.share) { - await window.__xnetIpcSyncManager?.configureShareSession({ - signalingUrl: input.share.endpoint, - ucanToken: input.share.token, - transport: input.share.transport, - iceServers: input.share.iceServers - }) + try { + await window.__xnetIpcSyncManager?.configureShareSession({ + signalingUrl: input.share.endpoint, + ucanToken: input.share.token, + transport: input.share.transport, + iceServers: input.share.iceServers + }) + } catch (error) { + console.error('Failed to configure shared session', toError(error)) + } + } + + if (input.docType === 'canvas') { + clearTransitionTimer() + setHomeCanvasId(input.docId) + setShellState({ kind: 'canvas-home' }) + setActiveNodeId(input.docId) + return } - setSelectedDocId(input.docId) - setSelectedDocType(input.docType) - setActiveNodeId(input.docId) + focusDocument(input.docId, input.docType, false) }, - [setActiveNodeId] + [clearTransitionTimer, focusDocument, setActiveNodeId] ) - // Render content based on document type or settings - const renderContent = () => { - // Show settings if open - if (showSettings) { - return setShowSettings(false)} /> + const overlayTitle = useMemo(() => { + if (shellState.kind === 'page-focus') return 'Document' + if (shellState.kind === 'database-focus') return 'Database' + if (shellState.kind === 'settings') return 'Settings' + return null + }, [shellState.kind]) + + const handleOpenSettings = useCallback(() => { + clearTransitionTimer() + setShellState({ kind: 'settings' }) + }, [clearTransitionTimer]) + + const paletteCommands = useMemo( + () => [ + { + id: 'create-page', + name: 'Create Page', + description: 'Create a new page and place it on the canvas', + icon: 'file-text', + execute: () => void handleCreateLinkedDocument('page') + }, + { + id: 'create-database', + name: 'Create Database', + description: 'Create a new database and place it on the canvas', + icon: 'database', + execute: () => void handleCreateLinkedDocument('database') + }, + { + id: 'create-note', + name: 'Create Canvas Note', + description: 'Add a lightweight note card to the workspace', + icon: 'sparkles', + execute: () => handleCreateCanvasNote() + }, + { + id: 'open-settings', + name: 'Open Settings', + description: 'Open the system settings overlay', + icon: 'settings', + execute: handleOpenSettings + }, + ...recentDocuments.map((document) => ({ + id: `open-${document.id}`, + name: document.title, + description: `Open ${document.type}`, + icon: + document.type === 'page' + ? 'file-text' + : document.type === 'database' + ? 'database' + : 'layout', + group: 'Recent', + execute: () => handleOpenDocument(document.id) + })) + ], + [ + handleCreateCanvasNote, + handleCreateLinkedDocument, + handleOpenDocument, + handleOpenSettings, + recentDocuments + ] + ) + + const renderOverlay = () => { + const overlaySurfaceClassName = [ + 'flex h-full overflow-hidden rounded-[32px] border border-border/70 bg-background shadow-2xl shadow-black/10', + prefersReducedMotion ? '' : 'animate-in fade-in zoom-in-95 duration-200' + ].join(' ') + + if (shellState.kind === 'canvas-home') { + return null } - if (!selectedDocId) { + if (shellState.kind === 'settings') { return ( -
-

Welcome to xNet

-

Select a document or create a new one

+
+
+ +
) } - switch (selectedDocType) { - case 'page': - return - case 'database': - return - case 'canvas': - return - default: - return null - } + return ( +
+
+
+
+ {overlayTitle} +
+
+ +
+ {shellState.kind === 'page-focus' ? ( + + ) : ( + + )} +
+
+
+ ) } - if (isLoading) { + if (homeCanvasBootstrapError && !homeCanvasId) { return ( -
+
+
+

Unable to create your workspace canvas.

+

{homeCanvasBootstrapError.message}

+
+ +
+ ) + } + + if (isLoading || !homeCanvasId) { + return ( +

Loading xNet...

@@ -195,35 +430,58 @@ export function App() { } return ( -
- {/* Titlebar */} -
+
+
-

xNet

- +
+ { + setPrefilledShareValue('') + setShowAddSharedDialog(true) + }} + onToggleDebugPanel={() => { + window.dispatchEvent(new CustomEvent('xnet-devtools-toggle')) + }} + /> +
- {/* Main content */} -
- {/* Sidebar */} - { - setPrefilledShareValue('') - setShowAddSharedDialog(true) - }} - onSettings={() => setShowSettings(true)} - /> +
+
+ focusDocument(docId, docType, true)} + /> +
+ + {renderOverlay()} - {/* Content area */} -
{renderContent()}
+ void handleCreateLinkedDocument('page')} + onCreateDatabase={() => void handleCreateLinkedDocument('database')} + onCreateNote={handleCreateCanvasNote} + onOpenRecent={showPalette} + onOpenSearch={showPalette} + onReturnHome={handleReturnHome} + />
- {/* Add Shared Dialog */} { @@ -234,7 +492,8 @@ export function App() { initialValue={prefilledShareValue} /> - {/* Auto-install bundled plugins */} + +
) diff --git a/apps/electron/src/renderer/components/ActionDock.tsx b/apps/electron/src/renderer/components/ActionDock.tsx new file mode 100644 index 000000000..16448e083 --- /dev/null +++ b/apps/electron/src/renderer/components/ActionDock.tsx @@ -0,0 +1,96 @@ +/** + * 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 React from 'react' + +export type DockMode = 'canvas-home' | 'focused' + +interface ActionDockProps { + mode: DockMode + onCreatePage: () => void + onCreateDatabase: () => void + onCreateNote: () => void + onOpenSearch: () => void + onOpenRecent: () => void + onReturnHome: () => void +} + +function DockButton({ + icon, + label, + onClick, + highlight = false +}: { + icon: React.ReactNode + label: string + onClick: () => void + highlight?: boolean +}): React.ReactElement { + return ( + + ) +} + +export function ActionDock({ + mode, + onCreatePage, + onCreateDatabase, + onCreateNote, + onOpenSearch, + onOpenRecent, + onReturnHome +}: ActionDockProps): React.ReactElement { + return ( +
+
+ {mode === 'focused' ? ( + } + label="Canvas" + onClick={onReturnHome} + highlight + /> + ) : ( + <> + } label="Page" onClick={onCreatePage} /> + } label="Database" onClick={onCreateDatabase} /> + } label="Note" 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" + onClick={onOpenSearch} + className="h-11 w-11 rounded-2xl bg-background/90 text-foreground shadow-sm" + /> +
+
+ ) +} diff --git a/apps/electron/src/renderer/components/CanvasView.tsx b/apps/electron/src/renderer/components/CanvasView.tsx index 4891921a2..a2e1b70f5 100644 --- a/apps/electron/src/renderer/components/CanvasView.tsx +++ b/apps/electron/src/renderer/components/CanvasView.tsx @@ -1,30 +1,117 @@ /** * Canvas View - Infinite canvas for spatial visualization - * - * Uses @xnetjs/react hooks and @xnetjs/canvas for the canvas component. */ -import { Canvas, createNode, createEdge, type CanvasHandle } from '@xnetjs/canvas' +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 { Plus, LayoutGrid, ZoomIn, Maximize2 } from 'lucide-react' -import React, { useEffect, useState, useCallback, useRef } from 'react' -import { PresenceAvatars } from './PresenceAvatars' -import { ShareButton } from './ShareButton' +import { IconButton } from '@xnetjs/ui' +import { Compass, Database, FileText, Maximize2, StickyNote } from 'lucide-react' +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { + createCanvasShellNoteProperties, + isCanvasShellNote, + shouldRenderCanvasShellCard, + type LinkedDocType, + type LinkedDocumentItem +} from '../lib/canvas-shell' -interface CanvasViewProps { +type ViewportSnapshot = { + x: number + y: number + zoom: number +} + +type CanvasViewProps = { docId: string + documents?: LinkedDocumentItem[] + 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, + y: node.position.y, + width: node.position.width, + height: node.position.height + } +} + +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 subtitle = + linkedType === 'page' + ? 'Document' + : linkedType === 'database' + ? 'Database' + : isCanvasShellNote(node) + ? 'Canvas note' + : 'Canvas' + + const Icon = linkedType === 'page' ? FileText : linkedType === 'database' ? Database : StickyNote + + return ( +
+
+ + + {subtitle} + + {node.linkedNodeId && linkedType !== 'canvas' ? ( + + Open + + ) : 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.'} +

+
+
+ ) } -export function CanvasView({ docId }: CanvasViewProps) { +export const CanvasView = forwardRef(function CanvasView( + { docId, documents = [], onOpenDocument }: CanvasViewProps, + ref +): React.ReactElement { const { did } = useIdentity() const { data: canvas, doc, loading, - update, - presence, awareness } = useNode(CanvasSchema, docId, { createIfMissing: { title: 'Untitled Canvas' }, @@ -33,72 +120,112 @@ export function CanvasView({ docId }: CanvasViewProps) { const canvasRef = useRef(null) const [canvasReady, setCanvasReady] = useState(false) + const [hasNodes, setHasNodes] = useState(false) + const documentMap = useMemo( + () => new Map(documents.map((entry) => [entry.id, entry])), + [documents] + ) - // Initialize canvas data structure if needed useEffect(() => { if (!doc) return + setCanvasReady(true) + }, [doc]) - 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' } - ) + useEffect(() => { + if (!doc) return - 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) - }) + const nodesMap = doc.getMap('nodes') + const syncHasNodes = () => { + setHasNodes(nodesMap.size > 0) } - setCanvasReady(true) + syncHasNodes() + nodesMap.observe(syncHasNodes) + + return () => { + nodesMap.unobserve(syncHasNodes) + } }, [doc]) - // Add a new node to the canvas - const handleAddNode = useCallback(() => { - if (!doc) return + const addCanvasNote = useCallback(() => { + if (!doc || !canvasRef.current) return - const nodesMap = doc.getMap('nodes') - const newNode = createNode( + const viewport = canvasRef.current.getViewportSnapshot() + const nodesMap = doc.getMap('nodes') + const noteNode = createNode( 'card', { - x: 100 + Math.random() * 400, - y: 100 + Math.random() * 300, - width: 200, - height: 100 + x: viewport.x - 160, + y: viewport.y - 100, + width: 320, + height: 180 }, - { title: 'New Node' } + createCanvasShellNoteProperties() ) - nodesMap.set(newNode.id, newNode) + + nodesMap.set(noteNode.id, noteNode) }, [doc]) + const addLinkedDocumentNode = useCallback( + (document: LinkedDocumentItem) => { + if (!doc || !canvasRef.current) return + + const viewport = canvasRef.current.getViewportSnapshot() + const nodesMap = doc.getMap('nodes') + const linkedNode = createNode( + 'embed', + { + x: viewport.x - (document.type === 'database' ? 220 : 180), + y: viewport.y - 120, + width: document.type === 'database' ? 440 : 360, + height: document.type === 'database' ? 260 : 220 + }, + { + title: document.title, + linkedType: document.type + } + ) + linkedNode.linkedNodeId = document.id + nodesMap.set(linkedNode.id, linkedNode) + }, + [doc] + ) + + 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 + ) + if (!targetNode) return null + + const snapshot = canvasRef.current.getViewportSnapshot() + canvasRef.current.fitToRect(getNodeRect(targetNode), 140) + return snapshot + }, + [doc] + ) + + const restoreViewport = useCallback((snapshot: ViewportSnapshot) => { + canvasRef.current?.setViewportSnapshot(snapshot) + }, []) + + useImperativeHandle( + ref, + () => ({ + addLinkedDocumentNode, + addCanvasNote, + focusLinkedDocument, + restoreViewport + }), + [addCanvasNote, addLinkedDocumentNode, focusLinkedDocument, restoreViewport] + ) + if (loading || !doc) { return ( -
+

Loading canvas...

) @@ -106,59 +233,50 @@ export function CanvasView({ docId }: CanvasViewProps) { if (!canvasReady) { return ( -
+

Preparing canvas...

) } return ( -
- {/* Canvas toolbar */} -
- {/* Title */} - update({ title: e.target.value })} - placeholder="Untitled" - /> +
+
+ {canvas?.title || 'Workspace Canvas'} +
-
- - - - - - - -
- - Pan: Drag background - | - - Zoom: Scroll + {!hasNodes ? ( +
+
+

Canvas-first workspace

+

+ Create from the bottom dock, double-click any linked card to open it, and use the + Canvas action to zoom back out. +

+
+ ) : null} - +
+
+ } + label="Fit canvas to content" + onClick={() => canvasRef.current?.fitToContent(80)} + className="h-10 w-10 rounded-2xl" + /> +
+
+ } + label="Reset canvas view" + onClick={() => canvasRef.current?.resetView()} + className="h-10 w-10 rounded-2xl" + /> +
- {/* Canvas */} -
+
{ - console.log('Double-clicked node:', id) + renderNode={(node) => { + const linkedDocument = node.linkedNodeId + ? documentMap.get(node.linkedNodeId) + : undefined + if (shouldRenderCanvasShellCard(node, linkedDocument)) { + return renderNodeCard(node, linkedDocument) + } + return undefined }} - onBackgroundClick={() => { - console.log('Background clicked') + 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) + } }} />
) -} +}) diff --git a/apps/electron/src/renderer/components/DatabaseView.tsx b/apps/electron/src/renderer/components/DatabaseView.tsx index fcde6e444..50ff187bc 100644 --- a/apps/electron/src/renderer/components/DatabaseView.tsx +++ b/apps/electron/src/renderer/components/DatabaseView.tsx @@ -51,6 +51,7 @@ import { ShareButton } from './ShareButton' interface DatabaseViewProps { docId: string + minimalChrome?: boolean } type ViewMode = 'table' | 'board' @@ -116,7 +117,7 @@ function buildDefaultBoardView(columns: StoredColumn[]): ViewConfig { } } -export function DatabaseView({ docId }: DatabaseViewProps) { +export function DatabaseView({ docId, minimalChrome = false }: DatabaseViewProps) { const { did } = useIdentity() const { @@ -1430,20 +1431,28 @@ export function DatabaseView({ docId }: DatabaseViewProps) { return (
{/* Toolbar */} -
+
{/* Title */} update({ title: e.target.value })} placeholder="Untitled" /> - + {!minimalChrome && } {/* Schema version badge */} - {schemaMetadata && ( + {!minimalChrome && schemaMetadata && ( - + {!minimalChrome && }
{/* View content + Sidebar horizontal layout */} diff --git a/apps/electron/src/renderer/components/DocumentHeader.tsx b/apps/electron/src/renderer/components/DocumentHeader.tsx index d3beb7a90..5412b8561 100644 --- a/apps/electron/src/renderer/components/DocumentHeader.tsx +++ b/apps/electron/src/renderer/components/DocumentHeader.tsx @@ -14,6 +14,8 @@ interface DocumentHeaderProps { onTitleChange: (title: string) => void placeholder?: string children?: React.ReactNode + compact?: boolean + showShareButton?: boolean } export function DocumentHeader({ @@ -22,7 +24,9 @@ export function DocumentHeader({ title, onTitleChange, placeholder = 'Untitled', - children + children, + compact = false, + showShareButton = true }: DocumentHeaderProps) { // Local state for the input to prevent cursor jumping const [localTitle, setLocalTitle] = useState(title) @@ -56,20 +60,28 @@ export function DocumentHeader({ }, [title]) return ( -
+
-
+
{children} - + {showShareButton ? : null}
) diff --git a/apps/electron/src/renderer/components/PageView.tsx b/apps/electron/src/renderer/components/PageView.tsx index 9169e4215..881813f02 100644 --- a/apps/electron/src/renderer/components/PageView.tsx +++ b/apps/electron/src/renderer/components/PageView.tsx @@ -41,6 +41,7 @@ import { PresenceAvatars } from './PresenceAvatars' interface PageViewProps { docId: string + minimalChrome?: boolean } type EditorExtensions = NonNullable['extensions']> @@ -70,7 +71,7 @@ interface NewCommentState { selectionTo: number } -export function PageView({ docId }: PageViewProps) { +export function PageView({ docId, minimalChrome = false }: PageViewProps) { const { did } = useIdentity() const onImageUpload = useImageUpload() const onFileUpload = useFileUpload() @@ -722,9 +723,11 @@ export function PageView({ docId }: PageViewProps) { title={page?.title || ''} onTitleChange={(title) => update({ title })} placeholder="Untitled Page" + compact={minimalChrome} + showShareButton={!minimalChrome} > - - {unresolvedCount > 0 && ( + {!minimalChrome && } + {!minimalChrome && unresolvedCount > 0 && ( )} - + {!minimalChrome && } {/* Editor + Sidebar horizontal layout */}
{/* Editor */} -
+
void + onOpenSettings: () => void + onAddShared: () => void + onToggleDebugPanel: () => void +} + +function ThemeMenuItem({ + active, + label, + icon, + onSelect +}: { + active: boolean + label: string + icon: React.ReactNode + onSelect: () => void +}): React.ReactElement { + return ( + + + {icon} + {label} + {active && } + + + ) +} + +function labelForTheme(theme: Theme): string { + switch (theme) { + case 'light': + return 'Light' + case 'dark': + return 'Dark' + default: + return 'System' + } +} + +export function SystemMenu({ + recentDocuments, + onOpenDocument, + onOpenSettings, + onAddShared, + onToggleDebugPanel +}: SystemMenuProps): React.ReactElement { + const { theme, setTheme } = useTheme() + + return ( + + + + } + align="end" + sideOffset={8} + className="min-w-[240px]" + > + Workspace + + + + Settings + + + + + + Add shared item + + + + + + Toggle debug panel + + + + + Theme + } + onSelect={() => setTheme('light')} + /> + } + onSelect={() => setTheme('dark')} + /> + } + onSelect={() => setTheme('system')} + /> + + + Recent + {recentDocuments.length === 0 ? ( + No recent documents + ) : ( + recentDocuments.map((doc) => ( + onOpenDocument(doc.id)}> + + {doc.type} + {doc.title} + + + )) + )} + + ) +} diff --git a/apps/electron/src/renderer/lib/canvas-shell.test.ts b/apps/electron/src/renderer/lib/canvas-shell.test.ts new file mode 100644 index 000000000..4ae60b0f1 --- /dev/null +++ b/apps/electron/src/renderer/lib/canvas-shell.test.ts @@ -0,0 +1,46 @@ +import { createNode } from '@xnetjs/canvas' +import { describe, expect, it } from 'vitest' +import { + createCanvasShellNoteProperties, + isCanvasShellNote, + shouldRenderCanvasShellCard +} from './canvas-shell' + +describe('canvas-shell', () => { + describe('isCanvasShellNote', () => { + it('returns true for shell-created note cards', () => { + const node = createNode('card', {}, createCanvasShellNoteProperties()) + + expect(isCanvasShellNote(node)).toBe(true) + }) + + it('returns false for generic card nodes', () => { + const node = createNode('card', {}, { title: 'Generic card' }) + + expect(isCanvasShellNote(node)).toBe(false) + }) + + it('returns false for non-card nodes', () => { + const node = createNode('embed', {}, { title: 'Linked page' }) + + expect(isCanvasShellNote(node)).toBe(false) + }) + }) + + describe('shouldRenderCanvasShellCard', () => { + it('renders linked documents with shell cards', () => { + const node = createNode('embed', {}, { title: 'Linked page' }) + node.linkedNodeId = 'page-1' + + expect(shouldRenderCanvasShellCard(node, { id: 'page-1', title: 'Page', type: 'page' })).toBe( + true + ) + }) + + it('does not render generic cards with shell chrome', () => { + const node = createNode('card', {}, { title: 'Generic card' }) + + expect(shouldRenderCanvasShellCard(node)).toBe(false) + }) + }) +}) diff --git a/apps/electron/src/renderer/lib/canvas-shell.ts b/apps/electron/src/renderer/lib/canvas-shell.ts new file mode 100644 index 000000000..240339393 --- /dev/null +++ b/apps/electron/src/renderer/lib/canvas-shell.ts @@ -0,0 +1,29 @@ +import type { CanvasNode } from '@xnetjs/canvas' + +export type LinkedDocType = 'page' | 'database' | 'canvas' + +export type LinkedDocumentItem = { + id: string + title: string + type: LinkedDocType +} + +const SHELL_NOTE_ROLE = 'canvas-note' + +export function createCanvasShellNoteProperties(): Record { + return { + title: 'Untitled Note', + shellRole: SHELL_NOTE_ROLE + } +} + +export function isCanvasShellNote(node: CanvasNode): boolean { + return node.type === 'card' && node.properties.shellRole === SHELL_NOTE_ROLE +} + +export function shouldRenderCanvasShellCard( + node: CanvasNode, + linkedDocument?: LinkedDocumentItem +): boolean { + return Boolean(linkedDocument) || isCanvasShellNote(node) +} diff --git a/docs/explorations/0104_[_]_EXPLORE_DRAMATICALLY_SIMPLIFYING_THE_UX_AROUND_A_CANVAS_FIRST_PRIMARY_APP_INSPIRED_BY_AFFINE_MINIMIZING_BUTTONS_AND_CHROME_WITH_ZOOM_IN_DOCUMENTS_AND_DATABASES.md b/docs/explorations/0104_[_]_EXPLORE_DRAMATICALLY_SIMPLIFYING_THE_UX_AROUND_A_CANVAS_FIRST_PRIMARY_APP_INSPIRED_BY_AFFINE_MINIMIZING_BUTTONS_AND_CHROME_WITH_ZOOM_IN_DOCUMENTS_AND_DATABASES.md new file mode 100644 index 000000000..56ea29c72 --- /dev/null +++ b/docs/explorations/0104_[_]_EXPLORE_DRAMATICALLY_SIMPLIFYING_THE_UX_AROUND_A_CANVAS_FIRST_PRIMARY_APP_INSPIRED_BY_AFFINE_MINIMIZING_BUTTONS_AND_CHROME_WITH_ZOOM_IN_DOCUMENTS_AND_DATABASES.md @@ -0,0 +1,657 @@ +# 0104 - Canvas-First Minimal UX for the Primary App + +> **Status:** Exploration +> **Date:** 2026-03-05 +> **Author:** Codex +> **Tags:** ux, canvas, affine, shell, navigation, electron, editor, database + +## Problem Statement โœณ๏ธ + +xNet's primary Electron app currently behaves like a conventional document app: + +- a permanent left sidebar for navigation and creation +- a top title bar plus per-view top toolbars +- separate page, database, and canvas surfaces + +That is functional, but it fights the product direction the user described: + +- canvas should feel like the default home +- documents and databases should emerge from the canvas, not from a file browser +- most controls should disappear until they are needed +- creating and editing should feel spatial, direct, and calm + +The goal of this exploration is to define a **dramatically simpler, AFFiNE-inspired, canvas-first shell** that preserves xNet's architecture and implementation momentum. + +## Executive Summary ๐ŸŽฏ + +The most pragmatic path is **not** a full AFFiNE-style engine rewrite. It is a shell simplification project built on top of the current xNet packages: + +- Make a single canvas the default landing surface in [`apps/electron/src/renderer/App.tsx`](../../apps/electron/src/renderer/App.tsx). +- Replace the permanent left sidebar with: + - one **top-right system/profile menu** + - one **bottom-center creation/action dock** + - contextual controls that appear only when editing or selecting +- Treat page and database nodes as **canvas-linked objects** that can be opened with a zoom transition into focused full-screen mode. +- Keep advanced actions in the command palette, native menu bar, or contextual popovers instead of persistent toolbars. +- Reuse the existing canvas package's spatial model and linked/embed node direction instead of inventing a new document model. + +Recommendation: + +1. Ship a **hybrid canvas-first shell** first. +2. Defer full embedded live doc/database editing on the canvas until shell simplification is working. +3. Keep exactly two persistent UI anchors: + - top-right profile/system menu + - bottom-center action dock + +This is the cleanest route to a much calmer UX without discarding the current xNet stack. + +## Current State in the Repository ๐Ÿ”Ž + +### Observed app shell + +Today the Electron renderer composes a classic sidebar-driven application in [`apps/electron/src/renderer/App.tsx`](../../apps/electron/src/renderer/App.tsx): + +- top title bar with app name and theme toggle +- left [`Sidebar`](../../apps/electron/src/renderer/components/Sidebar.tsx) +- content area that switches between `PageView`, `DatabaseView`, `CanvasView`, and `SettingsView` +- empty state when nothing is selected + +That means the current primary interaction is: + +`pick from list -> open object -> use object-specific toolbar` + +instead of: + +`enter canvas -> place object -> zoom into object -> zoom back out` + +### Observed persistent chrome + +Current persistent chrome is spread across multiple layers: + +- [`Sidebar`](../../apps/electron/src/renderer/components/Sidebar.tsx) + - creation dropdown + - grouped document lists + - delete buttons per item + - settings entry +- [`apps/electron/src/renderer/components/PageView.tsx`](../../apps/electron/src/renderer/components/PageView.tsx) + - large document header + - presence, sync, share, comment count + - editor layout with optional comments sidebar +- [`apps/electron/src/renderer/components/DatabaseView.tsx`](../../apps/electron/src/renderer/components/DatabaseView.tsx) + - title input + - schema badge + - clone action + - comments action + - view switcher + - add row button + - share button +- [`apps/electron/src/renderer/components/CanvasView.tsx`](../../apps/electron/src/renderer/components/CanvasView.tsx) + - title input + - add node + - center button + - presence + - instructional hint text + - share button + +### Important architectural assets already present + +The repo already contains the right foundations for a simplified shell: + +- `@xnetjs/canvas` supports an infinite canvas with pan, zoom, selection, fit-to-content, and linked node metadata. +- [`packages/canvas/src/types.ts`](../../packages/canvas/src/types.ts) includes `linkedNodeId` on canvas nodes. +- [`packages/canvas/src/nodes/embed-node.tsx`](../../packages/canvas/src/nodes/embed-node.tsx) already points toward page/database embedding. +- [`packages/canvas/src/components/NavigationTools.tsx`](../../packages/canvas/src/components/NavigationTools.tsx) already models compact bottom-corner navigation controls. +- The rich text editor already prefers a contextual floating toolbar over always-visible formatting chrome. + +### Current mismatch + +The shell is still list-first, while the primitives are already moving toward space-first interaction. + +```mermaid +flowchart LR + A["Current shell
App.tsx"] --> B["Left Sidebar"] + A --> C["Page View"] + A --> D["Database View"] + A --> E["Canvas View"] + + B --> F["Create from menu"] + B --> G["Pick from grouped lists"] + + E --> H["Infinite canvas primitives"] + H --> I["Pan / zoom / linkedNodeId / embeds"] + + style B fill:#ffe4e6 + style H fill:#dcfce7 +``` + +## External Research ๐ŸŒ + +### AFFiNE patterns worth borrowing + +From AFFiNE's official materials: + +- AFFiNE explicitly positions **edgeless** as a primary product surface, not an auxiliary whiteboard. + Source: [AFFiNE July 2024 update](https://affine.pro/blog/whats-new-affine-2024-07) +- AFFiNE added **Center Peek** so linked documents can be previewed and edited without a full context switch. + Source: [AFFiNE July 2024 update](https://affine.pro/blog/whats-new-affine-2024-07) +- AFFiNE added **synced docs** so multiple pages can live on one whiteboard and stay live-linked. + Source: [AFFiNE July 2024 update](https://affine.pro/blog/whats-new-affine-2024-07) +- AFFiNE later improved **linked doc and database integration**, including creating docs from databases and syncing properties into document info. + Source: [AFFiNE November 2024 update](https://affine.pro/blog/whats-new-affine-nov-update) +- AFFiNE also shipped a **floating sidebar** and separated doc mode from current view mode. + Source: [AFFiNE November 2024 update](https://affine.pro/blog/whats-new-affine-nov-update) + +### BlockSuite signals + +BlockSuite's official site describes itself as: + +- a **headless editor framework** +- shipping **interoperable components** +- being **collaborative at core** + +That matters because the best AFFiNE lesson for xNet is not "copy every screen." It is "make the shell more headless and let different editing surfaces share a calmer interaction model." +Source: [BlockSuite](https://blocksuite.io/) + +### Platform guidance relevant to "fewer buttons" + +Apple's HIG materials still align with the user's request: + +- not every menu item needs an icon +- long menus should be shortened or grouped +- toolbars should hold edge-placed controls, not become the entire product + +That supports moving secondary actions out of persistent view toolbars and into menus/command surfaces. +Sources: + +- [Menus | Apple Developer Documentation](https://developer.apple.com/design/human-interface-guidelines/menus) +- [UIToolbar | Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uitoolbar) + +### Inference from the research + +Observed fact: + +- AFFiNE succeeds by keeping the primary surface spatial and using linked content, peeking, and mode shifts instead of a dense persistent control layer. + +Inference: + +- xNet does **not** need AFFiNE's full block architecture to capture most of the perceived UX gain. It mainly needs a calmer app shell, spatial entry point, and better transitions between overview and focus. + +## Key Findings ๐Ÿง  + +### 1. The sidebar is the biggest source of "old app" feeling + +The permanent sidebar in [`Sidebar.tsx`](../../apps/electron/src/renderer/components/Sidebar.tsx) does too much: + +- create +- browse +- manage +- delete +- settings + +That makes the app feel like a file manager wrapped around canvases, rather than a canvas-native workspace. + +### 2. xNet already has the right primitive for minimal navigation: zoom + +The canvas stack already knows how to: + +- pan +- zoom +- fit to content +- treat objects spatially + +So the most coherent navigation system is: + +- **zoom out for workspace context** +- **zoom in for object focus** + +not: + +- sidebar hierarchy +- nested panes +- repeated top bars + +### 3. Page UX is already halfway minimal + +`PageView` is closer to the target than the other surfaces: + +- the rich text editor uses contextual formatting +- comments already use popovers and an optional sidebar + +The remaining issue is the large header + surrounding app chrome. + +### 4. Database UX needs heavier compression, not elimination + +Database editing has more operational controls than documents: + +- view type +- add row +- schema/version +- comments +- share + +A zero-button database UI is unrealistic. The right move is to **compress controls into a single dock plus contextual popovers**, not pretend they do not exist. + +### 5. Canvas-linked objects are already technically plausible + +Because canvas nodes already have `linkedNodeId`, xNet can represent: + +- page cards on canvas +- database cards on canvas +- future live previews and embeds + +without changing the underlying Page/Database/Canvas schemas. + +### 6. The app should stop treating "nothing selected" as a blank state + +The current welcome state in `App.tsx` is an explicit empty screen. The user wants the opposite: + +- the app should open to a living spatial surface +- creation should happen directly there + +## Options and Tradeoffs โš–๏ธ + +### Option 1. Keep current shell, just reduce visual weight + +What it means: + +- slimmer sidebar +- smaller headers +- icon-only controls + +Pros: + +- lowest implementation cost +- minimal routing/state churn + +Cons: + +- does not fundamentally change the interaction model +- still feels list-first +- fails the user's canvas-first intent + +### Option 2. Hybrid canvas-first shell + +What it means: + +- canvas is the default landing surface +- sidebar is removed from the persistent shell +- page/database objects live as nodes/cards on canvas +- opening an object triggers a zoom/focus transition into full-screen editing +- top-right menu and bottom action dock remain persistent + +Pros: + +- delivers most of the desired feel +- fits current architecture +- preserves separate page/database/canvas implementations +- creates a clean path to richer embedding later + +Cons: + +- requires a new shell state model +- needs strong transition design to avoid feeling like hard route swaps + +### Option 3. Full unified AFFiNE-style multimodal document model + +What it means: + +- pages, databases, and whiteboard become variants of one deeper mode system +- full live editing of docs/databases inside canvas becomes first-class + +Pros: + +- highest long-term elegance +- closest to AFFiNE behavior + +Cons: + +- conflicts with the prior 0102 finding against heavy BlockSuite/AFFiNE-style integration +- substantially higher migration risk +- too much architecture churn for a shell simplification project + +## Recommendation โœ… + +Choose **Option 2: Hybrid canvas-first shell**. + +### The target shell + +Keep only two permanent anchors: + +1. **Top-right profile/system menu** + - profile + - settings + - workspace/share/session controls + - theme + - help/debug +2. **Bottom-center action dock** + - create page + - create database + - create canvas item/frame + - search / command palette + - recent items + +Everything else becomes contextual: + +- text formatting only when text is selected +- database controls only when database is focused +- canvas navigation in a bottom corner +- comments hidden until requested +- share hidden in the system menu or a focused object menu + +### Interaction model + +```mermaid +stateDiagram-v2 + [*] --> CanvasHome + CanvasHome --> CreateNode: tap dock action + CanvasHome --> FocusObject: double-click / Enter / open + FocusObject --> PageFocus: linked page + FocusObject --> DatabaseFocus: linked database + FocusObject --> CanvasHome: Escape / zoom out / breadcrumb tap + PageFocus --> CanvasHome: zoom out + DatabaseFocus --> CanvasHome: zoom out +``` + +### Recommended spatial behavior + +```mermaid +sequenceDiagram + participant U as User + participant S as Shell + participant C as Canvas + participant O as Linked Object View + + U->>S: Launch app + S->>C: Open home canvas + U->>C: Tap bottom dock -> New Page + C->>C: Create linked page node at camera center + U->>C: Open node + C->>S: Animate zoom into node bounds + S->>O: Mount focused PageView + U->>O: Edit content + U->>S: Escape / Back / pinch out + S->>C: Return to prior camera state +``` + +### Why this is the right boundary + +It captures the UX win the user wants: + +- calm first impression +- spatial organization +- almost no permanent buttons +- documents and databases feel like things in space + +while respecting the codebase reality: + +- `PageView` can stay a focused full-screen editor +- `DatabaseView` can stay a focused full-screen data surface +- `CanvasView` becomes the app home and navigation map + +## Proposed UX Architecture ๐Ÿ—๏ธ + +### Shell decomposition + +```mermaid +mindmap + root((Minimal xNet Shell)) + Persistent + Top-right system menu + Bottom-center action dock + Canvas nav in bottom corner + Contextual + Editor bubble toolbar + Database view popovers + Comment panel on demand + Presence when collaborating + Hidden by default + Sidebar lists + Share buttons + View mode toggles + Schema utilities + Debug affordances +``` + +### Concrete UI rules + +#### 1. Global shell rules + +- No permanent left sidebar. +- No permanent title bar text besides platform drag affordance if needed. +- Theme toggle moves into the profile/system menu. +- Native desktop menu bar handles standard file/edit/view actions. + +#### 2. Canvas home rules + +- App opens to the user's home canvas by default. +- Empty state becomes a canvas with a subtle onboarding prompt near the bottom dock. +- Creating a page/database immediately places a node near the current viewport center. +- The canvas title is de-emphasized or hidden unless explicitly renamed. + +#### 3. Focused page rules + +- Remove the large persistent document header. +- Show title inline, near the top edge, only on focus/hover or while editing. +- Keep the editor's contextual toolbar and slash menu as the primary affordances. +- Move share/comments/sync into a compact corner menu or inspector chip. + +#### 4. Focused database rules + +- Replace the wide database header with one compact focus bar. +- Put view switching, add row, filters, and schema actions into a single bottom dock or compact top chip group. +- Keep advanced schema/version actions in a secondary popover. + +#### 5. Comments and collaboration rules + +- Presence avatars should collapse into a small stack unless hovered. +- Comments should open from badges or inline indicators, not a permanently visible rail. +- Share should not occupy top-level space unless the object is explicitly in collaboration mode. + +## Migration Strategy ๐Ÿšš + +### Phase 1. Shell simplification without live embed editing + +Change only the shell: + +- replace sidebar-first app state with canvas-home state +- open page/database views from canvas-linked nodes +- add zoom transitions + +This yields the big UX win early. + +### Phase 2. Canvas-linked object intelligence + +Improve canvas nodes: + +- page cards show title + excerpt +- database cards show title + mini view metadata +- cards support peek, rename, duplicate, and open + +### Phase 3. Selective inline preview + +Add lightweight previews: + +- page preview card +- database preview card +- possibly read-only embed preview before full live editing + +### Phase 4. True multimodal cross-surface editing + +Only after the shell works: + +- richer inline editing +- deeper doc/database embedding +- possible shared transition model with peeking and split view + +## Risks and Unknowns ๐Ÿšจ + +- **Discoverability risk:** removing too much chrome can make first-run behavior feel opaque. +- **Database density risk:** structured workflows still need fast access to row/view/schema commands. +- **Electron window chrome risk:** removing visible title structure must still preserve drag regions and native-feeling window controls. +- **Navigation risk:** route changes disguised as zooms can feel fake if camera restoration is not solid. +- **Collaboration risk:** comments, presence, and share need a predictable secondary home once the toolbar clutter is removed. + +## Implementation Checklist ๐Ÿ› ๏ธ + +- [x] Add a shell state model that distinguishes `canvas-home`, `page-focus`, `database-focus`, and `settings`. +- [x] Make a default home canvas and open it on app launch instead of rendering the current empty state. +- [x] Remove the persistent left sidebar from the main renderer shell. +- [x] Introduce a bottom-center action dock with create/search/recent actions. +- [x] Introduce a top-right profile/system menu containing settings, theme, share/session, and debug actions. +- [x] Represent pages and databases as linked canvas nodes using existing `linkedNodeId` direction. +- [x] Replace hard view switching with zoom-in/zoom-out shell transitions. +- [x] Compress `PageView` header chrome into lightweight contextual controls. +- [ ] Compress `DatabaseView` controls into a compact dock plus popovers. +- [ ] Reuse or adapt `NavigationTools` for persistent bottom-corner canvas navigation. +- [x] Move advanced/rare actions into command palette or secondary menus. +- [x] Add onboarding copy that teaches exactly three interactions: create, open, zoom out. + +## Validation Checklist ๐Ÿงช + +- [x] Verify the app always lands on a canvas, including first-run and empty-workspace flows. +- [ ] Verify a new page/database appears at the current viewport center. +- [ ] Verify opening a linked object preserves spatial context and restores it on exit. +- [ ] Verify keyboard-only flows still work: command palette, open, rename, zoom out, undo/redo. +- [ ] Verify comment, share, and presence affordances remain discoverable without persistent sidebars. +- [ ] Verify database-focused workflows still expose row/view/schema operations in two interactions or fewer. +- [ ] Verify the shell remains usable on small laptop widths without bringing back a sidebar. +- [ ] Verify reduced-motion mode falls back from zoom animation to clean state swaps. +- [ ] Verify Playwright/Electron manual checks cover canvas home, page focus, database focus, and collaboration affordances. + +Validation notes: + +- Automated validation completed on March 6, 2026, with `pnpm typecheck`, `pnpm test`, `pnpm --filter xnet-desktop build`, and `pnpm --filter @xnetjs/canvas build`. +- Manual Electron validation on March 6, 2026, confirmed the first-run shell lands on the workspace canvas with a fresh `XNET_PROFILE=codex-pr5` and `XNET_TEST_BYPASS=true`; screenshot saved to `tmp/playwright/pr5-canvas-home.png`. +- Manual Electron validation is still required for the UX-specific items above before squash-and-merge. + +## Example Code ๐Ÿ’ก + +The shell can be simplified without rewriting the underlying views by introducing a focused state machine like this: + +```ts +/** + * Minimal shell state for a canvas-first workspace. + */ +export type NonSettingsShellMode = + | { kind: 'canvas-home'; canvasId: string } + | { kind: 'page-focus'; canvasId: string; pageId: string; returnViewport: ViewportSnapshot } + | { + kind: 'database-focus' + canvasId: string + databaseId: string + returnViewport: ViewportSnapshot + } + +export type ShellMode = + | NonSettingsShellMode + | { kind: 'settings'; canvasId: string; previous: NonSettingsShellMode } + +export type ViewportSnapshot = { + x: number + y: number + zoom: number +} + +export type CreateIntent = 'page' | 'database' | 'canvas-card' + +export function createLinkedNodeAtViewport( + intent: CreateIntent, + viewport: ViewportSnapshot +): { + type: 'embed' + linkedSchema: 'page' | 'database' | 'canvas' + position: { x: number; y: number; width: number; height: number } +} { + const basePosition = { + x: viewport.x - 180, + y: viewport.y - 120, + width: 360, + height: intent === 'database' ? 240 : 180 + } + + return { + type: 'embed', + linkedSchema: intent === 'canvas-card' ? 'canvas' : intent, + position: basePosition + } +} + +export function reduceShellAction( + state: ShellMode, + action: + | { type: 'open-page'; pageId: string; returnViewport: ViewportSnapshot } + | { type: 'open-database'; databaseId: string; returnViewport: ViewportSnapshot } + | { type: 'zoom-out' } + | { type: 'open-settings' } + | { type: 'close-settings' } +): ShellMode { + switch (action.type) { + case 'open-page': + if (state.kind !== 'canvas-home') return state + return { + kind: 'page-focus', + canvasId: state.canvasId, + pageId: action.pageId, + returnViewport: action.returnViewport + } + + case 'open-database': + if (state.kind !== 'canvas-home') return state + return { + kind: 'database-focus', + canvasId: state.canvasId, + databaseId: action.databaseId, + returnViewport: action.returnViewport + } + + case 'zoom-out': + if (state.kind === 'canvas-home') return state + return { kind: 'canvas-home', canvasId: state.canvasId } + + case 'open-settings': + if (state.kind === 'settings') return state + return { kind: 'settings', canvasId: state.canvasId, previous: state } + + case 'close-settings': + return state.kind === 'settings' ? state.previous : state + } +} +``` + +This keeps the implementation aligned with the existing architecture: + +- focused views still mount `PageView` and `DatabaseView` +- canvas remains the spatial source of truth for navigation context +- the shell owns transitions and visibility of global chrome + +## Next Actions ๐Ÿ“Œ + +1. Prototype the hybrid shell in Electron only. +2. Do not start with live embedded editing inside the canvas. +3. Remove the sidebar before polishing the focused views, because that is the largest UX shift. +4. Use motion sparingly: the zoom transition must preserve orientation, not become decoration. +5. Once the shell lands, run a second pass on database control compression. + +## References ๐Ÿ”— + +### Repo references + +- [Prior AFFiNE exploration](../../docs/explorations/0102_[_]_AFFINE_BLOCKSUITE_INTEGRATION_FEASIBILITY.md) +- [Electron app shell](../../apps/electron/src/renderer/App.tsx) +- [Sidebar](../../apps/electron/src/renderer/components/Sidebar.tsx) +- [Canvas view](../../apps/electron/src/renderer/components/CanvasView.tsx) +- [Page view](../../apps/electron/src/renderer/components/PageView.tsx) +- [Database view](../../apps/electron/src/renderer/components/DatabaseView.tsx) +- [Canvas types](../../packages/canvas/src/types.ts) +- [Canvas embed node](../../packages/canvas/src/nodes/embed-node.tsx) +- [Canvas navigation tools](../../packages/canvas/src/components/NavigationTools.tsx) + +### Web references + +- [AFFiNE Docs: Get Started](https://docs.affine.pro/) +- [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 June 2025 update](https://affine.pro/blog/whats-new-june-update) +- [BlockSuite](https://blocksuite.io/) +- [Apple HIG: Menus](https://developer.apple.com/design/human-interface-guidelines/menus) +- [Apple Developer: UIToolbar](https://developer.apple.com/documentation/uikit/uitoolbar) diff --git a/package.json b/package.json index 777f06dfa..6680c2f4b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,10 @@ "package.json": [] }, "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "electron" + ], "patchedDependencies": { "y-webrtc@10.3.0": "patches/y-webrtc@10.3.0.patch" } diff --git a/packages/canvas/src/hooks/useCanvas.ts b/packages/canvas/src/hooks/useCanvas.ts index 3de95987d..187e2d854 100644 --- a/packages/canvas/src/hooks/useCanvas.ts +++ b/packages/canvas/src/hooks/useCanvas.ts @@ -60,7 +60,10 @@ export interface UseCanvasReturn { pan: (deltaX: number, deltaY: number) => void zoomAt: (x: number, y: number, factor: number) => void fitToContent: (padding?: number) => void + fitToRect: (rect: Rect, padding?: number) => void resetView: () => void + getViewportSnapshot: () => { x: number; y: number; zoom: number } + setViewportSnapshot: (snapshot: { x: number; y: number; zoom: number }) => void // Layout autoLayout: (config?: LayoutConfig) => Promise @@ -277,11 +280,40 @@ export function useCanvas(options: UseCanvasOptions): UseCanvasReturn { [store] ) + const fitToRect = useCallback((rect: Rect, padding = 50) => { + viewportRef.current.fitToRect(rect, padding) + setViewportState(viewportRef.current.clone()) + }, []) + const resetView = useCallback(() => { viewportRef.current.reset() setViewportState(viewportRef.current.clone()) }, []) + const getViewportSnapshot = useCallback(() => { + const snapshot = viewportRef.current.clone() + return { + x: snapshot.x, + y: snapshot.y, + zoom: snapshot.zoom + } + }, []) + + const setViewportSnapshot = useCallback( + (snapshot: { x: number; y: number; zoom: number }) => { + const x = Number.isFinite(snapshot.x) ? snapshot.x : 0 + const y = Number.isFinite(snapshot.y) ? snapshot.y : 0 + const requestedZoom = Number.isFinite(snapshot.zoom) ? snapshot.zoom : 1 + const zoom = Math.min(fullConfig.maxZoom, Math.max(fullConfig.minZoom, requestedZoom)) + + viewportRef.current.x = x + viewportRef.current.y = y + viewportRef.current.zoom = zoom + setViewportState(viewportRef.current.clone()) + }, + [fullConfig.maxZoom, fullConfig.minZoom] + ) + // ============================================================================ // Layout // ============================================================================ @@ -384,7 +416,10 @@ export function useCanvas(options: UseCanvasOptions): UseCanvasReturn { pan, zoomAt, fitToContent, + fitToRect, resetView, + getViewportSnapshot, + setViewportSnapshot, // Layout autoLayout, diff --git a/packages/canvas/src/renderer/Canvas.tsx b/packages/canvas/src/renderer/Canvas.tsx index f6ee49df7..63273a9b7 100644 --- a/packages/canvas/src/renderer/Canvas.tsx +++ b/packages/canvas/src/renderer/Canvas.tsx @@ -4,7 +4,7 @@ * Main infinite canvas component with pan, zoom, and node rendering. */ -import type { CanvasConfig, CanvasNode, GridType, Point } from '../types' +import type { CanvasConfig, CanvasNode, GridType, Point, Rect } from '../types' import React, { useRef, useCallback, @@ -48,8 +48,14 @@ export interface CanvasRemoteUser { export interface CanvasHandle { /** Fit the viewport to show all content */ fitToContent: (padding?: number) => void + /** Fit the viewport to a specific rectangle */ + fitToRect: (rect: Rect, padding?: number) => void /** Reset viewport to origin at zoom 1 */ resetView: () => void + /** Get the current viewport state */ + getViewportSnapshot: () => { x: number; y: number; zoom: number } + /** Restore a previous viewport state */ + setViewportSnapshot: (snapshot: { x: number; y: number; zoom: number }) => void } export interface CanvasProps { @@ -203,7 +209,11 @@ export const Canvas = forwardRef(function Canvas( ref, () => ({ fitToContent: (padding?: number) => canvas.fitToContent(padding), - resetView: () => canvas.resetView() + 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] )