diff --git a/src/components/PrivateWorkspaceManager.tsx b/src/components/PrivateWorkspaceManager.tsx new file mode 100644 index 0000000..db42923 --- /dev/null +++ b/src/components/PrivateWorkspaceManager.tsx @@ -0,0 +1,22 @@ +import { useEditor } from 'tldraw' +import { usePrivateWorkspace } from '../hooks/usePrivateWorkspace' + +/** + * Component that manages the Private Workspace zone for Google Export data. + * Listens for 'add-google-items-to-canvas' events and creates items in the workspace. + * + * Must be rendered inside a Tldraw context. + */ +export function PrivateWorkspaceManager() { + const editor = useEditor() + + // This hook handles: + // - Creating/showing the private workspace zone + // - Listening for 'add-google-items-to-canvas' events + // - Adding items to the workspace when triggered + usePrivateWorkspace({ editor }) + + // This component doesn't render anything visible + // It just manages the workspace logic + return null +} diff --git a/src/hooks/usePrivateWorkspace.ts b/src/hooks/usePrivateWorkspace.ts new file mode 100644 index 0000000..6d15c0c --- /dev/null +++ b/src/hooks/usePrivateWorkspace.ts @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useState } from 'react' +import { Editor, createShapeId, TLShapeId } from 'tldraw' +import { IPrivateWorkspaceShape, findPrivateWorkspace } from '../shapes/PrivateWorkspaceShapeUtil' +import type { ShareableItem } from '../lib/google' + +const WORKSPACE_STORAGE_KEY = 'private-workspace-visible' + +export interface UsePrivateWorkspaceOptions { + editor: Editor | null +} + +export function usePrivateWorkspace({ editor }: UsePrivateWorkspaceOptions) { + const [workspaceId, setWorkspaceId] = useState(null) + const [isVisible, setIsVisible] = useState(() => { + try { + return localStorage.getItem(WORKSPACE_STORAGE_KEY) === 'true' + } catch { + return false + } + }) + + // Find existing workspace on mount or when editor changes + useEffect(() => { + if (!editor) return + + const existing = findPrivateWorkspace(editor) + if (existing) { + setWorkspaceId(existing.id) + setIsVisible(true) + } + }, [editor]) + + // Create or show the private workspace + const showWorkspace = useCallback(() => { + if (!editor) return null + + // Check if workspace already exists + const existing = findPrivateWorkspace(editor) + if (existing) { + setWorkspaceId(existing.id) + setIsVisible(true) + localStorage.setItem(WORKSPACE_STORAGE_KEY, 'true') + return existing.id + } + + // Get viewport center for placement + const viewport = editor.getViewportScreenBounds() + const center = editor.screenToPage({ + x: viewport.x + viewport.width * 0.15, // Position on left side + y: viewport.y + viewport.height * 0.2, + }) + + // Create new workspace + const id = createShapeId() + editor.createShape({ + id, + type: 'PrivateWorkspace', + x: center.x, + y: center.y, + props: { + w: 350, + h: 450, + pinnedToView: false, + isCollapsed: false, + }, + }) + + setWorkspaceId(id) + setIsVisible(true) + localStorage.setItem(WORKSPACE_STORAGE_KEY, 'true') + return id + }, [editor]) + + // Hide/delete the workspace + const hideWorkspace = useCallback(() => { + if (!editor || !workspaceId) return + + const shape = editor.getShape(workspaceId) + if (shape) { + editor.deleteShape(workspaceId) + } + + setWorkspaceId(null) + setIsVisible(false) + localStorage.setItem(WORKSPACE_STORAGE_KEY, 'false') + }, [editor, workspaceId]) + + // Toggle workspace visibility + const toggleWorkspace = useCallback(() => { + if (isVisible && workspaceId) { + hideWorkspace() + } else { + showWorkspace() + } + }, [isVisible, workspaceId, showWorkspace, hideWorkspace]) + + // Add items to the workspace (from GoogleExportBrowser) + const addItemsToWorkspace = useCallback(( + items: ShareableItem[], + _position?: { x: number; y: number } + ) => { + if (!editor) return + + // Ensure workspace exists + let wsId = workspaceId + if (!wsId) { + wsId = showWorkspace() + } + if (!wsId) return + + const workspace = editor.getShape(wsId) as IPrivateWorkspaceShape | undefined + if (!workspace) return + + // Calculate starting position inside workspace + const startX = workspace.x + 20 + const startY = workspace.y + 60 // Below header + const itemSpacing = 100 + + // Create placeholder shapes for each item + // In Phase 4, these will be proper PrivateItemShape with visibility tracking + items.forEach((item, index) => { + const itemId = createShapeId() + const col = index % 3 + const row = Math.floor(index / 3) + + // For now, create text shapes as placeholders + // Phase 4 will replace with proper GoogleItemShape + editor.createShape({ + id: itemId, + type: 'text', + x: startX + col * itemSpacing, + y: startY + row * itemSpacing, + props: { + text: `🔒 ${item.title}`, + size: 's', + font: 'sans', + color: 'violet', + autoSize: true, + }, + }) + }) + + // Focus on workspace + editor.select(wsId) + editor.zoomToSelection({ animation: { duration: 300 } }) + }, [editor, workspaceId, showWorkspace]) + + // Listen for add-google-items-to-canvas events + useEffect(() => { + const handleAddItems = (event: CustomEvent<{ + items: ShareableItem[] + position: { x: number; y: number } + }>) => { + const { items, position } = event.detail + addItemsToWorkspace(items, position) + } + + window.addEventListener('add-google-items-to-canvas', handleAddItems as EventListener) + return () => { + window.removeEventListener('add-google-items-to-canvas', handleAddItems as EventListener) + } + }, [addItemsToWorkspace]) + + return { + workspaceId, + isVisible, + showWorkspace, + hideWorkspace, + toggleWorkspace, + addItemsToWorkspace, + } +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index cff42b6..b6b4273 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -49,6 +49,10 @@ import { MultmuxTool } from "@/tools/MultmuxTool" import { MultmuxShape } from "@/shapes/MultmuxShapeUtil" // MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil" +// Private Workspace for Google Export data sovereignty +import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil" +import { PrivateWorkspaceTool } from "@/tools/PrivateWorkspaceTool" +import { PrivateWorkspaceManager } from "@/components/PrivateWorkspaceManager" import { lockElement, unlockElement, @@ -141,6 +145,7 @@ const customShapeUtils = [ VideoGenShape, MultmuxShape, MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility + PrivateWorkspaceShape, // Private zone for Google Export data sovereignty ] const customTools = [ ChatBoxTool, @@ -158,6 +163,7 @@ const customTools = [ ImageGenTool, VideoGenTool, MultmuxTool, + PrivateWorkspaceTool, ] // Debug: Log tool and shape registration info @@ -1100,6 +1106,7 @@ export function Board() { }} > + diff --git a/src/shapes/PrivateWorkspaceShapeUtil.tsx b/src/shapes/PrivateWorkspaceShapeUtil.tsx new file mode 100644 index 0000000..0ecaebb --- /dev/null +++ b/src/shapes/PrivateWorkspaceShapeUtil.tsx @@ -0,0 +1,370 @@ +import { useState, useEffect } from "react" +import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, TLShapeId } from "tldraw" +import { usePinnedToView } from "../hooks/usePinnedToView" + +export type IPrivateWorkspaceShape = TLBaseShape< + "PrivateWorkspace", + { + w: number + h: number + pinnedToView: boolean + isCollapsed: boolean + } +> + +// Storage key for persisting workspace position/size +const STORAGE_KEY = 'private-workspace-state' + +interface WorkspaceState { + x: number + y: number + w: number + h: number +} + +function saveWorkspaceState(state: WorkspaceState) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } catch (e) { + console.warn('Failed to save workspace state:', e) + } +} + +function loadWorkspaceState(): WorkspaceState | null { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + return JSON.parse(stored) + } + } catch (e) { + console.warn('Failed to load workspace state:', e) + } + return null +} + +export class PrivateWorkspaceShape extends BaseBoxShapeUtil { + static override type = "PrivateWorkspace" as const + + // Privacy zone color: Indigo + static readonly PRIMARY_COLOR = "#6366f1" + + getDefaultProps(): IPrivateWorkspaceShape["props"] { + const saved = loadWorkspaceState() + return { + w: saved?.w ?? 400, + h: saved?.h ?? 500, + pinnedToView: false, + isCollapsed: false, + } + } + + override canResize() { + return true + } + + override canBind() { + return false + } + + indicator(shape: IPrivateWorkspaceShape) { + return ( + + ) + } + + component(shape: IPrivateWorkspaceShape) { + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + // Use the pinning hook to keep the shape fixed to viewport when pinned + usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) + + // Save position/size when shape changes + useEffect(() => { + const shapeData = this.editor.getShape(shape.id) + if (shapeData) { + saveWorkspaceState({ + x: shapeData.x, + y: shapeData.y, + w: shape.props.w, + h: shape.props.h, + }) + } + }, [shape.props.w, shape.props.h, shape.id]) + + const handleClose = () => { + this.editor.deleteShape(shape.id) + } + + const handlePinToggle = () => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + pinnedToView: !shape.props.pinnedToView, + }, + }) + } + + const handleCollapse = () => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + isCollapsed: !shape.props.isCollapsed, + }, + }) + } + + // Detect dark mode + const isDarkMode = typeof document !== 'undefined' && + document.documentElement.classList.contains('dark') + + const colors = isDarkMode ? { + bg: 'rgba(99, 102, 241, 0.12)', + headerBg: 'rgba(99, 102, 241, 0.25)', + border: 'rgba(99, 102, 241, 0.4)', + text: '#e4e4e7', + textMuted: '#a1a1aa', + btnHover: 'rgba(255, 255, 255, 0.1)', + } : { + bg: 'rgba(99, 102, 241, 0.06)', + headerBg: 'rgba(99, 102, 241, 0.15)', + border: 'rgba(99, 102, 241, 0.3)', + text: '#3730a3', + textMuted: '#6366f1', + btnHover: 'rgba(99, 102, 241, 0.1)', + } + + const collapsedHeight = 44 + + return ( + +
+ {/* Header */} +
{ + // Allow dragging from header + e.stopPropagation() + }} + > +
+ 🔒 + + Private Workspace + +
+ +
+ {/* Pin button */} + + + {/* Collapse button */} + + + {/* Close button */} + +
+
+ + {/* Content area */} + {!shape.props.isCollapsed && ( +
+
+ 🔐 +
+
+

+ Drop items here to keep them private +

+

+ Encrypted in your browser • Only you can see these +

+
+
+ )} + + {/* Footer hint */} + {!shape.props.isCollapsed && ( +
+ Drag items outside to share with collaborators +
+ )} +
+
+ ) + } +} + +// Helper function to check if a shape is inside the private workspace +export function isShapeInPrivateWorkspace( + editor: any, + shapeId: TLShapeId, + workspaceId: TLShapeId +): boolean { + const shape = editor.getShape(shapeId) + const workspace = editor.getShape(workspaceId) + + if (!shape || !workspace || workspace.type !== 'PrivateWorkspace') { + return false + } + + const shapeBounds = editor.getShapeGeometry(shape).bounds + const workspaceBounds = editor.getShapeGeometry(workspace).bounds + + // Check if shape center is within workspace bounds + const shapeCenterX = shape.x + shapeBounds.width / 2 + const shapeCenterY = shape.y + shapeBounds.height / 2 + + return ( + shapeCenterX >= workspace.x && + shapeCenterX <= workspace.x + workspaceBounds.width && + shapeCenterY >= workspace.y && + shapeCenterY <= workspace.y + workspaceBounds.height + ) +} + +// Helper to find the private workspace shape on the canvas +export function findPrivateWorkspace(editor: any): IPrivateWorkspaceShape | null { + const shapes = editor.getCurrentPageShapes() + return shapes.find((s: any) => s.type === 'PrivateWorkspace') || null +} diff --git a/src/tools/PrivateWorkspaceTool.ts b/src/tools/PrivateWorkspaceTool.ts new file mode 100644 index 0000000..812a443 --- /dev/null +++ b/src/tools/PrivateWorkspaceTool.ts @@ -0,0 +1,11 @@ +import { BaseBoxShapeTool, TLEventHandlers } from "tldraw" + +export class PrivateWorkspaceTool extends BaseBoxShapeTool { + static override id = "PrivateWorkspace" + shapeType = "PrivateWorkspace" + override initial = "idle" + + override onComplete: TLEventHandlers["onComplete"] = () => { + this.editor.setCurrentTool('select') + } +}