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,1012 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocsEditorPage — Tiptap + Yjs collaborative document editor.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the previous contenteditable + HTML-in-Y.Text approach with
|
|
5
|
+
* ProseMirror-via-Tiptap bound to a Y.XmlFragment. Remote keystrokes are
|
|
6
|
+
* applied as granular PM transactions (no `innerHTML` swap), the local
|
|
7
|
+
* caret is migrated through concurrent edits by PM's position mapping,
|
|
8
|
+
* and remote cursors are painted by `@tiptap/extension-collaboration-caret`.
|
|
9
|
+
*
|
|
10
|
+
* Legacy docs created with the old editor stored HTML in Y.Text('content').
|
|
11
|
+
* On first open by an editor/owner we migrate that into the new XmlFragment
|
|
12
|
+
* via `editor.commands.setContent(html)` and clear the legacy field. The
|
|
13
|
+
* migration is a single Yjs transaction so all peers converge.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
17
|
+
import { useParams, useNavigate, useRouteError, isRouteErrorResponse } from 'react-router-dom'
|
|
18
|
+
import {
|
|
19
|
+
useUser,
|
|
20
|
+
useQuery,
|
|
21
|
+
useMutations,
|
|
22
|
+
usePresenceRoom,
|
|
23
|
+
getUserColor,
|
|
24
|
+
type PresencePeerClient,
|
|
25
|
+
} from 'deepspace'
|
|
26
|
+
import { ArrowLeft, AlertTriangle, List as ListIcon, RefreshCw, Share2 } from 'lucide-react'
|
|
27
|
+
import { Badge } from 'deepspace'
|
|
28
|
+
import { type Editor, useEditorState } from '@tiptap/react'
|
|
29
|
+
import { useYjsRoomWithAwareness } from './use-yjs-room-with-awareness'
|
|
30
|
+
import { useDocEditor } from './editor/useDocEditor'
|
|
31
|
+
import { DocEditorSurface, PAGE_HEIGHT_PX, PAGE_WIDTH_PX } from './editor/DocEditorSurface'
|
|
32
|
+
import { DocsTiptapToolbar } from './editor/DocsTiptapToolbar'
|
|
33
|
+
import { DocsOutlinePanel, DOCUMENT_OUTLINE_WIDTH_PX, type OutlineEntry } from './DocsOutlinePanel'
|
|
34
|
+
import { DocsPresence, type DocsPresenceParticipant } from './DocsPresence'
|
|
35
|
+
import { InviteDialog, type InviteAclDiff } from './InviteDialog'
|
|
36
|
+
import './docs-ui.css'
|
|
37
|
+
|
|
38
|
+
interface DocumentFields {
|
|
39
|
+
title: string
|
|
40
|
+
ownerId: string
|
|
41
|
+
collaborators?: string
|
|
42
|
+
editors?: string
|
|
43
|
+
folderId?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CANVAS_ZOOM_KEY = 'deepspace-docs-editor-canvas-zoom'
|
|
47
|
+
const OUTLINE_OPEN_KEY = 'deepspace-docs-editor-outline-open'
|
|
48
|
+
const KEYBOARD_ZOOM_STEP = 0.1
|
|
49
|
+
const TYPING_IDLE_MS = 1600
|
|
50
|
+
const TYPING_STALE_MS = 5000
|
|
51
|
+
/** Re-broadcast presence so clients who connect later still see existing viewers. */
|
|
52
|
+
const PRESENCE_HEARTBEAT_MS = 25_000
|
|
53
|
+
const DOC_NOT_FOUND_GRACE_MS = 450
|
|
54
|
+
|
|
55
|
+
function normalizeZoom(z: number): number {
|
|
56
|
+
return Math.min(2, Math.max(0.5, Math.round(z * 1000) / 1000))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseIdList(raw: string | undefined): string[] {
|
|
60
|
+
if (!raw) return []
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(raw)
|
|
63
|
+
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
|
|
64
|
+
} catch {
|
|
65
|
+
return []
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function peerToDocsParticipant(p: PresencePeerClient): DocsPresenceParticipant {
|
|
70
|
+
const state = p.state
|
|
71
|
+
const mode: DocsPresenceParticipant['mode'] = state.mode === 'view' ? 'view' : 'edit'
|
|
72
|
+
const lastTypedAt = typeof state.lastTypedAt === 'number' ? state.lastTypedAt : undefined
|
|
73
|
+
const participant: DocsPresenceParticipant = {
|
|
74
|
+
clientId: 0,
|
|
75
|
+
userId: p.userId,
|
|
76
|
+
name: p.userName?.trim() || p.userEmail?.trim() || 'Guest',
|
|
77
|
+
mode,
|
|
78
|
+
typing: state.typing === true,
|
|
79
|
+
isSelf: false,
|
|
80
|
+
}
|
|
81
|
+
if (p.userEmail) participant.email = p.userEmail
|
|
82
|
+
if (p.userImageUrl) participant.imageUrl = p.userImageUrl
|
|
83
|
+
if (lastTypedAt != null) participant.lastTypedAt = lastTypedAt
|
|
84
|
+
return participant
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sortDocsPresenceParticipants(
|
|
88
|
+
a: DocsPresenceParticipant,
|
|
89
|
+
b: DocsPresenceParticipant,
|
|
90
|
+
): number {
|
|
91
|
+
if (a.isSelf !== b.isSelf) return a.isSelf ? -1 : 1
|
|
92
|
+
if (a.mode !== b.mode) return a.mode === 'edit' ? -1 : 1
|
|
93
|
+
return a.name.localeCompare(b.name)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface DocsPresenceSelfUser {
|
|
97
|
+
id: string
|
|
98
|
+
name?: string | null
|
|
99
|
+
email?: string | null
|
|
100
|
+
imageUrl?: string | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildDocsPresence(
|
|
104
|
+
peers: PresencePeerClient[],
|
|
105
|
+
self: DocsPresenceSelfUser | null | undefined,
|
|
106
|
+
presenceAsEditMode: boolean,
|
|
107
|
+
): DocsPresenceParticipant[] {
|
|
108
|
+
if (!self) {
|
|
109
|
+
return [...peers.map(peerToDocsParticipant)].sort(sortDocsPresenceParticipants)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const selfRow: DocsPresenceParticipant = {
|
|
113
|
+
clientId: 0,
|
|
114
|
+
userId: self.id,
|
|
115
|
+
name: self.name?.trim() || self.email?.trim() || 'You',
|
|
116
|
+
mode: presenceAsEditMode ? 'edit' : 'view',
|
|
117
|
+
isSelf: true,
|
|
118
|
+
}
|
|
119
|
+
if (self.email) selfRow.email = self.email
|
|
120
|
+
if (self.imageUrl) selfRow.imageUrl = self.imageUrl
|
|
121
|
+
|
|
122
|
+
const others = peers.filter((p) => p.userId !== self.id).map(peerToDocsParticipant)
|
|
123
|
+
return [selfRow, ...others].sort(sortDocsPresenceParticipants)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function InlineTitle({
|
|
127
|
+
title,
|
|
128
|
+
canEdit,
|
|
129
|
+
onSave,
|
|
130
|
+
}: {
|
|
131
|
+
title: string
|
|
132
|
+
canEdit: boolean
|
|
133
|
+
onSave: (next: string) => void
|
|
134
|
+
}) {
|
|
135
|
+
const [editing, setEditing] = useState(false)
|
|
136
|
+
const [value, setValue] = useState(title)
|
|
137
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
setValue(title)
|
|
141
|
+
}, [title])
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (editing && inputRef.current) {
|
|
145
|
+
inputRef.current.focus()
|
|
146
|
+
inputRef.current.select()
|
|
147
|
+
}
|
|
148
|
+
}, [editing])
|
|
149
|
+
|
|
150
|
+
const commit = () => {
|
|
151
|
+
const t = value.trim()
|
|
152
|
+
if (t && t !== title) onSave(t)
|
|
153
|
+
else setValue(title)
|
|
154
|
+
setEditing(false)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!canEdit || !editing) {
|
|
158
|
+
return (
|
|
159
|
+
<h1
|
|
160
|
+
className="min-w-0 flex-1 cursor-text truncate rounded px-1 text-lg font-semibold tracking-tight transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
|
|
161
|
+
style={{ color: 'var(--docs-el-text)' }}
|
|
162
|
+
onClick={() => canEdit && setEditing(true)}
|
|
163
|
+
title={canEdit ? 'Click to rename' : undefined}
|
|
164
|
+
>
|
|
165
|
+
{title}
|
|
166
|
+
</h1>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<input
|
|
172
|
+
ref={inputRef}
|
|
173
|
+
value={value}
|
|
174
|
+
onChange={(e) => setValue(e.target.value)}
|
|
175
|
+
onBlur={commit}
|
|
176
|
+
onKeyDown={(e) => {
|
|
177
|
+
if (e.key === 'Enter') commit()
|
|
178
|
+
if (e.key === 'Escape') {
|
|
179
|
+
setValue(title)
|
|
180
|
+
setEditing(false)
|
|
181
|
+
}
|
|
182
|
+
}}
|
|
183
|
+
className="min-w-0 flex-1 rounded border px-2 py-0.5 text-lg font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
|
|
184
|
+
style={{
|
|
185
|
+
borderColor: 'var(--docs-el-line)',
|
|
186
|
+
backgroundColor: 'var(--docs-el-bg)',
|
|
187
|
+
color: 'var(--docs-el-text)',
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
type AccessChangeKind = 'downgrade' | 'upgrade' | 'revoked'
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Full-screen overlay shown to the second user when the owner changes their
|
|
197
|
+
* permissions mid-session. Three cases:
|
|
198
|
+
*
|
|
199
|
+
* - `downgrade`: editor → viewer. We block the editor surface so an
|
|
200
|
+
* in-flight keystroke can't slip past the role boundary while Yjs and the
|
|
201
|
+
* `documents` record settle on the new permission, and prompt the user to
|
|
202
|
+
* refresh the route. Without this, the Tiptap editor view is rebuilt with
|
|
203
|
+
* a new placeholder/extensions array (because `showReadOnlyDocUx` flips)
|
|
204
|
+
* which races a stale `requestAnimationFrame` in `DocEditorSurface` and
|
|
205
|
+
* throws `Cannot read properties of null (reading 'matchesNode')`.
|
|
206
|
+
*
|
|
207
|
+
* - `upgrade`: viewer → editor. Same editor-rebuild path on the local peer
|
|
208
|
+
* (the placeholder string flips the other way), so a refresh gives the
|
|
209
|
+
* user a clean Tiptap mount with editing enabled and no risk of the
|
|
210
|
+
* stale-rAF crash in the rebuild.
|
|
211
|
+
*
|
|
212
|
+
* - `revoked`: the owner removed this peer from `collaborators`/`editors`
|
|
213
|
+
* entirely. The Yjs server-side auth cache plus the locally-cached
|
|
214
|
+
* `documents` row let the peer keep typing for a few seconds until they
|
|
215
|
+
* reconnect; the overlay locks the UI immediately so no further edits are
|
|
216
|
+
* attempted, and tells the user to refresh to leave.
|
|
217
|
+
*/
|
|
218
|
+
function AccessChangedOverlay({
|
|
219
|
+
kind,
|
|
220
|
+
onRefresh,
|
|
221
|
+
}: {
|
|
222
|
+
kind: AccessChangeKind
|
|
223
|
+
onRefresh: () => void
|
|
224
|
+
}) {
|
|
225
|
+
const title =
|
|
226
|
+
kind === 'revoked'
|
|
227
|
+
? 'Your access has been removed'
|
|
228
|
+
: kind === 'upgrade'
|
|
229
|
+
? 'You can now edit this document'
|
|
230
|
+
: "You're now view-only"
|
|
231
|
+
const body =
|
|
232
|
+
kind === 'revoked'
|
|
233
|
+
? 'The owner has removed your access to this document. Refresh to continue.'
|
|
234
|
+
: kind === 'upgrade'
|
|
235
|
+
? 'The owner gave you editor access. Refresh to reload the document with editing enabled.'
|
|
236
|
+
: 'The owner changed your access to view-only. Refresh to reload the document.'
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div
|
|
240
|
+
role="alertdialog"
|
|
241
|
+
aria-modal="true"
|
|
242
|
+
aria-labelledby="docs-access-change-title"
|
|
243
|
+
className="absolute inset-0 z-[80] flex items-center justify-center px-4"
|
|
244
|
+
style={{ backgroundColor: 'color-mix(in srgb, var(--docs-el-bg) 78%, transparent)' }}
|
|
245
|
+
>
|
|
246
|
+
<div
|
|
247
|
+
className="w-full max-w-sm rounded-xl border p-6 shadow-xl backdrop-blur"
|
|
248
|
+
style={{
|
|
249
|
+
borderColor: 'var(--docs-el-line)',
|
|
250
|
+
backgroundColor: 'var(--docs-el-surface)',
|
|
251
|
+
color: 'var(--docs-el-text)',
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<div className="flex items-start gap-3">
|
|
255
|
+
<span
|
|
256
|
+
className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
|
257
|
+
style={{
|
|
258
|
+
backgroundColor: 'color-mix(in srgb, var(--docs-el-accent) 14%, transparent)',
|
|
259
|
+
color: 'var(--docs-el-accent)',
|
|
260
|
+
}}
|
|
261
|
+
aria-hidden
|
|
262
|
+
>
|
|
263
|
+
<AlertTriangle className="h-4 w-4" strokeWidth={2} />
|
|
264
|
+
</span>
|
|
265
|
+
<div className="min-w-0 flex-1">
|
|
266
|
+
<h2 id="docs-access-change-title" className="text-base font-semibold tracking-tight">
|
|
267
|
+
{title}
|
|
268
|
+
</h2>
|
|
269
|
+
<p className="mt-1.5 text-sm" style={{ color: 'var(--docs-el-muted)' }}>
|
|
270
|
+
{body}
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div className="mt-5 flex justify-end">
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
autoFocus
|
|
278
|
+
onClick={onRefresh}
|
|
279
|
+
data-testid="docs-access-refresh"
|
|
280
|
+
className="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
|
281
|
+
style={{ backgroundColor: 'var(--docs-el-accent)' }}
|
|
282
|
+
>
|
|
283
|
+
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2.25} />
|
|
284
|
+
Refresh
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Rendered while we don't yet have a Tiptap editor to mount (initial mount,
|
|
294
|
+
* docId switch, or while Yjs is still syncing). Crucially this mirrors the
|
|
295
|
+
* exact geometry of {@link DocEditorSurface} — same canvas, same paged
|
|
296
|
+
* page-frame, same blank paper — so when the real surface takes over the
|
|
297
|
+
* swap is invisible. No `animate-pulse` skeleton bars: those used to flash
|
|
298
|
+
* for a single frame on fast loads, which read as a "flicker".
|
|
299
|
+
*/
|
|
300
|
+
function DocsEditorLoadingSurface({ label = 'Opening document...' }: { label?: string }) {
|
|
301
|
+
return (
|
|
302
|
+
<div className="docs-paged-editor-canvas h-full min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain print:block print:!h-auto print:!overflow-visible print:!bg-white">
|
|
303
|
+
<div className="docs-paged-inner pointer-events-auto mx-auto w-full max-w-full px-2 py-3 sm:px-3 sm:py-4 md:py-5">
|
|
304
|
+
<div
|
|
305
|
+
className="docs-paged-page-frame relative mx-auto"
|
|
306
|
+
style={{ width: `${PAGE_WIDTH_PX / 96}in`, maxWidth: '100%' }}
|
|
307
|
+
aria-busy="true"
|
|
308
|
+
aria-label={label}
|
|
309
|
+
>
|
|
310
|
+
<div
|
|
311
|
+
className="docs-paged-page-stack relative"
|
|
312
|
+
style={{ minHeight: `${PAGE_HEIGHT_PX}px` }}
|
|
313
|
+
>
|
|
314
|
+
<div className="docs-paged-page-face docs-editor-paper pointer-events-none absolute left-0 top-0 h-[11in] w-full rounded-sm print:static print:h-auto print:shadow-none print:border-0" />
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Walk the ProseMirror doc once per editor update, returning headings.
|
|
324
|
+
* Cheap (linear in node count) and keeps the outline panel in lockstep
|
|
325
|
+
* with what's actually rendered.
|
|
326
|
+
*/
|
|
327
|
+
function useOutlineEntries(editor: Editor | null): OutlineEntry[] {
|
|
328
|
+
return (
|
|
329
|
+
useEditorState({
|
|
330
|
+
editor,
|
|
331
|
+
selector: ({ editor: e }) => {
|
|
332
|
+
// Same defensive checks as the toolbar selector — `e.state` is null
|
|
333
|
+
// when the underlying ProseMirror view has been torn down.
|
|
334
|
+
if (!e || e.isDestroyed || !e.view) return [] as OutlineEntry[]
|
|
335
|
+
try {
|
|
336
|
+
const out: OutlineEntry[] = []
|
|
337
|
+
e.state.doc.descendants((node, pos) => {
|
|
338
|
+
if (node.type.name === 'heading') {
|
|
339
|
+
const level = (node.attrs.level as number) ?? 1
|
|
340
|
+
const title = node.textContent.trim()
|
|
341
|
+
if (title) out.push({ level, title, pos })
|
|
342
|
+
return false
|
|
343
|
+
}
|
|
344
|
+
return true
|
|
345
|
+
})
|
|
346
|
+
return out
|
|
347
|
+
} catch {
|
|
348
|
+
return [] as OutlineEntry[]
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}) ?? []
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export default function DocsEditorPage() {
|
|
356
|
+
const { docId } = useParams<{ docId: string }>()
|
|
357
|
+
const navigate = useNavigate()
|
|
358
|
+
const { user } = useUser()
|
|
359
|
+
|
|
360
|
+
const { records: documents, status } = useQuery<DocumentFields>('documents', {
|
|
361
|
+
orderBy: 'createdAt',
|
|
362
|
+
orderDir: 'desc',
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const selectedDoc = useMemo(
|
|
366
|
+
() => (docId ? documents.find((d) => d.recordId === docId) : null),
|
|
367
|
+
[documents, docId],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
const { put } = useMutations<DocumentFields>('documents')
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `'content'` is kept as the field name only so legacy-doc migration can
|
|
374
|
+
* read the old HTML out of Y.Text('content') below. The Tiptap editor
|
|
375
|
+
* itself binds to a separate Y.XmlFragment('default') inside the same
|
|
376
|
+
* Y.Doc — they don't collide.
|
|
377
|
+
*/
|
|
378
|
+
const {
|
|
379
|
+
doc,
|
|
380
|
+
text: legacyText,
|
|
381
|
+
synced,
|
|
382
|
+
canWrite,
|
|
383
|
+
writeAuthResolved,
|
|
384
|
+
awareness,
|
|
385
|
+
} = useYjsRoomWithAwareness(docId ?? 'noop', 'content')
|
|
386
|
+
|
|
387
|
+
const presenceScopeId = docId ? `doc:${docId}` : '_'
|
|
388
|
+
const {
|
|
389
|
+
peers: presencePeers,
|
|
390
|
+
connected: presenceConnected,
|
|
391
|
+
updateState: updatePresenceState,
|
|
392
|
+
} = usePresenceRoom(presenceScopeId)
|
|
393
|
+
|
|
394
|
+
const [inviteOpen, setInviteOpen] = useState(false)
|
|
395
|
+
const typingRef = useRef(false)
|
|
396
|
+
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
397
|
+
|
|
398
|
+
const [canvasZoom, setCanvasZoom] = useState(() => {
|
|
399
|
+
if (typeof window === 'undefined') return 1
|
|
400
|
+
try {
|
|
401
|
+
const raw = sessionStorage.getItem(CANVAS_ZOOM_KEY)
|
|
402
|
+
const v = raw == null ? 1 : Number.parseFloat(raw)
|
|
403
|
+
return normalizeZoom(Number.isFinite(v) ? v : 1)
|
|
404
|
+
} catch {
|
|
405
|
+
return 1
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
const [outlineOpen, setOutlineOpen] = useState(() => {
|
|
410
|
+
if (typeof window === 'undefined') return true
|
|
411
|
+
try {
|
|
412
|
+
return window.localStorage.getItem(OUTLINE_OPEN_KEY) !== '0'
|
|
413
|
+
} catch {
|
|
414
|
+
return true
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
try {
|
|
420
|
+
sessionStorage.setItem(CANVAS_ZOOM_KEY, String(canvasZoom))
|
|
421
|
+
} catch {
|
|
422
|
+
/* ignore */
|
|
423
|
+
}
|
|
424
|
+
}, [canvasZoom])
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
428
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return
|
|
429
|
+
const key = event.key
|
|
430
|
+
const isZoomIn = key === '+' || key === '='
|
|
431
|
+
const isZoomOut = key === '-' || key === '_'
|
|
432
|
+
const isReset = key === '0'
|
|
433
|
+
if (!isZoomIn && !isZoomOut && !isReset) return
|
|
434
|
+
|
|
435
|
+
event.preventDefault()
|
|
436
|
+
event.stopPropagation()
|
|
437
|
+
setCanvasZoom((current) => {
|
|
438
|
+
if (isReset) return 1
|
|
439
|
+
return normalizeZoom(current + (isZoomIn ? KEYBOARD_ZOOM_STEP : -KEYBOARD_ZOOM_STEP))
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
443
|
+
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
444
|
+
}, [])
|
|
445
|
+
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
try {
|
|
448
|
+
window.localStorage.setItem(OUTLINE_OPEN_KEY, outlineOpen ? '1' : '0')
|
|
449
|
+
} catch {
|
|
450
|
+
/* ignore */
|
|
451
|
+
}
|
|
452
|
+
}, [outlineOpen])
|
|
453
|
+
|
|
454
|
+
// Permissions ----------------------------------------------------------------
|
|
455
|
+
const isOwner = selectedDoc?.data.ownerId === user?.id
|
|
456
|
+
const collaboratorIds = useMemo(
|
|
457
|
+
() => parseIdList(selectedDoc?.data.collaborators),
|
|
458
|
+
[selectedDoc?.data.collaborators],
|
|
459
|
+
)
|
|
460
|
+
const editorIds = useMemo(
|
|
461
|
+
() => parseIdList(selectedDoc?.data.editors),
|
|
462
|
+
[selectedDoc?.data.editors],
|
|
463
|
+
)
|
|
464
|
+
const effectiveRole: 'owner' | 'editor' | 'viewer' | 'none' = !user
|
|
465
|
+
? 'none'
|
|
466
|
+
: isOwner
|
|
467
|
+
? 'owner'
|
|
468
|
+
: editorIds.includes(user.id)
|
|
469
|
+
? 'editor'
|
|
470
|
+
: collaboratorIds.includes(user.id)
|
|
471
|
+
? 'viewer'
|
|
472
|
+
: 'none'
|
|
473
|
+
const policyCanEdit = effectiveRole === 'owner' || effectiveRole === 'editor'
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Snapshot of the *first* concrete role this peer saw for this `docId`,
|
|
477
|
+
* plus the wall-clock instant when we captured it. We can't trust
|
|
478
|
+
* `effectiveRole` alone to detect "the owner just removed me" because
|
|
479
|
+
* role transitions (none → editor on initial fetch, editor → viewer on
|
|
480
|
+
* demote, viewer → none on remove) all flow through the same `none`
|
|
481
|
+
* value before/after `selectedDoc` resolves. The role tells us
|
|
482
|
+
* downgrade/upgrade vs. still-loading; the timestamp lets us ignore
|
|
483
|
+
* stale `aclSignal` presence updates from before we joined.
|
|
484
|
+
*/
|
|
485
|
+
const [initialRole, setInitialRole] = useState<typeof effectiveRole | null>(null)
|
|
486
|
+
const sessionStartedAtRef = useRef<number | null>(null)
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
setInitialRole(null)
|
|
489
|
+
sessionStartedAtRef.current = null
|
|
490
|
+
}, [docId])
|
|
491
|
+
useEffect(() => {
|
|
492
|
+
if (initialRole) return
|
|
493
|
+
if (!selectedDoc || !user) return
|
|
494
|
+
if (effectiveRole === 'none') return
|
|
495
|
+
setInitialRole(effectiveRole)
|
|
496
|
+
sessionStartedAtRef.current = Date.now()
|
|
497
|
+
}, [initialRole, selectedDoc, user, effectiveRole])
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Latched permission-change event delivered over presence. The owner
|
|
501
|
+
* publishes an `aclSignal` payload after every InviteDialog save; peers
|
|
502
|
+
* watch the owner's presence state and freeze the first signal addressed
|
|
503
|
+
* to them. This is the only mechanism that reaches a *removed* user,
|
|
504
|
+
* since the docs schema's `read: 'collaborator'` rule means the
|
|
505
|
+
* documents-record update with the new collaborators list is filtered
|
|
506
|
+
* out of their RecordRoom subscription — the kicked client would
|
|
507
|
+
* otherwise keep its stale role indefinitely and let the user keep
|
|
508
|
+
* typing until they refresh.
|
|
509
|
+
*/
|
|
510
|
+
type DetectedAclEvent = { kind: AccessChangeKind; at: number }
|
|
511
|
+
const [detectedAclEvent, setDetectedAclEvent] = useState<DetectedAclEvent | null>(null)
|
|
512
|
+
useEffect(() => {
|
|
513
|
+
setDetectedAclEvent(null)
|
|
514
|
+
}, [docId])
|
|
515
|
+
|
|
516
|
+
const accessChangeKind: AccessChangeKind | null = useMemo(() => {
|
|
517
|
+
// Latched presence-derived revoke always wins — it's the only signal
|
|
518
|
+
// that reaches a peer whose record subscription was filtered out.
|
|
519
|
+
if (detectedAclEvent?.kind === 'revoked') return 'revoked'
|
|
520
|
+
|
|
521
|
+
// Local-record-derived transitions cover the cases where the
|
|
522
|
+
// documents record still propagates (downgrade / upgrade). They
|
|
523
|
+
// need an initial concrete role to compare against; without one
|
|
524
|
+
// we fall through to whatever presence latched.
|
|
525
|
+
if (initialRole && initialRole !== 'owner') {
|
|
526
|
+
if (effectiveRole === 'none') return 'revoked'
|
|
527
|
+
if (initialRole === 'editor' && effectiveRole === 'viewer') return 'downgrade'
|
|
528
|
+
if (initialRole === 'viewer' && effectiveRole === 'editor') return 'upgrade'
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return detectedAclEvent?.kind ?? null
|
|
532
|
+
}, [initialRole, effectiveRole, detectedAclEvent])
|
|
533
|
+
const accessLocked = accessChangeKind !== null
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Once the owner revokes/downgrades, we hard-stop further writes from this
|
|
537
|
+
* peer even if Yjs/server auth hasn't caught up yet. Combined with the
|
|
538
|
+
* blocking overlay below, this prevents the "removed user can still type
|
|
539
|
+
* until refresh" window described in the report.
|
|
540
|
+
*/
|
|
541
|
+
const writesLockedByAcl = accessLocked && accessChangeKind !== 'upgrade'
|
|
542
|
+
const effectiveCanWrite = canWrite && policyCanEdit && !writesLockedByAcl
|
|
543
|
+
/** Collaborator viewers always see read-only chrome; editors/owners only after Yjs auth resolves and denies write. */
|
|
544
|
+
const showReadOnlyDocUx =
|
|
545
|
+
effectiveRole === 'viewer' || (policyCanEdit && writeAuthResolved && !canWrite)
|
|
546
|
+
const presenceAsEditMode =
|
|
547
|
+
policyCanEdit && !(writeAuthResolved && !canWrite) && !writesLockedByAcl
|
|
548
|
+
|
|
549
|
+
// Tiptap editor --------------------------------------------------------------
|
|
550
|
+
const userName = user?.name?.trim() || user?.email?.trim() || 'Guest'
|
|
551
|
+
const userColor = useMemo(() => getUserColor(user?.id ?? 'anon'), [user?.id])
|
|
552
|
+
const editor = useDocEditor({
|
|
553
|
+
doc,
|
|
554
|
+
awareness,
|
|
555
|
+
userName,
|
|
556
|
+
userColor,
|
|
557
|
+
synced,
|
|
558
|
+
canWrite: effectiveCanWrite,
|
|
559
|
+
placeholder: showReadOnlyDocUx ? 'View only' : 'Start typing — toolbar above for formatting…',
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
// Legacy migration: if doc.getXmlFragment('default') is empty but the
|
|
563
|
+
// legacy Y.Text('content') field has HTML, parse it once into Tiptap.
|
|
564
|
+
// Only the first writer to open does this; viewers never see partial state
|
|
565
|
+
// because both the wipe and the parse run inside a single Yjs transaction.
|
|
566
|
+
const migratedRef = useRef(false)
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
if (migratedRef.current) return
|
|
569
|
+
if (!editor || !synced || !effectiveCanWrite) return
|
|
570
|
+
const fragment = doc.getXmlFragment('default')
|
|
571
|
+
if (fragment.length > 0) {
|
|
572
|
+
migratedRef.current = true
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
const yText = doc.getText('content')
|
|
576
|
+
const legacy = (legacyText ?? yText.toString()).trim()
|
|
577
|
+
if (!legacy) {
|
|
578
|
+
migratedRef.current = true
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
migratedRef.current = true
|
|
582
|
+
/**
|
|
583
|
+
* Tiptap parses the HTML against its schema, then writes the resulting
|
|
584
|
+
* nodes into the bound Y.XmlFragment. We then clear the legacy field
|
|
585
|
+
* inside the same transaction so peers see one atomic step.
|
|
586
|
+
*/
|
|
587
|
+
doc.transact(() => {
|
|
588
|
+
editor.commands.setContent(legacy, { emitUpdate: true })
|
|
589
|
+
if (yText.length > 0) yText.delete(0, yText.length)
|
|
590
|
+
})
|
|
591
|
+
}, [editor, synced, effectiveCanWrite, doc, legacyText])
|
|
592
|
+
|
|
593
|
+
// Presence -------------------------------------------------------------------
|
|
594
|
+
const presenceParticipants = useMemo(
|
|
595
|
+
() => buildDocsPresence(presencePeers, user ?? undefined, presenceAsEditMode),
|
|
596
|
+
[presencePeers, user, presenceAsEditMode],
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
const publishPresence = useCallback(
|
|
600
|
+
(typingFlag: boolean) => {
|
|
601
|
+
if (!selectedDoc || !user || !synced) return
|
|
602
|
+
updatePresenceState({
|
|
603
|
+
mode: presenceAsEditMode ? 'edit' : 'view',
|
|
604
|
+
typing: typingFlag,
|
|
605
|
+
...(typingFlag ? { lastTypedAt: Date.now() } : {}),
|
|
606
|
+
})
|
|
607
|
+
},
|
|
608
|
+
[presenceAsEditMode, selectedDoc, synced, updatePresenceState, user],
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Permission-change fan-out via presence.
|
|
613
|
+
*
|
|
614
|
+
* The owner publishes a one-shot `aclSignal` payload after every
|
|
615
|
+
* InviteDialog save. The PresenceRoom server merges incoming state into
|
|
616
|
+
* each peer's record, so this field rides alongside the existing
|
|
617
|
+
* `mode`/`typing`/`lastTypedAt` fields without clobbering them. We
|
|
618
|
+
* intentionally route through presence rather than the documents record
|
|
619
|
+
* because the docs schema's `read: 'collaborator'` rule prevents a
|
|
620
|
+
* just-removed user from seeing the new record state — presence is the
|
|
621
|
+
* only channel still wired to them at the moment of revocation.
|
|
622
|
+
*/
|
|
623
|
+
const handleAclChange = useCallback(
|
|
624
|
+
(diff: InviteAclDiff) => {
|
|
625
|
+
if (!isOwner) return
|
|
626
|
+
updatePresenceState({
|
|
627
|
+
aclSignal: {
|
|
628
|
+
at: Date.now(),
|
|
629
|
+
removed: diff.removedUserIds,
|
|
630
|
+
demoted: diff.demotedUserIds,
|
|
631
|
+
promoted: diff.promotedUserIds,
|
|
632
|
+
},
|
|
633
|
+
})
|
|
634
|
+
},
|
|
635
|
+
[isOwner, updatePresenceState],
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Latch the first relevant `aclSignal` we see from the owner's presence
|
|
640
|
+
* peer. We compare its `at` against `sessionStartedAtRef` so refreshing
|
|
641
|
+
* into a doc whose owner already published a signal doesn't immediately
|
|
642
|
+
* trip the overlay — only signals emitted *after* this session began
|
|
643
|
+
* count. Once latched we keep the value: even if the owner disconnects
|
|
644
|
+
* and their presence peer vanishes, the overlay must persist.
|
|
645
|
+
*/
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
if (!user || !selectedDoc) return
|
|
648
|
+
if (detectedAclEvent) return
|
|
649
|
+
const start = sessionStartedAtRef.current
|
|
650
|
+
if (start == null) return
|
|
651
|
+
const ownerId = selectedDoc.data.ownerId
|
|
652
|
+
if (user.id === ownerId) return
|
|
653
|
+
const ownerPeer = presencePeers.find((p) => p.userId === ownerId)
|
|
654
|
+
if (!ownerPeer) return
|
|
655
|
+
|
|
656
|
+
const rawSignal = (ownerPeer.state as Record<string, unknown>).aclSignal
|
|
657
|
+
if (!rawSignal || typeof rawSignal !== 'object') return
|
|
658
|
+
const signal = rawSignal as {
|
|
659
|
+
at?: unknown
|
|
660
|
+
removed?: unknown
|
|
661
|
+
demoted?: unknown
|
|
662
|
+
promoted?: unknown
|
|
663
|
+
}
|
|
664
|
+
if (typeof signal.at !== 'number' || signal.at <= start) return
|
|
665
|
+
|
|
666
|
+
const includes = (list: unknown): boolean =>
|
|
667
|
+
Array.isArray(list) && list.some((id) => id === user.id)
|
|
668
|
+
|
|
669
|
+
if (includes(signal.removed)) {
|
|
670
|
+
setDetectedAclEvent({ kind: 'revoked', at: signal.at })
|
|
671
|
+
} else if (includes(signal.demoted)) {
|
|
672
|
+
setDetectedAclEvent({ kind: 'downgrade', at: signal.at })
|
|
673
|
+
} else if (includes(signal.promoted)) {
|
|
674
|
+
setDetectedAclEvent({ kind: 'upgrade', at: signal.at })
|
|
675
|
+
}
|
|
676
|
+
}, [presencePeers, user, selectedDoc, detectedAclEvent])
|
|
677
|
+
|
|
678
|
+
// Typing flag — driven by Tiptap's `update` event (debounced idle reset).
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
if (!editor) return
|
|
681
|
+
const onUpdate = () => {
|
|
682
|
+
if (!effectiveCanWrite) return
|
|
683
|
+
typingRef.current = true
|
|
684
|
+
publishPresence(true)
|
|
685
|
+
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
|
|
686
|
+
typingTimeoutRef.current = setTimeout(() => {
|
|
687
|
+
typingRef.current = false
|
|
688
|
+
publishPresence(false)
|
|
689
|
+
}, TYPING_IDLE_MS)
|
|
690
|
+
}
|
|
691
|
+
editor.on('update', onUpdate)
|
|
692
|
+
return () => {
|
|
693
|
+
editor.off('update', onUpdate)
|
|
694
|
+
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
|
|
695
|
+
}
|
|
696
|
+
}, [editor, effectiveCanWrite, publishPresence])
|
|
697
|
+
|
|
698
|
+
// Initial presence + heartbeat so late joiners still see existing viewers.
|
|
699
|
+
useEffect(() => {
|
|
700
|
+
publishPresence(typingRef.current)
|
|
701
|
+
}, [publishPresence, presenceConnected])
|
|
702
|
+
|
|
703
|
+
useEffect(() => {
|
|
704
|
+
if (!synced || !selectedDoc || !user) return
|
|
705
|
+
const id = window.setInterval(() => publishPresence(typingRef.current), PRESENCE_HEARTBEAT_MS)
|
|
706
|
+
return () => clearInterval(id)
|
|
707
|
+
}, [synced, selectedDoc, user, publishPresence])
|
|
708
|
+
|
|
709
|
+
// Typing names from presence (stale-aware).
|
|
710
|
+
const typingNames = useMemo(() => {
|
|
711
|
+
const names: string[] = []
|
|
712
|
+
const seen = new Set<string>()
|
|
713
|
+
for (const p of presenceParticipants) {
|
|
714
|
+
if (p.isSelf || !p.typing) continue
|
|
715
|
+
if (p.lastTypedAt != null && Date.now() - p.lastTypedAt >= TYPING_STALE_MS) continue
|
|
716
|
+
if (seen.has(p.name)) continue
|
|
717
|
+
seen.add(p.name)
|
|
718
|
+
names.push(p.name)
|
|
719
|
+
}
|
|
720
|
+
return names
|
|
721
|
+
}, [presenceParticipants])
|
|
722
|
+
|
|
723
|
+
// Outline --------------------------------------------------------------------
|
|
724
|
+
const outlineEntries = useOutlineEntries(editor)
|
|
725
|
+
const jumpToHeading = useCallback(
|
|
726
|
+
(pos: number) => {
|
|
727
|
+
if (!editor) return
|
|
728
|
+
editor.chain().focus().setTextSelection(pos).scrollIntoView().run()
|
|
729
|
+
},
|
|
730
|
+
[editor],
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
// Title save -----------------------------------------------------------------
|
|
734
|
+
const docTitle = selectedDoc?.data.title?.trim() || 'Untitled Document'
|
|
735
|
+
const handleTitleSave = useCallback(
|
|
736
|
+
async (next: string) => {
|
|
737
|
+
if (!selectedDoc) return
|
|
738
|
+
await put(selectedDoc.recordId, { ...selectedDoc.data, title: next }).catch(() => {})
|
|
739
|
+
},
|
|
740
|
+
[put, selectedDoc],
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Loading / not-found are intentionally NOT alternative early-return
|
|
745
|
+
* layouts — swapping the whole page between a skeleton chrome and the real
|
|
746
|
+
* chrome produced a visible flash on create/open. We render one full layout
|
|
747
|
+
* and swap only the editor area for not-found vs paper vs live ProseMirror.
|
|
748
|
+
* The Tiptap instance mounts as soon as it is ready (before Yjs `synced`);
|
|
749
|
+
* the header, toolbar shell, and paper stay stable while the connection
|
|
750
|
+
* finishes and document content hydrates from the room.
|
|
751
|
+
*/
|
|
752
|
+
const isLoading = status === 'loading'
|
|
753
|
+
const [showNotFound, setShowNotFound] = useState(false)
|
|
754
|
+
|
|
755
|
+
useEffect(() => {
|
|
756
|
+
if (isLoading || selectedDoc) {
|
|
757
|
+
setShowNotFound(false)
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const id = window.setTimeout(() => setShowNotFound(true), DOC_NOT_FOUND_GRACE_MS)
|
|
762
|
+
return () => window.clearTimeout(id)
|
|
763
|
+
}, [docId, isLoading, selectedDoc])
|
|
764
|
+
|
|
765
|
+
const docResolutionPending = isLoading || (!selectedDoc && !showNotFound)
|
|
766
|
+
const docMissing = !isLoading && !selectedDoc && showNotFound
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<div
|
|
770
|
+
className="docs-feature-scope flex h-full flex-col overflow-hidden print:h-auto print:overflow-visible"
|
|
771
|
+
style={{ backgroundColor: 'var(--docs-el-bg)', color: 'var(--docs-el-text)' }}
|
|
772
|
+
>
|
|
773
|
+
<header
|
|
774
|
+
className="relative z-10 flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-3 backdrop-blur-sm print:hidden sm:gap-3 sm:px-4"
|
|
775
|
+
style={{
|
|
776
|
+
borderColor: 'var(--docs-el-line)',
|
|
777
|
+
backgroundColor: 'color-mix(in srgb, var(--docs-el-surface) 92%, transparent)',
|
|
778
|
+
}}
|
|
779
|
+
>
|
|
780
|
+
<button
|
|
781
|
+
type="button"
|
|
782
|
+
onClick={() => navigate('/docs')}
|
|
783
|
+
className="rounded-lg p-1.5 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
|
|
784
|
+
style={{ color: 'var(--docs-el-muted)' }}
|
|
785
|
+
title="Back to documents"
|
|
786
|
+
>
|
|
787
|
+
<ArrowLeft className="h-4 w-4" strokeWidth={2} />
|
|
788
|
+
</button>
|
|
789
|
+
|
|
790
|
+
<InlineTitle title={docTitle} canEdit={isOwner ?? false} onSave={handleTitleSave} />
|
|
791
|
+
|
|
792
|
+
<DocsPresence participants={presenceParticipants} typingNames={typingNames} />
|
|
793
|
+
|
|
794
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
795
|
+
{isOwner ? (
|
|
796
|
+
<button
|
|
797
|
+
type="button"
|
|
798
|
+
onClick={() => setInviteOpen(true)}
|
|
799
|
+
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
|
|
800
|
+
style={{
|
|
801
|
+
borderColor: 'var(--docs-el-line)',
|
|
802
|
+
color: 'var(--docs-el-muted)',
|
|
803
|
+
backgroundColor: 'transparent',
|
|
804
|
+
}}
|
|
805
|
+
title="Share this document"
|
|
806
|
+
>
|
|
807
|
+
<Share2 className="h-3.5 w-3.5" />
|
|
808
|
+
Share
|
|
809
|
+
</button>
|
|
810
|
+
) : effectiveRole === 'editor' || effectiveRole === 'viewer' ? (
|
|
811
|
+
<span
|
|
812
|
+
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
|
|
813
|
+
style={{ borderColor: 'var(--docs-el-line)', color: 'var(--docs-el-muted)' }}
|
|
814
|
+
>
|
|
815
|
+
{effectiveRole === 'editor' ? 'Shared editor' : 'Shared viewer'}
|
|
816
|
+
</span>
|
|
817
|
+
) : null}
|
|
818
|
+
{synced ? (
|
|
819
|
+
<span
|
|
820
|
+
className="flex items-center gap-1.5 text-xs tabular-nums"
|
|
821
|
+
style={{ color: 'var(--docs-el-muted)' }}
|
|
822
|
+
>
|
|
823
|
+
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden />
|
|
824
|
+
Synced
|
|
825
|
+
</span>
|
|
826
|
+
) : (
|
|
827
|
+
<span
|
|
828
|
+
className="flex items-center gap-1.5 text-xs tabular-nums"
|
|
829
|
+
style={{ color: 'var(--docs-el-muted)' }}
|
|
830
|
+
>
|
|
831
|
+
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" aria-hidden />
|
|
832
|
+
Connecting…
|
|
833
|
+
</span>
|
|
834
|
+
)}
|
|
835
|
+
{effectiveRole === 'owner' ? (
|
|
836
|
+
<Badge variant="success">Owner</Badge>
|
|
837
|
+
) : effectiveRole === 'editor' ? (
|
|
838
|
+
<Badge variant="success">Editor</Badge>
|
|
839
|
+
) : effectiveRole === 'viewer' ? (
|
|
840
|
+
<Badge variant="secondary">Viewer</Badge>
|
|
841
|
+
) : effectiveCanWrite ? (
|
|
842
|
+
<Badge variant="success">Edit</Badge>
|
|
843
|
+
) : (
|
|
844
|
+
<Badge variant="secondary">View</Badge>
|
|
845
|
+
)}
|
|
846
|
+
</div>
|
|
847
|
+
</header>
|
|
848
|
+
|
|
849
|
+
{showReadOnlyDocUx ? (
|
|
850
|
+
<div
|
|
851
|
+
className="relative z-10 flex shrink-0 items-center gap-2 border-b px-4 py-2 text-sm print:hidden"
|
|
852
|
+
style={{
|
|
853
|
+
borderColor: 'var(--docs-el-line)',
|
|
854
|
+
backgroundColor: 'color-mix(in srgb, var(--docs-el-muted) 12%, transparent)',
|
|
855
|
+
color: 'var(--docs-el-muted)',
|
|
856
|
+
}}
|
|
857
|
+
>
|
|
858
|
+
{effectiveRole === 'viewer'
|
|
859
|
+
? 'You have view-only access to this document. Ask the owner for editor access.'
|
|
860
|
+
: 'You are viewing this document in read-only mode.'}
|
|
861
|
+
</div>
|
|
862
|
+
) : null}
|
|
863
|
+
|
|
864
|
+
<DocsTiptapToolbar
|
|
865
|
+
editor={editor}
|
|
866
|
+
disabled={!synced || !effectiveCanWrite}
|
|
867
|
+
canvasZoom={canvasZoom}
|
|
868
|
+
onCanvasZoomChange={(z) => setCanvasZoom(normalizeZoom(z))}
|
|
869
|
+
/>
|
|
870
|
+
|
|
871
|
+
<div className="relative flex min-h-0 flex-1 flex-col print:block">
|
|
872
|
+
{outlineOpen ? (
|
|
873
|
+
<DocsOutlinePanel entries={outlineEntries} onJumpTo={jumpToHeading} />
|
|
874
|
+
) : null}
|
|
875
|
+
|
|
876
|
+
<button
|
|
877
|
+
type="button"
|
|
878
|
+
data-testid="toggle-outline"
|
|
879
|
+
onClick={() => setOutlineOpen((o) => !o)}
|
|
880
|
+
title={outlineOpen ? 'Hide document outline' : 'Show document outline'}
|
|
881
|
+
style={{
|
|
882
|
+
top: 12,
|
|
883
|
+
left: outlineOpen ? DOCUMENT_OUTLINE_WIDTH_PX + 10 : 12,
|
|
884
|
+
borderColor: 'color-mix(in srgb, var(--docs-el-line) 85%, transparent)',
|
|
885
|
+
backgroundColor: 'color-mix(in srgb, var(--docs-el-surface) 92%, transparent)',
|
|
886
|
+
color: 'var(--docs-el-muted)',
|
|
887
|
+
}}
|
|
888
|
+
className={`absolute z-[36] flex h-9 w-9 items-center justify-center rounded-full border shadow-md backdrop-blur-md transition-[left,box-shadow,color] duration-200 ease-out hover:bg-black/[0.06] hover:text-[color:var(--docs-el-text)] hover:shadow-lg print:hidden dark:border-white/12 dark:hover:bg-white/[0.08] ${outlineOpen ? 'text-[color:var(--docs-el-text)]' : ''}`}
|
|
889
|
+
>
|
|
890
|
+
<ListIcon className="h-4 w-4" strokeWidth={2} />
|
|
891
|
+
</button>
|
|
892
|
+
|
|
893
|
+
<div
|
|
894
|
+
data-testid="doc-canvas-zoom"
|
|
895
|
+
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden print:[zoom:1]"
|
|
896
|
+
style={{
|
|
897
|
+
zoom: canvasZoom,
|
|
898
|
+
paddingLeft: outlineOpen ? DOCUMENT_OUTLINE_WIDTH_PX : 0,
|
|
899
|
+
}}
|
|
900
|
+
>
|
|
901
|
+
{docMissing ? (
|
|
902
|
+
<div className="docs-paged-editor-canvas flex h-full min-h-0 w-full flex-1 flex-col items-center justify-center gap-4 px-6">
|
|
903
|
+
<p className="text-sm" style={{ color: 'var(--docs-el-muted)' }}>
|
|
904
|
+
This document is private. Ask the owner for an invite.
|
|
905
|
+
</p>
|
|
906
|
+
<button
|
|
907
|
+
type="button"
|
|
908
|
+
onClick={() => navigate('/docs')}
|
|
909
|
+
className="rounded-lg px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
|
910
|
+
style={{ backgroundColor: 'var(--docs-el-accent)' }}
|
|
911
|
+
>
|
|
912
|
+
Back to documents
|
|
913
|
+
</button>
|
|
914
|
+
</div>
|
|
915
|
+
) : editor && !docResolutionPending ? (
|
|
916
|
+
<DocEditorSurface editor={editor} />
|
|
917
|
+
) : (
|
|
918
|
+
<DocsEditorLoadingSurface
|
|
919
|
+
label={isLoading ? 'Opening document...' : 'Resolving document...'}
|
|
920
|
+
/>
|
|
921
|
+
)}
|
|
922
|
+
{accessChangeKind ? (
|
|
923
|
+
<AccessChangedOverlay
|
|
924
|
+
kind={accessChangeKind}
|
|
925
|
+
onRefresh={() => window.location.reload()}
|
|
926
|
+
/>
|
|
927
|
+
) : null}
|
|
928
|
+
</div>
|
|
929
|
+
</div>
|
|
930
|
+
|
|
931
|
+
{selectedDoc ? (
|
|
932
|
+
<InviteDialog
|
|
933
|
+
open={inviteOpen}
|
|
934
|
+
onOpenChange={setInviteOpen}
|
|
935
|
+
doc={selectedDoc}
|
|
936
|
+
isOwner={isOwner ?? false}
|
|
937
|
+
onAclChange={handleAclChange}
|
|
938
|
+
/>
|
|
939
|
+
) : null}
|
|
940
|
+
</div>
|
|
941
|
+
)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Route-level error boundary. Generouted picks up the `ErrorBoundary` export
|
|
946
|
+
* and wires it to the route's `errorElement` prop, so any render-time throw
|
|
947
|
+
* inside this route (notably the ProseMirror `matchesNode` crash when the
|
|
948
|
+
* owner toggles a peer's role mid-session) is contained here instead of
|
|
949
|
+
* blowing up the whole app with the React Router default screen.
|
|
950
|
+
*
|
|
951
|
+
* The recovery path is a hard reload — Yjs/Tiptap state at the moment of
|
|
952
|
+
* the crash is no longer trustworthy, and the new permissions on the doc
|
|
953
|
+
* are already authoritative on the server.
|
|
954
|
+
*/
|
|
955
|
+
export function ErrorBoundary() {
|
|
956
|
+
const error = useRouteError()
|
|
957
|
+
const message = isRouteErrorResponse(error)
|
|
958
|
+
? `${error.status} ${error.statusText}`
|
|
959
|
+
: error instanceof Error
|
|
960
|
+
? error.message
|
|
961
|
+
: 'Something went wrong while loading this document.'
|
|
962
|
+
|
|
963
|
+
return (
|
|
964
|
+
<div
|
|
965
|
+
data-testid="docs-route-error"
|
|
966
|
+
className="docs-feature-scope flex h-full flex-col items-center justify-center px-6 text-center"
|
|
967
|
+
style={{ backgroundColor: 'var(--docs-el-bg)', color: 'var(--docs-el-text)' }}
|
|
968
|
+
>
|
|
969
|
+
<div
|
|
970
|
+
className="w-full max-w-md rounded-xl border p-6 shadow-md"
|
|
971
|
+
style={{ borderColor: 'var(--docs-el-line)', backgroundColor: 'var(--docs-el-surface)' }}
|
|
972
|
+
>
|
|
973
|
+
<div className="mb-3 flex items-center justify-center">
|
|
974
|
+
<span
|
|
975
|
+
className="inline-flex h-10 w-10 items-center justify-center rounded-full"
|
|
976
|
+
style={{
|
|
977
|
+
backgroundColor: 'color-mix(in srgb, var(--docs-el-accent) 14%, transparent)',
|
|
978
|
+
color: 'var(--docs-el-accent)',
|
|
979
|
+
}}
|
|
980
|
+
aria-hidden
|
|
981
|
+
>
|
|
982
|
+
<AlertTriangle className="h-5 w-5" strokeWidth={2} />
|
|
983
|
+
</span>
|
|
984
|
+
</div>
|
|
985
|
+
<h1 className="text-base font-semibold tracking-tight">This document needs to reload</h1>
|
|
986
|
+
<p className="mx-auto mt-1.5 max-w-xs text-sm" style={{ color: 'var(--docs-el-muted)' }}>
|
|
987
|
+
Your access to this document just changed. Refresh to load the latest version.
|
|
988
|
+
</p>
|
|
989
|
+
<p
|
|
990
|
+
className="mx-auto mt-3 max-w-xs truncate text-xs"
|
|
991
|
+
style={{ color: 'var(--docs-el-muted)' }}
|
|
992
|
+
title={message}
|
|
993
|
+
>
|
|
994
|
+
{message}
|
|
995
|
+
</p>
|
|
996
|
+
<div className="mt-5 flex justify-center">
|
|
997
|
+
<button
|
|
998
|
+
type="button"
|
|
999
|
+
autoFocus
|
|
1000
|
+
onClick={() => window.location.reload()}
|
|
1001
|
+
data-testid="docs-route-error-refresh"
|
|
1002
|
+
className="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
|
1003
|
+
style={{ backgroundColor: 'var(--docs-el-accent)' }}
|
|
1004
|
+
>
|
|
1005
|
+
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2.25} />
|
|
1006
|
+
Refresh
|
|
1007
|
+
</button>
|
|
1008
|
+
</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
</div>
|
|
1011
|
+
)
|
|
1012
|
+
}
|