feat: Add Private Workspace zone for data sovereignty (Phase 3)
- PrivateWorkspaceShapeUtil: Frosted glass container shape with: - Dashed indigo border for visual distinction - Pin/collapse/close buttons in header - Dark mode support - Position/size persistence to localStorage - Helper functions for zone detection - PrivateWorkspaceTool: Tool for creating workspace zones - usePrivateWorkspace hook: - Creates/toggles workspace visibility - Listens for 'add-google-items-to-canvas' events - Places items inside the private zone - Persists visibility state - PrivateWorkspaceManager: Headless component that manages workspace lifecycle inside Tldraw context Items added from GoogleExportBrowser will now appear in the Private Workspace zone as placeholder text shapes (Phase 4 will add proper GoogleItemShape with visual badges). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5786848714
commit
c4b148df94
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<TLShapeId | null>(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<IPrivateWorkspaceShape>({
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
}}
|
||||
>
|
||||
<CmdK />
|
||||
<PrivateWorkspaceManager />
|
||||
</Tldraw>
|
||||
</div>
|
||||
</AutomergeHandleProvider>
|
||||
|
|
|
|||
|
|
@ -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<IPrivateWorkspaceShape> {
|
||||
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 (
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h}
|
||||
rx={12}
|
||||
ry={12}
|
||||
strokeDasharray="8 4"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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<IPrivateWorkspaceShape>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
this.editor.updateShape<IPrivateWorkspaceShape>({
|
||||
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 (
|
||||
<HTMLContainer
|
||||
style={{
|
||||
width: shape.props.w,
|
||||
height: shape.props.isCollapsed ? collapsedHeight : shape.props.h,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.bg,
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
borderRadius: '12px',
|
||||
border: `2px dashed ${colors.border}`,
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 2px ${PrivateWorkspaceShape.PRIMARY_COLOR}, 0 8px 32px rgba(99, 102, 241, 0.15)`
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
transition: 'box-shadow 0.2s ease, height 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px',
|
||||
backgroundColor: colors.headerBg,
|
||||
borderBottom: shape.props.isCollapsed ? 'none' : `1px solid ${colors.border}`,
|
||||
cursor: 'grab',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
// Allow dragging from header
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>🔒</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
Private Workspace
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{/* Pin button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePinToggle()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px 6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
borderRadius: '4px',
|
||||
opacity: shape.props.pinnedToView ? 1 : 0.6,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
title={shape.props.pinnedToView ? 'Unpin from viewport' : 'Pin to viewport'}
|
||||
>
|
||||
📌
|
||||
</button>
|
||||
|
||||
{/* Collapse button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCollapse()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px 6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
borderRadius: '4px',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
title={shape.props.isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{shape.props.isCollapsed ? '▼' : '▲'}
|
||||
</button>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleClose()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px 6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
borderRadius: '4px',
|
||||
opacity: 0.6,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
title="Close workspace"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
{!shape.props.isCollapsed && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: colors.textMuted,
|
||||
fontSize: '13px',
|
||||
textAlign: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: colors.headerBg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
}}
|
||||
>
|
||||
🔐
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ margin: '0 0 4px 0', fontWeight: '500', color: colors.text }}>
|
||||
Drop items here to keep them private
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '12px', opacity: 0.8 }}>
|
||||
Encrypted in your browser • Only you can see these
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer hint */}
|
||||
{!shape.props.isCollapsed && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
backgroundColor: colors.headerBg,
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
fontSize: '11px',
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Drag items outside to share with collaborators
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue