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,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
|
+
* (` `, `<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
|
+
}
|