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
33f5dc7e7f
commit
052c98417d
|
|
@ -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"
|
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
|
||||||
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
|
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
|
||||||
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
|
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 {
|
import {
|
||||||
lockElement,
|
lockElement,
|
||||||
unlockElement,
|
unlockElement,
|
||||||
|
|
@ -141,6 +145,7 @@ const customShapeUtils = [
|
||||||
VideoGenShape,
|
VideoGenShape,
|
||||||
MultmuxShape,
|
MultmuxShape,
|
||||||
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
|
||||||
|
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
||||||
]
|
]
|
||||||
const customTools = [
|
const customTools = [
|
||||||
ChatBoxTool,
|
ChatBoxTool,
|
||||||
|
|
@ -158,6 +163,7 @@ const customTools = [
|
||||||
ImageGenTool,
|
ImageGenTool,
|
||||||
VideoGenTool,
|
VideoGenTool,
|
||||||
MultmuxTool,
|
MultmuxTool,
|
||||||
|
PrivateWorkspaceTool,
|
||||||
]
|
]
|
||||||
|
|
||||||
// Debug: Log tool and shape registration info
|
// Debug: Log tool and shape registration info
|
||||||
|
|
@ -1100,6 +1106,7 @@ export function Board() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CmdK />
|
<CmdK />
|
||||||
|
<PrivateWorkspaceManager />
|
||||||
</Tldraw>
|
</Tldraw>
|
||||||
</div>
|
</div>
|
||||||
</AutomergeHandleProvider>
|
</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