deepspace 0.3.10 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +331 -115
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +199 -16
- package/dist/index.js +354 -166
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +242 -73
- package/dist/server.js +985 -374
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +322 -73
- package/dist/worker.js +961 -343
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- package/scripts/add-feature.cjs +654 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanvasView — SVG canvas with shape rendering, mouse interaction, zoom/pan, and multi-user cursors.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState, useRef, useCallback, useEffect } from 'react'
|
|
6
|
+
import { useCanvas, type CanvasShapeClient } from 'deepspace'
|
|
7
|
+
import { CanvasToolbar, type CanvasTool } from './CanvasToolbar'
|
|
8
|
+
import { ShapeRenderer } from './ShapeRenderer'
|
|
9
|
+
import { Badge } from 'deepspace'
|
|
10
|
+
|
|
11
|
+
interface CanvasViewProps {
|
|
12
|
+
docId: string
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DragState {
|
|
17
|
+
type: 'create' | 'move'
|
|
18
|
+
startX: number
|
|
19
|
+
startY: number
|
|
20
|
+
shapeId?: string
|
|
21
|
+
originalX?: number
|
|
22
|
+
originalY?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// User colors for multi-user cursors
|
|
26
|
+
const CURSOR_COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6']
|
|
27
|
+
|
|
28
|
+
export function CanvasView({ docId, className }: CanvasViewProps) {
|
|
29
|
+
const {
|
|
30
|
+
shapes,
|
|
31
|
+
viewports,
|
|
32
|
+
connected,
|
|
33
|
+
addShape,
|
|
34
|
+
moveShape,
|
|
35
|
+
deleteShape,
|
|
36
|
+
setViewport,
|
|
37
|
+
undo,
|
|
38
|
+
redo,
|
|
39
|
+
} = useCanvas(docId)
|
|
40
|
+
|
|
41
|
+
const [activeTool, setActiveTool] = useState<CanvasTool>('select')
|
|
42
|
+
const [activeColor, setActiveColor] = useState('#6366f1')
|
|
43
|
+
const [selectedShapeId, setSelectedShapeId] = useState<string | null>(null)
|
|
44
|
+
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
45
|
+
const [pan, setPan] = useState({ x: 0, y: 0 })
|
|
46
|
+
const [zoom, setZoom] = useState(1)
|
|
47
|
+
const [isPanning, setIsPanning] = useState(false)
|
|
48
|
+
const [panStart, setPanStart] = useState({ x: 0, y: 0, panX: 0, panY: 0 })
|
|
49
|
+
|
|
50
|
+
const svgRef = useRef<SVGSVGElement>(null)
|
|
51
|
+
|
|
52
|
+
// Convert screen coordinates to canvas coordinates
|
|
53
|
+
const screenToCanvas = useCallback(
|
|
54
|
+
(clientX: number, clientY: number) => {
|
|
55
|
+
const svg = svgRef.current
|
|
56
|
+
if (!svg) return { x: 0, y: 0 }
|
|
57
|
+
const rect = svg.getBoundingClientRect()
|
|
58
|
+
return {
|
|
59
|
+
x: (clientX - rect.left - pan.x) / zoom,
|
|
60
|
+
y: (clientY - rect.top - pan.y) / zoom,
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[pan, zoom],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Report viewport on pan/zoom change
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const svg = svgRef.current
|
|
69
|
+
if (!svg) return
|
|
70
|
+
const rect = svg.getBoundingClientRect()
|
|
71
|
+
setViewport({
|
|
72
|
+
x: -pan.x / zoom,
|
|
73
|
+
y: -pan.y / zoom,
|
|
74
|
+
width: rect.width / zoom,
|
|
75
|
+
height: rect.height / zoom,
|
|
76
|
+
zoom,
|
|
77
|
+
})
|
|
78
|
+
}, [pan, zoom, setViewport])
|
|
79
|
+
|
|
80
|
+
// Keyboard shortcuts
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
83
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
84
|
+
if (selectedShapeId && document.activeElement === document.body) {
|
|
85
|
+
e.preventDefault()
|
|
86
|
+
deleteShape(selectedShapeId)
|
|
87
|
+
setSelectedShapeId(null)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
if (e.shiftKey) {
|
|
93
|
+
redo()
|
|
94
|
+
} else {
|
|
95
|
+
undo()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
100
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
101
|
+
}, [selectedShapeId, deleteShape, undo, redo])
|
|
102
|
+
|
|
103
|
+
// Mouse down on SVG background
|
|
104
|
+
const handleSvgMouseDown = useCallback(
|
|
105
|
+
(e: React.MouseEvent) => {
|
|
106
|
+
// Middle mouse button or space+click for panning
|
|
107
|
+
if (e.button === 1) {
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
setIsPanning(true)
|
|
110
|
+
setPanStart({ x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (e.button !== 0) return
|
|
115
|
+
|
|
116
|
+
const pos = screenToCanvas(e.clientX, e.clientY)
|
|
117
|
+
|
|
118
|
+
if (activeTool === 'select') {
|
|
119
|
+
// Deselect when clicking background
|
|
120
|
+
setSelectedShapeId(null)
|
|
121
|
+
// Start panning
|
|
122
|
+
setIsPanning(true)
|
|
123
|
+
setPanStart({ x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Start creating a shape
|
|
128
|
+
setDragState({
|
|
129
|
+
type: 'create',
|
|
130
|
+
startX: pos.x,
|
|
131
|
+
startY: pos.y,
|
|
132
|
+
})
|
|
133
|
+
},
|
|
134
|
+
[activeTool, screenToCanvas, pan],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Mouse down on a shape
|
|
138
|
+
const handleShapeMouseDown = useCallback(
|
|
139
|
+
(e: React.MouseEvent, shapeId: string) => {
|
|
140
|
+
if (activeTool !== 'select') return
|
|
141
|
+
|
|
142
|
+
const shape = shapes.find((s) => s.id === shapeId)
|
|
143
|
+
if (!shape) return
|
|
144
|
+
|
|
145
|
+
setSelectedShapeId(shapeId)
|
|
146
|
+
setDragState({
|
|
147
|
+
type: 'move',
|
|
148
|
+
startX: e.clientX,
|
|
149
|
+
startY: e.clientY,
|
|
150
|
+
shapeId,
|
|
151
|
+
originalX: shape.x,
|
|
152
|
+
originalY: shape.y,
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
[activeTool, shapes],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// Mouse move
|
|
159
|
+
const handleMouseMove = useCallback(
|
|
160
|
+
(e: React.MouseEvent) => {
|
|
161
|
+
if (isPanning) {
|
|
162
|
+
setPan({
|
|
163
|
+
x: panStart.panX + (e.clientX - panStart.x),
|
|
164
|
+
y: panStart.panY + (e.clientY - panStart.y),
|
|
165
|
+
})
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!dragState) return
|
|
170
|
+
|
|
171
|
+
if (dragState.type === 'move' && dragState.shapeId) {
|
|
172
|
+
const dx = (e.clientX - dragState.startX) / zoom
|
|
173
|
+
const dy = (e.clientY - dragState.startY) / zoom
|
|
174
|
+
moveShape(dragState.shapeId, dragState.originalX! + dx, dragState.originalY! + dy)
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
[dragState, isPanning, panStart, zoom, moveShape],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
// Mouse up
|
|
181
|
+
const handleMouseUp = useCallback(
|
|
182
|
+
(e: React.MouseEvent) => {
|
|
183
|
+
if (isPanning) {
|
|
184
|
+
setIsPanning(false)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!dragState) return
|
|
189
|
+
|
|
190
|
+
if (dragState.type === 'create') {
|
|
191
|
+
const pos = screenToCanvas(e.clientX, e.clientY)
|
|
192
|
+
const x = Math.min(dragState.startX, pos.x)
|
|
193
|
+
const y = Math.min(dragState.startY, pos.y)
|
|
194
|
+
const width = Math.max(Math.abs(pos.x - dragState.startX), 20)
|
|
195
|
+
const height = Math.max(Math.abs(pos.y - dragState.startY), 20)
|
|
196
|
+
|
|
197
|
+
addShape({
|
|
198
|
+
type: activeTool,
|
|
199
|
+
x,
|
|
200
|
+
y,
|
|
201
|
+
width,
|
|
202
|
+
height,
|
|
203
|
+
props: {
|
|
204
|
+
stroke: activeColor,
|
|
205
|
+
fill: 'transparent',
|
|
206
|
+
...(activeTool === 'text' ? { text: 'Text' } : {}),
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// Switch back to select after creating
|
|
211
|
+
setActiveTool('select')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setDragState(null)
|
|
215
|
+
},
|
|
216
|
+
[dragState, isPanning, screenToCanvas, activeTool, activeColor, addShape],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
// Wheel for zoom
|
|
220
|
+
const handleWheel = useCallback(
|
|
221
|
+
(e: React.WheelEvent) => {
|
|
222
|
+
e.preventDefault()
|
|
223
|
+
const svg = svgRef.current
|
|
224
|
+
if (!svg) return
|
|
225
|
+
|
|
226
|
+
const rect = svg.getBoundingClientRect()
|
|
227
|
+
const mouseX = e.clientX - rect.left
|
|
228
|
+
const mouseY = e.clientY - rect.top
|
|
229
|
+
|
|
230
|
+
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1
|
|
231
|
+
const newZoom = Math.min(Math.max(zoom * zoomFactor, 0.1), 5)
|
|
232
|
+
|
|
233
|
+
// Zoom towards mouse position
|
|
234
|
+
setPan({
|
|
235
|
+
x: mouseX - (mouseX - pan.x) * (newZoom / zoom),
|
|
236
|
+
y: mouseY - (mouseY - pan.y) * (newZoom / zoom),
|
|
237
|
+
})
|
|
238
|
+
setZoom(newZoom)
|
|
239
|
+
},
|
|
240
|
+
[zoom, pan],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const handleDelete = useCallback(() => {
|
|
244
|
+
if (selectedShapeId) {
|
|
245
|
+
deleteShape(selectedShapeId)
|
|
246
|
+
setSelectedShapeId(null)
|
|
247
|
+
}
|
|
248
|
+
}, [selectedShapeId, deleteShape])
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div data-testid="canvas-view" className={`flex flex-col h-full ${className ?? ''}`}>
|
|
252
|
+
<CanvasToolbar
|
|
253
|
+
activeTool={activeTool}
|
|
254
|
+
activeColor={activeColor}
|
|
255
|
+
hasSelection={selectedShapeId !== null}
|
|
256
|
+
onToolChange={setActiveTool}
|
|
257
|
+
onColorChange={setActiveColor}
|
|
258
|
+
onDelete={handleDelete}
|
|
259
|
+
onUndo={undo}
|
|
260
|
+
onRedo={redo}
|
|
261
|
+
/>
|
|
262
|
+
|
|
263
|
+
<div className="relative flex-1 overflow-hidden bg-background">
|
|
264
|
+
{/* Connection status */}
|
|
265
|
+
<div className="absolute top-3 right-3 z-10">
|
|
266
|
+
{connected ? (
|
|
267
|
+
<Badge variant="success" className="text-xs">
|
|
268
|
+
<span className="w-1.5 h-1.5 bg-success rounded-full mr-1" />
|
|
269
|
+
Connected
|
|
270
|
+
</Badge>
|
|
271
|
+
) : (
|
|
272
|
+
<Badge variant="secondary" className="text-xs">
|
|
273
|
+
<span className="w-1.5 h-1.5 bg-warning rounded-full animate-pulse mr-1" />
|
|
274
|
+
Connecting...
|
|
275
|
+
</Badge>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* Zoom indicator */}
|
|
280
|
+
<div className="absolute bottom-3 right-3 z-10">
|
|
281
|
+
<span className="text-xs text-muted-foreground bg-card/80 px-2 py-1 rounded border border-border">
|
|
282
|
+
{Math.round(zoom * 100)}%
|
|
283
|
+
</span>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<svg
|
|
287
|
+
ref={svgRef}
|
|
288
|
+
data-testid="canvas-svg"
|
|
289
|
+
className="w-full h-full"
|
|
290
|
+
style={{
|
|
291
|
+
cursor: activeTool === 'select' ? (isPanning ? 'grabbing' : 'default') : 'crosshair',
|
|
292
|
+
}}
|
|
293
|
+
onMouseDown={handleSvgMouseDown}
|
|
294
|
+
onMouseMove={handleMouseMove}
|
|
295
|
+
onMouseUp={handleMouseUp}
|
|
296
|
+
onMouseLeave={handleMouseUp}
|
|
297
|
+
onWheel={handleWheel}
|
|
298
|
+
>
|
|
299
|
+
{/* Grid background */}
|
|
300
|
+
<defs>
|
|
301
|
+
<pattern
|
|
302
|
+
id="grid"
|
|
303
|
+
width={20 * zoom}
|
|
304
|
+
height={20 * zoom}
|
|
305
|
+
patternUnits="userSpaceOnUse"
|
|
306
|
+
x={pan.x % (20 * zoom)}
|
|
307
|
+
y={pan.y % (20 * zoom)}
|
|
308
|
+
>
|
|
309
|
+
<circle cx={1} cy={1} r={0.5} fill="currentColor" className="text-border" />
|
|
310
|
+
</pattern>
|
|
311
|
+
</defs>
|
|
312
|
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
313
|
+
|
|
314
|
+
{/* Canvas content with pan/zoom transform */}
|
|
315
|
+
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
|
316
|
+
{/* Shapes */}
|
|
317
|
+
{shapes.map((shape) => (
|
|
318
|
+
<ShapeRenderer
|
|
319
|
+
key={shape.id}
|
|
320
|
+
shape={shape}
|
|
321
|
+
isSelected={shape.id === selectedShapeId}
|
|
322
|
+
onMouseDown={handleShapeMouseDown}
|
|
323
|
+
/>
|
|
324
|
+
))}
|
|
325
|
+
|
|
326
|
+
{/* Other users' cursors */}
|
|
327
|
+
{viewports.map((vp, i) => (
|
|
328
|
+
<g key={vp.userId} pointerEvents="none">
|
|
329
|
+
{/* Cursor dot */}
|
|
330
|
+
<circle
|
|
331
|
+
cx={vp.x + vp.width / 2}
|
|
332
|
+
cy={vp.y + 20}
|
|
333
|
+
r={4 / zoom}
|
|
334
|
+
fill={CURSOR_COLORS[i % CURSOR_COLORS.length]}
|
|
335
|
+
/>
|
|
336
|
+
{/* User label */}
|
|
337
|
+
<text
|
|
338
|
+
x={vp.x + vp.width / 2 + 8 / zoom}
|
|
339
|
+
y={vp.y + 24 / zoom}
|
|
340
|
+
fontSize={11 / zoom}
|
|
341
|
+
fill={CURSOR_COLORS[i % CURSOR_COLORS.length]}
|
|
342
|
+
fontFamily="system-ui, sans-serif"
|
|
343
|
+
>
|
|
344
|
+
User {vp.userId.slice(0, 6)}
|
|
345
|
+
</text>
|
|
346
|
+
</g>
|
|
347
|
+
))}
|
|
348
|
+
</g>
|
|
349
|
+
|
|
350
|
+
{/* Drag preview for shape creation */}
|
|
351
|
+
{dragState?.type === 'create' && (
|
|
352
|
+
<rect
|
|
353
|
+
x={0}
|
|
354
|
+
y={0}
|
|
355
|
+
width={0}
|
|
356
|
+
height={0}
|
|
357
|
+
fill="transparent"
|
|
358
|
+
stroke={activeColor}
|
|
359
|
+
strokeWidth={1}
|
|
360
|
+
strokeDasharray="4 3"
|
|
361
|
+
pointerEvents="none"
|
|
362
|
+
style={{ display: 'none' }}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
</svg>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShapeRenderer — Renders individual shapes as SVG elements.
|
|
3
|
+
*
|
|
4
|
+
* Supports: rect, ellipse, line, text
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CanvasShapeClient } from 'deepspace'
|
|
8
|
+
|
|
9
|
+
interface ShapeRendererProps {
|
|
10
|
+
shape: CanvasShapeClient
|
|
11
|
+
isSelected: boolean
|
|
12
|
+
onMouseDown: (e: React.MouseEvent, shapeId: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SELECTION_PADDING = 4
|
|
16
|
+
|
|
17
|
+
export function ShapeRenderer({ shape, isSelected, onMouseDown }: ShapeRendererProps) {
|
|
18
|
+
const fill = (shape.props.fill as string) ?? 'transparent'
|
|
19
|
+
const stroke = (shape.props.stroke as string) ?? '#6366f1'
|
|
20
|
+
const strokeWidth = (shape.props.strokeWidth as number) ?? 2
|
|
21
|
+
|
|
22
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
23
|
+
e.stopPropagation()
|
|
24
|
+
onMouseDown(e, shape.id)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<g
|
|
29
|
+
data-testid={`shape-${shape.id}`}
|
|
30
|
+
onMouseDown={handleMouseDown}
|
|
31
|
+
style={{ cursor: 'move' }}
|
|
32
|
+
>
|
|
33
|
+
{/* Selection outline */}
|
|
34
|
+
{isSelected && (
|
|
35
|
+
<rect
|
|
36
|
+
x={shape.x - SELECTION_PADDING}
|
|
37
|
+
y={shape.y - SELECTION_PADDING}
|
|
38
|
+
width={shape.width + SELECTION_PADDING * 2}
|
|
39
|
+
height={shape.height + SELECTION_PADDING * 2}
|
|
40
|
+
fill="none"
|
|
41
|
+
style={{ stroke: 'var(--color-primary)' }}
|
|
42
|
+
strokeWidth={1.5}
|
|
43
|
+
strokeDasharray="4 3"
|
|
44
|
+
rx={2}
|
|
45
|
+
pointerEvents="none"
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
{/* Shape */}
|
|
50
|
+
{shape.type === 'rect' && (
|
|
51
|
+
<rect
|
|
52
|
+
x={shape.x}
|
|
53
|
+
y={shape.y}
|
|
54
|
+
width={shape.width}
|
|
55
|
+
height={shape.height}
|
|
56
|
+
fill={fill}
|
|
57
|
+
stroke={stroke}
|
|
58
|
+
strokeWidth={strokeWidth}
|
|
59
|
+
rx={4}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
{shape.type === 'ellipse' && (
|
|
64
|
+
<ellipse
|
|
65
|
+
cx={shape.x + shape.width / 2}
|
|
66
|
+
cy={shape.y + shape.height / 2}
|
|
67
|
+
rx={shape.width / 2}
|
|
68
|
+
ry={shape.height / 2}
|
|
69
|
+
fill={fill}
|
|
70
|
+
stroke={stroke}
|
|
71
|
+
strokeWidth={strokeWidth}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
{shape.type === 'line' && (
|
|
76
|
+
<line
|
|
77
|
+
x1={shape.x}
|
|
78
|
+
y1={shape.y}
|
|
79
|
+
x2={shape.x + shape.width}
|
|
80
|
+
y2={shape.y + shape.height}
|
|
81
|
+
stroke={stroke}
|
|
82
|
+
strokeWidth={strokeWidth}
|
|
83
|
+
strokeLinecap="round"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{shape.type === 'text' && (
|
|
88
|
+
<>
|
|
89
|
+
<rect
|
|
90
|
+
x={shape.x}
|
|
91
|
+
y={shape.y}
|
|
92
|
+
width={shape.width}
|
|
93
|
+
height={shape.height}
|
|
94
|
+
fill={fill === 'transparent' ? 'transparent' : fill}
|
|
95
|
+
stroke={isSelected ? stroke : 'transparent'}
|
|
96
|
+
strokeWidth={isSelected ? strokeWidth : 0}
|
|
97
|
+
rx={2}
|
|
98
|
+
/>
|
|
99
|
+
<text
|
|
100
|
+
x={shape.x + 8}
|
|
101
|
+
y={shape.y + shape.height / 2}
|
|
102
|
+
dominantBaseline="central"
|
|
103
|
+
fill={stroke}
|
|
104
|
+
fontSize={14}
|
|
105
|
+
fontFamily="system-ui, sans-serif"
|
|
106
|
+
pointerEvents="none"
|
|
107
|
+
>
|
|
108
|
+
{(shape.props.text as string) ?? 'Text'}
|
|
109
|
+
</text>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
</g>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "cron",
|
|
3
|
+
"name": "Cron Heartbeat",
|
|
4
|
+
"category": "data",
|
|
5
|
+
"description": "Scheduled background tasks via the per-app CronRoom DO",
|
|
6
|
+
"details": "Reference implementation of a working cron job. Registers a 1-minute 'heartbeat' task in src/cron.ts (consumed by AppCronRoom at construction time). Each fire is logged to the DO's cron_history table; /cron-log subscribes via useCronMonitor and renders live history. Useful both as a working example and as the basis for the cron e2e spec.",
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"src": "src/cron.ts",
|
|
10
|
+
"dest": "src/cron.ts",
|
|
11
|
+
"overwrite": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"src": "src/CronLogPage.tsx",
|
|
15
|
+
"dest": "src/pages/cron-log.tsx"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"route": {
|
|
19
|
+
"path": "/cron-log",
|
|
20
|
+
"protected": false
|
|
21
|
+
},
|
|
22
|
+
"requires": [],
|
|
23
|
+
"instructions": [
|
|
24
|
+
"Deploy the app: npm run deploy",
|
|
25
|
+
"Within ~90s, visit /cron-log on the deployed app — you should see one new row per minute as the AppCronRoom alarm fires the heartbeat task.",
|
|
26
|
+
"Open the page on a desktop browser and look at devtools → Network → WS → /ws/cron/app:<APP_NAME> to see live cron.* protocol frames."
|
|
27
|
+
],
|
|
28
|
+
"patterns": [
|
|
29
|
+
"Task config lives in code (src/cron.ts), not JSON. AppCronRoom validates at construction time.",
|
|
30
|
+
"Per-app CronRoom DO shards work; no global KV bottleneck. CPU is correctly attributed to the app owner via the tail-worker.",
|
|
31
|
+
"Heartbeat tasks (intervalMinutes: 1) are useful as low-cost liveness probes for cron infrastructure."
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron Log Page
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the app's CronRoom via `useCronMonitor` and renders live
|
|
5
|
+
* task state + execution history. The DO is keyed by `app:${APP_NAME}` so
|
|
6
|
+
* a single shared CronRoom DO instance backs the whole app — same pattern
|
|
7
|
+
* as RecordRoom.
|
|
8
|
+
*
|
|
9
|
+
* Used both as a UI surface for verifying that scheduled tasks are firing
|
|
10
|
+
* in production, and as the data source for the cron e2e spec at
|
|
11
|
+
* tests/feature-tests/tests/cron.spec.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCronMonitor } from 'deepspace'
|
|
15
|
+
import { SCOPE_ID } from '../constants'
|
|
16
|
+
|
|
17
|
+
export default function CronLogPage() {
|
|
18
|
+
const { tasks, history, connected } = useCronMonitor(SCOPE_ID)
|
|
19
|
+
|
|
20
|
+
// Newest first.
|
|
21
|
+
const sorted = [...history].sort((a, b) => {
|
|
22
|
+
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div data-testid="cron-log-page" style={{ padding: 24 }}>
|
|
27
|
+
<h1>Cron Log</h1>
|
|
28
|
+
<p>
|
|
29
|
+
Each row is one tick of a scheduled task fired by the AppCronRoom DO.
|
|
30
|
+
Heartbeat ticks every minute once the DO alarm picks up the registered
|
|
31
|
+
config.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<div data-testid="cron-log-status" style={{ marginBottom: 12 }}>
|
|
35
|
+
Connection: {connected ? 'live' : 'connecting…'}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<h2>Tasks</h2>
|
|
39
|
+
<table data-testid="cron-tasks" style={{ borderCollapse: 'collapse', width: '100%' }}>
|
|
40
|
+
<thead>
|
|
41
|
+
<tr>
|
|
42
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Name</th>
|
|
43
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Schedule</th>
|
|
44
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Last run</th>
|
|
45
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Next run</th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
{tasks.map((task) => (
|
|
50
|
+
<tr key={task.name} data-testid="cron-task-row" data-task={task.name}>
|
|
51
|
+
<td style={{ padding: 8 }}>{task.name}</td>
|
|
52
|
+
<td style={{ padding: 8 }}>
|
|
53
|
+
{task.schedule
|
|
54
|
+
? `${task.schedule} (${task.timezone ?? 'UTC'})`
|
|
55
|
+
: `every ${task.intervalMinutes ?? '?'} min`}
|
|
56
|
+
</td>
|
|
57
|
+
<td style={{ padding: 8 }}>{task.lastRunAt ?? '—'}</td>
|
|
58
|
+
<td style={{ padding: 8 }}>{task.nextRunAt ?? '—'}</td>
|
|
59
|
+
</tr>
|
|
60
|
+
))}
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
|
|
64
|
+
<h2 style={{ marginTop: 24 }}>History</h2>
|
|
65
|
+
<div data-testid="cron-log-count">{sorted.length} entries</div>
|
|
66
|
+
|
|
67
|
+
<table style={{ marginTop: 16, borderCollapse: 'collapse', width: '100%' }}>
|
|
68
|
+
<thead>
|
|
69
|
+
<tr>
|
|
70
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Task</th>
|
|
71
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Started (UTC)</th>
|
|
72
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Duration</th>
|
|
73
|
+
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Outcome</th>
|
|
74
|
+
</tr>
|
|
75
|
+
</thead>
|
|
76
|
+
<tbody data-testid="cron-log-rows">
|
|
77
|
+
{sorted.map((entry, idx) => (
|
|
78
|
+
<tr
|
|
79
|
+
key={`${entry.taskName}-${entry.startedAt}-${idx}`}
|
|
80
|
+
data-testid="cron-log-row"
|
|
81
|
+
data-task={entry.taskName}
|
|
82
|
+
data-success={entry.success ? '1' : '0'}
|
|
83
|
+
>
|
|
84
|
+
<td style={{ padding: 8 }}>{entry.taskName}</td>
|
|
85
|
+
<td style={{ padding: 8 }}>{entry.startedAt}</td>
|
|
86
|
+
<td style={{ padding: 8 }}>{entry.durationMs} ms</td>
|
|
87
|
+
<td style={{ padding: 8 }}>{entry.success ? 'ok' : `error: ${entry.error ?? '?'}`}</td>
|
|
88
|
+
</tr>
|
|
89
|
+
))}
|
|
90
|
+
</tbody>
|
|
91
|
+
</table>
|
|
92
|
+
|
|
93
|
+
{sorted.length === 0 && connected && (
|
|
94
|
+
<p data-testid="cron-log-empty" style={{ marginTop: 24, opacity: 0.6 }}>
|
|
95
|
+
No cron ticks recorded yet. The first one should appear within ~90s of deploy.
|
|
96
|
+
</p>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron task definitions for the heartbeat reference feature.
|
|
3
|
+
*
|
|
4
|
+
* AppCronRoom (declared in worker.ts) reads `tasks` at construction time
|
|
5
|
+
* and validates them. The DO alarm fires `runTask(name, env)` on the
|
|
6
|
+
* configured schedule; each fire is recorded in the DO's `cron_history`
|
|
7
|
+
* table and pushed to subscribers over `/ws/cron/:roomId`.
|
|
8
|
+
*
|
|
9
|
+
* The heartbeat task here is the low-cost liveness probe used by both
|
|
10
|
+
* the cron e2e spec and as a quick sanity check after a deploy. To add
|
|
11
|
+
* your own task, append a {name, intervalMinutes} or {name, schedule,
|
|
12
|
+
* timezone} entry to `tasks` and dispatch on `name` inside `runTask`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CronTask } from 'deepspace/worker'
|
|
16
|
+
|
|
17
|
+
export const tasks: CronTask[] = [
|
|
18
|
+
{ name: 'heartbeat', intervalMinutes: 1 },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export async function runTask(_name: string, _env: unknown): Promise<void> {
|
|
22
|
+
// No-op work — the DO records every invocation in cron_history regardless,
|
|
23
|
+
// which is what the e2e spec asserts against. Real apps would dispatch on
|
|
24
|
+
// `_name` here and call into integrations / records via buildCronContext.
|
|
25
|
+
}
|