deepspace 0.3.9 → 0.3.11

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.
Files changed (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Tiptap editor hook bound to a Yjs `XmlFragment` + Awareness.
3
+ *
4
+ * Why Tiptap vs the old contenteditable approach:
5
+ * - Y.XmlFragment is a structured CRDT — remote keystrokes become
6
+ * fine-grained ProseMirror transactions, not an `innerHTML` swap.
7
+ * - Local caret survives concurrent remote edits via ProseMirror's
8
+ * position-mapping system (no manual probe / restore).
9
+ * - Remote cursors come from `@tiptap/extension-collaboration-caret`
10
+ * reading the same Awareness instance our WS hook already wires.
11
+ * - No HTML round-trip in storage, so browser-inserted artefacts
12
+ * (`&nbsp;`, `<div><br></div>`, IME composition state) cannot
13
+ * corrupt the shared document.
14
+ */
15
+
16
+ import { useEffect, useMemo, useRef } from 'react'
17
+ import { useEditor, type Editor } from '@tiptap/react'
18
+ import StarterKit from '@tiptap/starter-kit'
19
+ import Collaboration from '@tiptap/extension-collaboration'
20
+ import { CollaborationCaret } from '@tiptap/extension-collaboration-caret'
21
+ import Placeholder from '@tiptap/extension-placeholder'
22
+ import Typography from '@tiptap/extension-typography'
23
+ import TextAlign from '@tiptap/extension-text-align'
24
+ import { TextStyle } from '@tiptap/extension-text-style'
25
+ import Color from '@tiptap/extension-color'
26
+ import Highlight from '@tiptap/extension-highlight'
27
+ import Subscript from '@tiptap/extension-subscript'
28
+ import Superscript from '@tiptap/extension-superscript'
29
+ import TaskList from '@tiptap/extension-task-list'
30
+ import TaskItem from '@tiptap/extension-task-item'
31
+ import type { Awareness } from 'deepspace'
32
+ import type * as Y from 'yjs'
33
+
34
+ type CollaborationUser = {
35
+ name?: string | null
36
+ color?: string | null
37
+ }
38
+
39
+ const DEFAULT_COLLABORATION_COLOR = '#2563eb'
40
+ const DISALLOWED_COLLABORATION_COLORS = new Set([
41
+ '#fff',
42
+ '#ffff',
43
+ '#ffffff',
44
+ '#ffffffff',
45
+ 'white',
46
+ 'rgb(255, 255, 255)',
47
+ 'rgba(255, 255, 255, 1)',
48
+ ])
49
+
50
+ export interface UseDocEditorOptions {
51
+ doc: Y.Doc
52
+ /** Awareness instance shared with the Yjs WS hook (or null while not yet connected). */
53
+ awareness: Awareness | null
54
+ userName: string
55
+ userColor: string
56
+ synced: boolean
57
+ canWrite: boolean
58
+ placeholder?: string
59
+ }
60
+
61
+ function collaborationColorFor(user: CollaborationUser): string {
62
+ const color = user.color?.trim()
63
+ if (!color || DISALLOWED_COLLABORATION_COLORS.has(color.toLowerCase())) {
64
+ return DEFAULT_COLLABORATION_COLOR
65
+ }
66
+ return color
67
+ }
68
+
69
+ function renderCollaborationCaret(user: CollaborationUser): HTMLElement {
70
+ const color = collaborationColorFor(user)
71
+ const cursor = document.createElement('span')
72
+ cursor.classList.add('collaboration-carets__caret')
73
+ cursor.style.setProperty('--collaboration-caret-color', color)
74
+ cursor.setAttribute('aria-hidden', 'true')
75
+
76
+ const label = document.createElement('span')
77
+ label.classList.add('collaboration-carets__label')
78
+ label.style.backgroundColor = `color-mix(in srgb, ${color} 16%, var(--docs-editor-paper))`
79
+ label.style.color = color
80
+ label.textContent = user.name || 'Collaborator'
81
+ cursor.appendChild(label)
82
+
83
+ return cursor
84
+ }
85
+
86
+ function renderCollaborationSelection(user: CollaborationUser) {
87
+ const color = collaborationColorFor(user)
88
+ return {
89
+ class: 'ProseMirror-yjs-selection collaboration-carets__selection',
90
+ style: `background-color: color-mix(in srgb, ${color} 20%, transparent)`,
91
+ }
92
+ }
93
+
94
+ /** Pasted HTML sometimes carries our own collab caret nodes — scrub them. */
95
+ function stripCollaborationArtifactsFromHTML(html: string): string {
96
+ if (typeof document === 'undefined') return html
97
+
98
+ const container = document.createElement('div')
99
+ container.innerHTML = html
100
+
101
+ container
102
+ .querySelectorAll('.collaboration-carets__caret, .collaboration-carets__label')
103
+ .forEach((node) => node.remove())
104
+
105
+ container
106
+ .querySelectorAll('.ProseMirror-yjs-selection, .collaboration-carets__selection')
107
+ .forEach((node) => {
108
+ node.replaceWith(...Array.from(node.childNodes))
109
+ })
110
+
111
+ return container.innerHTML
112
+ }
113
+
114
+ export function useDocEditor({
115
+ doc,
116
+ awareness,
117
+ userName,
118
+ userColor,
119
+ synced,
120
+ canWrite,
121
+ placeholder = 'Start typing — toolbar above for formatting…',
122
+ }: UseDocEditorOptions): Editor | null {
123
+ /** Structured CRDT field that ProseMirror binds to — distinct from the legacy Y.Text('content'). */
124
+ const fragment = useMemo(() => doc.getXmlFragment('default'), [doc])
125
+
126
+ /** CollaborationCaret expects a provider-shaped `{ awareness }` object. */
127
+ const provider = useMemo(() => (awareness ? { awareness } : null), [awareness])
128
+
129
+ const extensions = useMemo(() => {
130
+ const exts: unknown[] = [
131
+ StarterKit.configure({
132
+ // Yjs has its own undo/redo history via the Collaboration extension; keeping
133
+ // both enabled corrupts the shared document on undo.
134
+ undoRedo: false,
135
+ link: {
136
+ openOnClick: false,
137
+ autolink: true,
138
+ HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
139
+ },
140
+ }),
141
+ Collaboration.configure({ fragment }),
142
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
143
+ TextStyle,
144
+ Color,
145
+ Highlight.configure({
146
+ multicolor: true,
147
+ HTMLAttributes: { class: 'docs-text-highlight' },
148
+ }),
149
+ Subscript,
150
+ Superscript,
151
+ TaskList,
152
+ TaskItem.configure({ nested: true }),
153
+ Typography,
154
+ Placeholder.configure({ placeholder }),
155
+ ]
156
+
157
+ if (provider) {
158
+ exts.push(
159
+ CollaborationCaret.configure({
160
+ provider,
161
+ user: { name: userName, color: userColor },
162
+ render: renderCollaborationCaret,
163
+ selectionRender: renderCollaborationSelection,
164
+ }),
165
+ )
166
+ }
167
+
168
+ return exts
169
+ }, [fragment, provider, userName, userColor, placeholder])
170
+
171
+ const editor = useEditor(
172
+ {
173
+ editable: synced && canWrite,
174
+ // Tiptap renders synchronously by default which trips up React's strict mode
175
+ // in dev (the editor view is destroyed and re-created, and any subscriber
176
+ // that runs in between throws on `editor.view`). Defer to a useEffect.
177
+ immediatelyRender: false,
178
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
+ extensions: extensions as any,
180
+ editorProps: {
181
+ transformPastedHTML: stripCollaborationArtifactsFromHTML,
182
+ attributes: {
183
+ class: 'docs-tiptap-editor',
184
+ 'data-testid': 'docs-tiptap-editor',
185
+ },
186
+ },
187
+ },
188
+ [extensions],
189
+ )
190
+
191
+ /**
192
+ * Toggle editable without thrashing the editor: setEditable is cheap and
193
+ * preserves selection/awareness, whereas re-creating the editor on every
194
+ * `synced`/`canWrite` change would flicker remote carets and reset undo.
195
+ */
196
+ const prevEditable = useRef(false)
197
+ useEffect(() => {
198
+ const editable = synced && canWrite
199
+ if (editor && editable !== prevEditable.current) {
200
+ prevEditable.current = editable
201
+ editor.setEditable(editable)
202
+ }
203
+ }, [editor, synced, canWrite])
204
+
205
+ return editor
206
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Matches {@link import('deepspace').useYjsRoom} (YjsRoom DO via /ws/yjs/:id) but wires
3
+ * {@link Awareness} over the server's MSG_AWARENESS path. Published `useYjsRoom` builds may
4
+ * omit awareness relay; collaborator avatars/presence otherwise never populate.
5
+ */
6
+
7
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
8
+ import * as Y from 'yjs'
9
+ import {
10
+ Awareness,
11
+ createDecoder,
12
+ createEncoder,
13
+ encodeAwarenessMessage,
14
+ getAuthToken,
15
+ handleAwarenessMessage,
16
+ MSG_AWARENESS,
17
+ MSG_SYNC,
18
+ MSG_SYNC_STEP1,
19
+ MSG_SYNC_STEP2,
20
+ MSG_SYNC_UPDATE,
21
+ readVarUint,
22
+ readVarUint8Array,
23
+ toUint8Array,
24
+ writeVarUint,
25
+ writeVarUint8Array,
26
+ } from 'deepspace'
27
+
28
+ const UPDATE_BATCH_DELAY_MS = 16
29
+
30
+ export interface UseYjsRoomWithAwarenessResult {
31
+ doc: Y.Doc
32
+ text: string
33
+ setText: (value: string) => void
34
+ synced: boolean
35
+ /** From the server's WebSocket `{ type:'auth', canWrite }`; false until confirmed. */
36
+ canWrite: boolean
37
+ /** Becomes true after the auth message is parsed (eliminates transient false before handshake). */
38
+ writeAuthResolved: boolean
39
+ awareness: Awareness
40
+ }
41
+
42
+ export function useYjsRoomWithAwareness(
43
+ docId: string,
44
+ fieldName: string,
45
+ ): UseYjsRoomWithAwarenessResult {
46
+ const [syncedDocId, setSyncedDocId] = useState<string | null>(null)
47
+ const [canWrite, setCanWrite] = useState(false)
48
+ const [writeAuthResolved, setWriteAuthResolved] = useState(false)
49
+ const [text, setTextState] = useState('')
50
+ const [, setUpdateCount] = useState(0)
51
+ const synced = syncedDocId === docId
52
+
53
+ const { doc, awareness } = useMemo(() => {
54
+ const nextDoc = new Y.Doc()
55
+ return { doc: nextDoc, awareness: new Awareness(nextDoc) }
56
+ }, [docId])
57
+
58
+ const wsRef = useRef<WebSocket | null>(null)
59
+ const isLocalRef = useRef(false)
60
+ const applyingRemoteAwarenessRef = useRef(false)
61
+ const pendingUpdatesRef = useRef<Uint8Array[]>([])
62
+ const updateBatchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
63
+
64
+ const yText = useMemo(() => doc.getText(fieldName), [doc, fieldName])
65
+
66
+ useEffect(() => {
67
+ return () => {
68
+ awareness.setLocalState(null)
69
+ doc.destroy()
70
+ }
71
+ }, [awareness, doc])
72
+
73
+ useLayoutEffect(() => {
74
+ setSyncedDocId(null)
75
+ setCanWrite(false)
76
+ setWriteAuthResolved(false)
77
+ setTextState('')
78
+ }, [docId])
79
+
80
+ useEffect(() => {
81
+ const observer = () => {
82
+ if (isLocalRef.current) return
83
+ setTextState(yText.toString())
84
+ }
85
+ yText.observe(observer)
86
+ return () => yText.unobserve(observer)
87
+ }, [yText])
88
+
89
+ useEffect(() => {
90
+ let ws: WebSocket | null = null
91
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
92
+ let alive = true
93
+
94
+ const connect = async (isReconnect: boolean) => {
95
+ if (!alive) return
96
+
97
+ if (!isReconnect) {
98
+ doc.transact(() => {
99
+ if (yText.length > 0) yText.delete(0, yText.length)
100
+ })
101
+ setTextState('')
102
+ try {
103
+ awareness.setLocalState(null)
104
+ } catch {
105
+ /* ignore */
106
+ }
107
+ }
108
+
109
+ const token = await getAuthToken()
110
+ if (!alive) return
111
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
112
+ const baseUrl = `${protocol}//${window.location.host}`
113
+ const url = new URL(`/ws/yjs/${encodeURIComponent(docId)}`, baseUrl)
114
+ if (token) url.searchParams.set('token', token)
115
+ ws = new WebSocket(url.toString())
116
+ ws.binaryType = 'arraybuffer'
117
+ wsRef.current = ws
118
+
119
+ ws.onopen = () => {
120
+ setSyncedDocId(null)
121
+ }
122
+
123
+ ws.onmessage = (event: MessageEvent<ArrayBuffer>) => {
124
+ if (typeof event.data === 'string') {
125
+ try {
126
+ const msg = JSON.parse(event.data) as { type?: string; canWrite?: boolean }
127
+ if (msg.type === 'auth') {
128
+ setWriteAuthResolved(true)
129
+ if (typeof msg.canWrite === 'boolean') setCanWrite(msg.canWrite)
130
+ }
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ return
135
+ }
136
+
137
+ const data = new Uint8Array(event.data)
138
+ const decoder = createDecoder(data)
139
+ const messageType = readVarUint(decoder)
140
+
141
+ if (messageType === MSG_SYNC) {
142
+ const syncType = readVarUint(decoder)
143
+ const payload = readVarUint8Array(decoder)
144
+ switch (syncType) {
145
+ case MSG_SYNC_STEP1: {
146
+ const diff = Y.encodeStateAsUpdate(doc, payload)
147
+ const enc = createEncoder()
148
+ writeVarUint(enc, MSG_SYNC)
149
+ writeVarUint(enc, MSG_SYNC_STEP2)
150
+ writeVarUint8Array(enc, diff)
151
+ ws?.send(toUint8Array(enc).buffer)
152
+ setSyncedDocId(docId)
153
+ break
154
+ }
155
+ case MSG_SYNC_STEP2: {
156
+ Y.applyUpdate(doc, payload, 'server')
157
+ setTextState(yText.toString())
158
+ setSyncedDocId(docId)
159
+ break
160
+ }
161
+ case MSG_SYNC_UPDATE: {
162
+ Y.applyUpdate(doc, payload, 'server')
163
+ setTextState(yText.toString())
164
+ setUpdateCount((c) => c + 1)
165
+ break
166
+ }
167
+ }
168
+ } else if (messageType === MSG_AWARENESS) {
169
+ applyingRemoteAwarenessRef.current = true
170
+ try {
171
+ handleAwarenessMessage(awareness, data)
172
+ } catch {
173
+ /* ignore malformed */
174
+ } finally {
175
+ applyingRemoteAwarenessRef.current = false
176
+ }
177
+ }
178
+ }
179
+
180
+ ws.onclose = () => {
181
+ wsRef.current = null
182
+ setSyncedDocId(null)
183
+ if (alive) reconnectTimer = setTimeout(() => void connect(true), 1000)
184
+ }
185
+
186
+ ws.onerror = () => ws?.close()
187
+ }
188
+
189
+ void connect(false)
190
+
191
+ return () => {
192
+ alive = false
193
+ if (reconnectTimer) clearTimeout(reconnectTimer)
194
+ if (ws) {
195
+ ws.onclose = null
196
+ ws.onmessage = null
197
+ ws.onerror = null
198
+ ws.close()
199
+ }
200
+ wsRef.current = null
201
+ }
202
+ }, [doc, docId, yText, awareness])
203
+
204
+ useEffect(() => {
205
+ /**
206
+ * Tiptap fires a Yjs `update` for every keystroke. Sending each as its
207
+ * own WS frame floods the socket and visibly drops typing latency under
208
+ * load. We coalesce into a single MSG_SYNC_UPDATE per ~16 ms via
209
+ * Y.mergeUpdates — same behaviour Tiptap collab examples ship with.
210
+ */
211
+ const flushPendingUpdates = () => {
212
+ updateBatchTimerRef.current = null
213
+ const updates = pendingUpdatesRef.current
214
+ pendingUpdatesRef.current = []
215
+ const socket = wsRef.current
216
+ if (!socket || socket.readyState !== WebSocket.OPEN || updates.length === 0) return
217
+
218
+ const enc = createEncoder()
219
+ writeVarUint(enc, MSG_SYNC)
220
+ writeVarUint(enc, MSG_SYNC_UPDATE)
221
+ writeVarUint8Array(enc, updates.length === 1 ? updates[0] : Y.mergeUpdates(updates))
222
+ socket.send(toUint8Array(enc).buffer)
223
+ }
224
+
225
+ const handler = (update: Uint8Array, origin: unknown) => {
226
+ if (origin === 'server') return
227
+ const socket = wsRef.current
228
+ if (!socket || socket.readyState !== WebSocket.OPEN) return
229
+ pendingUpdatesRef.current.push(update)
230
+ if (!updateBatchTimerRef.current) {
231
+ updateBatchTimerRef.current = setTimeout(flushPendingUpdates, UPDATE_BATCH_DELAY_MS)
232
+ }
233
+ }
234
+ doc.on('update', handler)
235
+ return () => {
236
+ doc.off('update', handler)
237
+ if (updateBatchTimerRef.current) {
238
+ clearTimeout(updateBatchTimerRef.current)
239
+ flushPendingUpdates()
240
+ }
241
+ pendingUpdatesRef.current = []
242
+ }
243
+ }, [doc])
244
+
245
+ useEffect(() => {
246
+ const handleAwarenessUpdate = ({
247
+ added,
248
+ updated,
249
+ removed,
250
+ }: {
251
+ added: number[]
252
+ updated: number[]
253
+ removed: number[]
254
+ }) => {
255
+ if (applyingRemoteAwarenessRef.current) return
256
+ const changedClients = [...added, ...updated, ...removed]
257
+ if (changedClients.length === 0) return
258
+ const socket = wsRef.current
259
+ if (!socket || socket.readyState !== WebSocket.OPEN) return
260
+ socket.send(encodeAwarenessMessage(awareness, changedClients).buffer)
261
+ }
262
+
263
+ awareness.on('update', handleAwarenessUpdate)
264
+ return () => {
265
+ awareness.off('update', handleAwarenessUpdate)
266
+ awareness.setLocalState(null)
267
+ }
268
+ }, [awareness])
269
+
270
+ const setText = useCallback(
271
+ (value: string) => {
272
+ setTextState(value)
273
+ if (!canWrite) return
274
+ isLocalRef.current = true
275
+ doc.transact(() => {
276
+ yText.delete(0, yText.length)
277
+ yText.insert(0, value)
278
+ })
279
+ isLocalRef.current = false
280
+ },
281
+ [doc, yText, canWrite],
282
+ )
283
+
284
+ return { doc, text, setText, synced, canWrite, writeAuthResolved, awareness }
285
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "id": "file-attachments",
3
+ "name": "File Attachments",
4
+ "category": "data",
5
+ "description": "File upload with inline previews for images, videos, audio, PDF, code, CSV, DOCX, XLSX",
6
+ "details": "Adds a file attachments collection backed by R2 storage with useR2Files. Includes two hooks: useMimeTypeDetection (magic-byte MIME detection + 11 file categories) and useFilePreview (centralized preview with dynamic imports for PDF.js, CodeMirror, PapaParse, docx-preview, ExcelJS). Members can upload and manage their own files, admins can manage all.",
7
+ "files": [
8
+ { "src": "src/attachments-schema.ts", "dest": "src/schemas/attachments-schema.ts" },
9
+ { "src": "src/attachments-constants.ts", "dest": "src/constants/attachments-constants.ts" },
10
+ { "src": "src/useMimeTypeDetection.ts", "dest": "src/hooks/useMimeTypeDetection.ts" },
11
+ { "src": "src/useFilePreview.ts", "dest": "src/hooks/useFilePreview.ts" },
12
+ { "src": "src/AttachmentsPage.tsx", "dest": "src/pages/attachments.tsx" }
13
+ ],
14
+ "schema": {
15
+ "exportName": "attachmentsSchema",
16
+ "importPath": "./schemas/attachments-schema",
17
+ "spreadOperator": false
18
+ },
19
+ "route": {
20
+ "path": "/attachments",
21
+ "protected": true
22
+ },
23
+ "requires": [],
24
+ "dependencies": {
25
+ "file-type": "^19.6.0",
26
+ "@uiw/react-codemirror": "^4.23.0",
27
+ "@codemirror/lang-javascript": "^6.2.2",
28
+ "@codemirror/theme-one-dark": "^6.1.2",
29
+ "papaparse": "^5.4.1",
30
+ "docx-preview": "^0.3.5",
31
+ "exceljs": "^4.4.0",
32
+ "pdfjs-dist": "^4.7.76"
33
+ },
34
+ "instructions": [],
35
+ "patterns": [
36
+ "useMimeTypeDetection() → { detectMimeType, getFileCategory, getExtension, canPreview }",
37
+ "useFilePreview({ url, fileName, mimeType }) → { category, canPreview, textContent, csvData, codeEditorKit, xlsxData, ... }",
38
+ "useR2Files() → { upload, downloadFile, readFile, deleteFile, list, getUrl, isUploading }",
39
+ "File metadata stored in collection, binary stored in R2",
40
+ "Dynamic imports: PDF.js for PDF, CodeMirror for code, PapaParse for CSV, docx-preview for DOCX, ExcelJS for XLSX"
41
+ ]
42
+ }