371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
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
|
|
}
|