@actuate-media/cms-admin 0.12.0 → 0.12.1

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 (152) hide show
  1. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  2. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/comment-mark.test.js +89 -0
  4. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  5. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  9. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/notification-bell.test.js +200 -0
  12. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  13. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/offline-status.test.js +56 -0
  16. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  17. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  21. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  22. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  23. package/dist/__tests__/lib/active-editor.test.js +57 -0
  24. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  25. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  26. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  27. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  28. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  29. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  30. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  31. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  32. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  33. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  34. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  35. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  36. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  37. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  38. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  39. package/dist/__tests__/lib/comments-client.test.js +202 -0
  40. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  41. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  42. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  43. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  44. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  45. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  46. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  47. package/dist/__tests__/lib/slash-command.test.js +123 -0
  48. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  49. package/dist/actuate-admin.css +1 -1
  50. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  51. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  52. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  53. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  54. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  55. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  56. package/dist/components/CoauthorResultPopover.js +42 -0
  57. package/dist/components/CoauthorResultPopover.js.map +1 -0
  58. package/dist/components/CommandPalette.d.ts +1 -1
  59. package/dist/components/CommandPalette.d.ts.map +1 -1
  60. package/dist/components/CommandPalette.js +138 -40
  61. package/dist/components/CommandPalette.js.map +1 -1
  62. package/dist/components/CommentMark.d.ts +54 -0
  63. package/dist/components/CommentMark.d.ts.map +1 -0
  64. package/dist/components/CommentMark.js +104 -0
  65. package/dist/components/CommentMark.js.map +1 -0
  66. package/dist/components/CommentSidePanel.d.ts +86 -0
  67. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  68. package/dist/components/CommentSidePanel.js +165 -0
  69. package/dist/components/CommentSidePanel.js.map +1 -0
  70. package/dist/components/LivePreview.d.ts.map +1 -1
  71. package/dist/components/LivePreview.js +80 -16
  72. package/dist/components/LivePreview.js.map +1 -1
  73. package/dist/components/NotificationBell.d.ts +45 -0
  74. package/dist/components/NotificationBell.d.ts.map +1 -0
  75. package/dist/components/NotificationBell.js +90 -0
  76. package/dist/components/NotificationBell.js.map +1 -0
  77. package/dist/components/OfflineStatus.d.ts +39 -0
  78. package/dist/components/OfflineStatus.d.ts.map +1 -0
  79. package/dist/components/OfflineStatus.js +47 -0
  80. package/dist/components/OfflineStatus.js.map +1 -0
  81. package/dist/components/PresenceChips.d.ts +31 -0
  82. package/dist/components/PresenceChips.d.ts.map +1 -0
  83. package/dist/components/PresenceChips.js +58 -0
  84. package/dist/components/PresenceChips.js.map +1 -0
  85. package/dist/components/TipTapEditor.d.ts +61 -1
  86. package/dist/components/TipTapEditor.d.ts.map +1 -1
  87. package/dist/components/TipTapEditor.js +211 -8
  88. package/dist/components/TipTapEditor.js.map +1 -1
  89. package/dist/components/ui/Badge.d.ts +1 -1
  90. package/dist/index.d.ts +8 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +4 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/active-editor.d.ts +6 -0
  95. package/dist/lib/active-editor.d.ts.map +1 -0
  96. package/dist/lib/active-editor.js +35 -0
  97. package/dist/lib/active-editor.js.map +1 -0
  98. package/dist/lib/coauthor-client.d.ts +72 -0
  99. package/dist/lib/coauthor-client.d.ts.map +1 -0
  100. package/dist/lib/coauthor-client.js +98 -0
  101. package/dist/lib/coauthor-client.js.map +1 -0
  102. package/dist/lib/collaboration-provider.d.ts +142 -0
  103. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  104. package/dist/lib/collaboration-provider.js +168 -0
  105. package/dist/lib/collaboration-provider.js.map +1 -0
  106. package/dist/lib/comment-anchor.d.ts +86 -0
  107. package/dist/lib/comment-anchor.d.ts.map +1 -0
  108. package/dist/lib/comment-anchor.js +139 -0
  109. package/dist/lib/comment-anchor.js.map +1 -0
  110. package/dist/lib/comments-client.d.ts +106 -0
  111. package/dist/lib/comments-client.d.ts.map +1 -0
  112. package/dist/lib/comments-client.js +138 -0
  113. package/dist/lib/comments-client.js.map +1 -0
  114. package/dist/lib/notifications-client.d.ts +68 -0
  115. package/dist/lib/notifications-client.d.ts.map +1 -0
  116. package/dist/lib/notifications-client.js +85 -0
  117. package/dist/lib/notifications-client.js.map +1 -0
  118. package/dist/lib/slash-command.d.ts +39 -0
  119. package/dist/lib/slash-command.d.ts.map +1 -0
  120. package/dist/lib/slash-command.js +50 -0
  121. package/dist/lib/slash-command.js.map +1 -0
  122. package/package.json +12 -3
  123. package/src/__tests__/components/comment-mark.test.ts +99 -0
  124. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  125. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  126. package/src/__tests__/components/offline-status.test.tsx +66 -0
  127. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  128. package/src/__tests__/lib/active-editor.test.ts +70 -0
  129. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  130. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  131. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  132. package/src/__tests__/lib/comments-client.test.ts +239 -0
  133. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  134. package/src/__tests__/lib/slash-command.test.ts +146 -0
  135. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  136. package/src/components/CoauthorResultPopover.tsx +193 -0
  137. package/src/components/CommandPalette.tsx +397 -209
  138. package/src/components/CommentMark.ts +138 -0
  139. package/src/components/CommentSidePanel.tsx +521 -0
  140. package/src/components/LivePreview.tsx +117 -11
  141. package/src/components/NotificationBell.tsx +242 -0
  142. package/src/components/OfflineStatus.tsx +90 -0
  143. package/src/components/PresenceChips.tsx +150 -0
  144. package/src/components/TipTapEditor.tsx +310 -12
  145. package/src/index.ts +20 -0
  146. package/src/lib/active-editor.ts +39 -0
  147. package/src/lib/coauthor-client.ts +160 -0
  148. package/src/lib/collaboration-provider.ts +306 -0
  149. package/src/lib/comment-anchor.ts +179 -0
  150. package/src/lib/comments-client.ts +208 -0
  151. package/src/lib/notifications-client.ts +146 -0
  152. package/src/lib/slash-command.ts +67 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Browser-side Yjs collaboration provider for the TipTap editor.
3
+ *
4
+ * Wraps `y-websocket`'s `WebsocketProvider` with sensible defaults and a
5
+ * narrow public API. The wire protocol is byte-compatible with the
6
+ * cms-core realtime gateway shipped in PR #106, so the standard
7
+ * `WebsocketProvider` connects without any custom client code.
8
+ *
9
+ * Auth flows through the browser's cookie jar — `cms-admin` already uses
10
+ * cookie-based sessions, and the WebSocket upgrade request carries those
11
+ * cookies automatically against same-origin gateways. For cross-origin
12
+ * deployments, attach a query-string token via the `params` option.
13
+ */
14
+ import { Awareness } from 'y-protocols/awareness'
15
+ import { WebsocketProvider } from 'y-websocket'
16
+ import { IndexeddbPersistence } from 'y-indexeddb'
17
+ import * as Y from 'yjs'
18
+
19
+ /** Connection status reported via `onStatus` and surfaced in the editor UI. */
20
+ export type CollaborationStatus = 'connecting' | 'connected' | 'disconnected'
21
+
22
+ /**
23
+ * Offline-draft persistence status. Slice 6 layers a local IndexedDB
24
+ * mirror on top of the y-websocket transport so edits made while
25
+ * disconnected aren't lost. The lifecycle is:
26
+ *
27
+ * - `unsupported` — `indexedDB` is unavailable (Safari private mode,
28
+ * SSR, jsdom-without-fake-indexeddb).
29
+ * - `pending` — provider is being constructed; no local state yet.
30
+ * - `loading` — IndexedDB is replaying the last saved snapshot into
31
+ * the Y.Doc.
32
+ * - `ready` — the local store is initialised and every change is
33
+ * persisted locally before the WebSocket pushes it upstream.
34
+ * - `error` — IndexedDB threw; the editor still works in-memory but
35
+ * refresh will lose pending edits.
36
+ */
37
+ export type OfflinePersistenceStatus = 'unsupported' | 'pending' | 'loading' | 'ready' | 'error'
38
+
39
+ /** Per-user presence info that becomes the local awareness state. */
40
+ export interface CollaborationUser {
41
+ /** Display name (used for cursor labels and the presence chip strip). */
42
+ name: string
43
+ /** CSS color value for the user's cursor / chip background. */
44
+ color: string
45
+ /** Optional avatar URL (32-square recommended). Rendered if present. */
46
+ avatarUrl?: string
47
+ /** Stable user id from the cms-core session (used as a dedup key). */
48
+ userId?: string
49
+ }
50
+
51
+ export interface CollaborationOptions {
52
+ /** Logical document id (becomes the y-websocket "room name"). */
53
+ documentId: string
54
+ /**
55
+ * Base URL of the realtime gateway, e.g. `wss://your.app/api/cms/realtime/sync`.
56
+ * The provider appends `/${documentId}` automatically (y-websocket convention).
57
+ * Use `ws://` for local dev.
58
+ */
59
+ url: string
60
+ /** Presence info for the local user. */
61
+ user: CollaborationUser
62
+ /**
63
+ * Extra query-string parameters (e.g. an auth token for cross-origin
64
+ * deployments). Forwarded to `WebsocketProvider`'s `params` option.
65
+ */
66
+ params?: Record<string, string>
67
+ /** Called on every connection-state transition. */
68
+ onStatus?: (status: CollaborationStatus) => void
69
+ /** Called on connection-level errors (e.g. 4401 auth rejection). */
70
+ onError?: (err: Error) => void
71
+ /**
72
+ * If `false`, the helper does not connect on construction. Useful for
73
+ * tests and for editors that want a `Y.Doc` without network traffic.
74
+ * Defaults to `true`.
75
+ */
76
+ connect?: boolean
77
+ /**
78
+ * Offline-draft persistence. When `true` (default), the provider
79
+ * mounts a `y-indexeddb` mirror so edits made while the WebSocket is
80
+ * disconnected survive a refresh. Pass `false` to skip the mirror
81
+ * (e.g. on shared kiosk machines where you don't want drafts in
82
+ * IndexedDB).
83
+ */
84
+ offline?: boolean
85
+ /** Called on every offline-persistence status transition. */
86
+ onOfflineStatus?: (status: OfflinePersistenceStatus) => void
87
+ }
88
+
89
+ export interface CollaborationContext {
90
+ /** Shared Y.Doc — bind this to the TipTap `Collaboration` extension. */
91
+ ydoc: Y.Doc
92
+ /** The underlying y-websocket provider. */
93
+ provider: WebsocketProvider
94
+ /** Awareness instance — bind to `CollaborationCursor` and the presence strip. */
95
+ awareness: Awareness
96
+ /**
97
+ * Local IndexedDB mirror, when `offline` is enabled and the runtime
98
+ * supports it. `null` when persistence is disabled or unsupported
99
+ * (e.g. SSR / Safari private browsing).
100
+ */
101
+ offlinePersistence: IndexeddbPersistence | null
102
+ /** Latest offline-persistence status. Mirrors `onOfflineStatus`. */
103
+ offlineStatus: OfflinePersistenceStatus
104
+ /** Stop the provider, evict awareness, and destroy the Y.Doc. */
105
+ destroy: () => void
106
+ }
107
+
108
+ /** Optional dependency-injection seam used by tests to avoid real WebSockets. */
109
+ export interface CollaborationDeps {
110
+ WebsocketProviderCtor?: typeof WebsocketProvider
111
+ /**
112
+ * Override the IndexedDB persistence constructor (test seam). The
113
+ * default uses `y-indexeddb` which requires a browser-like
114
+ * `indexedDB` global; tests inject a no-op stub.
115
+ */
116
+ IndexeddbPersistenceCtor?: typeof IndexeddbPersistence
117
+ /**
118
+ * Override the IndexedDB feature check. Defaults to inspecting
119
+ * `globalThis.indexedDB`. Tests inject `() => true` to exercise the
120
+ * persistence path under happy-dom.
121
+ */
122
+ isIndexedDbSupported?: () => boolean
123
+ }
124
+
125
+ /**
126
+ * Open a collaboration session for `documentId`. Must be called in a browser
127
+ * context (jsdom is fine for tests when a fake provider is injected).
128
+ *
129
+ * The returned `ydoc` is the document the editor binds to via TipTap's
130
+ * `Collaboration` extension; `awareness` carries cursor + presence state.
131
+ * Always call `destroy()` on unmount to evict the local awareness state and
132
+ * close the socket.
133
+ */
134
+ export function createCollaborationProvider(
135
+ opts: CollaborationOptions,
136
+ deps: CollaborationDeps = {},
137
+ ): CollaborationContext {
138
+ if (
139
+ typeof globalThis === 'undefined' ||
140
+ typeof (globalThis as { window?: unknown }).window === 'undefined'
141
+ ) {
142
+ // Note: jsdom defines `window`, so this only trips on bare-Node SSR.
143
+ throw new Error(
144
+ 'createCollaborationProvider can only be called in a browser context (window is undefined)',
145
+ )
146
+ }
147
+ if (!opts.documentId || !opts.url) {
148
+ throw new Error('createCollaborationProvider requires both documentId and url')
149
+ }
150
+
151
+ const Ctor = deps.WebsocketProviderCtor ?? WebsocketProvider
152
+ const ydoc = new Y.Doc()
153
+ const provider = new Ctor(opts.url, opts.documentId, ydoc, {
154
+ connect: opts.connect ?? true,
155
+ params: opts.params,
156
+ })
157
+
158
+ // Layer y-indexeddb on top of the y-websocket transport for offline
159
+ // drafts. Both providers share the same Y.Doc, so they automatically
160
+ // converge: IndexedDB hydrates the doc on load, the websocket pushes
161
+ // pending updates to the gateway on reconnect.
162
+ const offlineEnabled = opts.offline !== false
163
+ const offlineCheck = deps.isIndexedDbSupported ?? defaultIndexedDbSupportCheck
164
+ let offlineStatus: OfflinePersistenceStatus = 'pending'
165
+ const setOfflineStatus = (next: OfflinePersistenceStatus) => {
166
+ offlineStatus = next
167
+ opts.onOfflineStatus?.(next)
168
+ }
169
+ let offlinePersistence: IndexeddbPersistence | null = null
170
+ if (!offlineEnabled) {
171
+ setOfflineStatus('unsupported')
172
+ } else if (!offlineCheck()) {
173
+ setOfflineStatus('unsupported')
174
+ } else {
175
+ setOfflineStatus('loading')
176
+ const PersistenceCtor = deps.IndexeddbPersistenceCtor ?? IndexeddbPersistence
177
+ try {
178
+ offlinePersistence = new PersistenceCtor(
179
+ // Namespace the IndexedDB key by documentId so multiple docs in
180
+ // the same admin session don't collide.
181
+ `actuate-cms:${opts.documentId}`,
182
+ ydoc,
183
+ )
184
+ // `whenSynced` is the canonical "snapshot replay finished" hook
185
+ // from y-indexeddb (https://github.com/yjs/y-indexeddb).
186
+ offlinePersistence.whenSynced
187
+ .then(() => setOfflineStatus('ready'))
188
+ .catch((err: unknown) => {
189
+ opts.onError?.(err instanceof Error ? err : new Error('Offline persistence failed'))
190
+ setOfflineStatus('error')
191
+ })
192
+ } catch (err) {
193
+ opts.onError?.(err instanceof Error ? err : new Error('Offline persistence failed'))
194
+ setOfflineStatus('error')
195
+ offlinePersistence = null
196
+ }
197
+ }
198
+
199
+ // Seed local awareness with the user's presence info. The
200
+ // `CollaborationCursor` TipTap extension reads `user.name` + `user.color`
201
+ // out of awareness state and renders cursors accordingly.
202
+ provider.awareness.setLocalState({
203
+ user: {
204
+ name: opts.user.name,
205
+ color: opts.user.color,
206
+ avatarUrl: opts.user.avatarUrl,
207
+ userId: opts.user.userId,
208
+ },
209
+ })
210
+
211
+ if (opts.onStatus) {
212
+ const handler = (event: { status: string }) => {
213
+ const status = normaliseStatus(event.status)
214
+ if (status) opts.onStatus!(status)
215
+ }
216
+ provider.on('status', handler)
217
+ }
218
+ if (opts.onError) {
219
+ const handler = () => {
220
+ // y-websocket emits this on socket close + on protocol errors. We
221
+ // surface a generic Error so callers can render a toast without
222
+ // sniffing y-websocket internals.
223
+ opts.onError!(new Error('Realtime connection error'))
224
+ }
225
+ provider.on('connection-error', handler)
226
+ }
227
+
228
+ const destroy = () => {
229
+ try {
230
+ provider.awareness.setLocalState(null)
231
+ } catch {
232
+ /* awareness may already be torn down */
233
+ }
234
+ try {
235
+ provider.destroy()
236
+ } catch {
237
+ /* idempotent */
238
+ }
239
+ if (offlinePersistence) {
240
+ try {
241
+ // y-indexeddb keeps the IDB connection open until `destroy()`;
242
+ // we don't drop the stored data (that's the point of offline
243
+ // drafts), but we close the handle so the runtime can GC.
244
+ offlinePersistence.destroy()
245
+ } catch {
246
+ /* idempotent */
247
+ }
248
+ }
249
+ try {
250
+ ydoc.destroy()
251
+ } catch {
252
+ /* idempotent */
253
+ }
254
+ }
255
+
256
+ return {
257
+ ydoc,
258
+ provider,
259
+ awareness: provider.awareness,
260
+ offlinePersistence,
261
+ get offlineStatus() {
262
+ return offlineStatus
263
+ },
264
+ destroy,
265
+ }
266
+ }
267
+
268
+ function defaultIndexedDbSupportCheck(): boolean {
269
+ return (
270
+ typeof globalThis !== 'undefined' &&
271
+ typeof (globalThis as { indexedDB?: unknown }).indexedDB !== 'undefined'
272
+ )
273
+ }
274
+
275
+ function normaliseStatus(value: string): CollaborationStatus | null {
276
+ if (value === 'connected') return 'connected'
277
+ if (value === 'connecting') return 'connecting'
278
+ if (value === 'disconnected') return 'disconnected'
279
+ return null
280
+ }
281
+
282
+ /**
283
+ * Reduce an Awareness map into a deduplicated array of `CollaborationUser`s.
284
+ * Used by `PresenceChips` and any other UI that visualises co-editors.
285
+ */
286
+ export interface PresenceSnapshot {
287
+ /** Local user's client id (so UIs can omit themselves from the strip). */
288
+ selfClientId: number
289
+ /** Connected users, deduplicated by `userId` when present, otherwise by clientId. */
290
+ users: Array<{ clientId: number; user: CollaborationUser }>
291
+ }
292
+
293
+ export function summarisePresence(awareness: Awareness): PresenceSnapshot {
294
+ const seenUserIds = new Set<string>()
295
+ const users: PresenceSnapshot['users'] = []
296
+ for (const [clientId, state] of awareness.getStates()) {
297
+ const user = (state as { user?: CollaborationUser })?.user
298
+ if (!user || typeof user.name !== 'string') continue
299
+ if (user.userId) {
300
+ if (seenUserIds.has(user.userId)) continue
301
+ seenUserIds.add(user.userId)
302
+ }
303
+ users.push({ clientId, user })
304
+ }
305
+ return { selfClientId: awareness.clientID, users }
306
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Comment anchor helpers — convert between Yjs **relative positions** and
3
+ * the Base64 wire format the comments REST API speaks.
4
+ *
5
+ * Why relative positions: storing absolute offsets (e.g. char 42–58) is
6
+ * fatal under concurrent editing because someone inserting text before
7
+ * char 42 silently shifts the meaning of every saved anchor. Yjs ships
8
+ * `createRelativePositionFromTypeIndex` /
9
+ * `createAbsolutePositionFromRelativePosition` for exactly this case —
10
+ * they identify a slot in the CRDT graph that survives concurrent edits.
11
+ *
12
+ * The wire shape is `{ from: string; to: string }` where each end is the
13
+ * Base64 of `Y.encodeRelativePosition(rel)`. The slice 4 server treats
14
+ * the field as opaque; this module is the only place in the workspace
15
+ * that interprets it.
16
+ *
17
+ * ## Choosing the right Yjs type to anchor against
18
+ *
19
+ * `createRelativePositionFromTypeIndex(type, index)` interprets `index`
20
+ * differently depending on the type:
21
+ *
22
+ * - `Y.XmlText` / `Y.Text` — index is the character offset within the
23
+ * text. **This is what comment anchors use** because character offsets
24
+ * are the natural unit for a text selection.
25
+ * - `Y.XmlFragment` / `Y.XmlElement` — index counts *children*. Useful
26
+ * for "before/after this child" anchors but not what we want here.
27
+ *
28
+ * Slice 5's TipTap binding maps a PM selection to the underlying
29
+ * `Y.XmlText` instance + character offset before calling `encodeAnchor`,
30
+ * and inverts that mapping after `decodeAnchor` returns. The y-prosemirror
31
+ * plugin keeps a `mapping` for this conversion; consumers that aren't
32
+ * using y-prosemirror can talk to the Y types directly.
33
+ */
34
+
35
+ import * as Y from 'yjs'
36
+
37
+ import type { CommentAnchor } from './comments-client.js'
38
+
39
+ export interface CreateAnchorInput {
40
+ /** Live Yjs document the editor is bound to. */
41
+ doc: Y.Doc
42
+ /**
43
+ * Yjs text type the selection lives in. Almost always a `Y.XmlText`
44
+ * for TipTap; we keep the bound a little loose so plain `Y.Text` works
45
+ * for non-rich-text editors.
46
+ */
47
+ yType: Y.XmlText | Y.Text
48
+ /** Character offset (inclusive) of the selection start within `yType`. */
49
+ from: number
50
+ /** Character offset (exclusive) of the selection end within `yType`. */
51
+ to: number
52
+ }
53
+
54
+ /**
55
+ * Build a comment anchor from an editor selection. Returns `null` when
56
+ * the range is empty or inverted — the caller should not save anchors
57
+ * that would resolve to nothing.
58
+ */
59
+ export function encodeAnchor(input: CreateAnchorInput): CommentAnchor | null {
60
+ if (input.from >= input.to) return null
61
+ if (input.from < 0) return null
62
+ // `createRelativePositionFromTypeIndex` clamps `index` to `[0, type.length]`
63
+ // internally, but we surface an explicit failure when the *intent* is
64
+ // out of bounds so the caller can flag a stale selection.
65
+ if (input.to > input.yType.length) return null
66
+
67
+ const relFrom = Y.createRelativePositionFromTypeIndex(input.yType, input.from)
68
+ const relTo = Y.createRelativePositionFromTypeIndex(input.yType, input.to)
69
+
70
+ return {
71
+ from: encodeBase64(Y.encodeRelativePosition(relFrom)),
72
+ to: encodeBase64(Y.encodeRelativePosition(relTo)),
73
+ }
74
+ }
75
+
76
+ export interface ResolveAnchorInput {
77
+ doc: Y.Doc
78
+ anchor: CommentAnchor
79
+ }
80
+
81
+ export interface ResolvedAnchor {
82
+ /**
83
+ * The text type the relative position landed on. Comparing identity
84
+ * (`resolved.yType === editorBoundText`) lets the caller decide
85
+ * whether a comment is still attached to the expected text node.
86
+ */
87
+ yType: Y.AbstractType<unknown>
88
+ /** Character offset of the saved selection start, after concurrent edits. */
89
+ from: number
90
+ /** Character offset of the saved selection end. */
91
+ to: number
92
+ }
93
+
94
+ /**
95
+ * Resolve a stored anchor back to live editor positions. Returns `null`
96
+ * when:
97
+ *
98
+ * - the anchor bytes are not valid base64 / not a Yjs relative position,
99
+ * - the relative position cannot be mapped (the anchored content was
100
+ * deleted entirely), or
101
+ * - the two ends collapse to the same offset (text between them was
102
+ * deleted, effectively detaching the comment).
103
+ *
104
+ * Slice 5 surfaces detached comments in the side panel with a small
105
+ * "anchor lost" badge so editors can still resolve / discuss them.
106
+ */
107
+ export function decodeAnchor(input: ResolveAnchorInput): ResolvedAnchor | null {
108
+ let relFromBytes: Uint8Array
109
+ let relToBytes: Uint8Array
110
+ try {
111
+ relFromBytes = decodeBase64(input.anchor.from)
112
+ relToBytes = decodeBase64(input.anchor.to)
113
+ } catch {
114
+ return null
115
+ }
116
+
117
+ let relFrom: Y.RelativePosition
118
+ let relTo: Y.RelativePosition
119
+ try {
120
+ relFrom = Y.decodeRelativePosition(relFromBytes)
121
+ relTo = Y.decodeRelativePosition(relToBytes)
122
+ } catch {
123
+ return null
124
+ }
125
+
126
+ const absFrom = Y.createAbsolutePositionFromRelativePosition(relFrom, input.doc)
127
+ const absTo = Y.createAbsolutePositionFromRelativePosition(relTo, input.doc)
128
+ if (absFrom === null || absTo === null) return null
129
+
130
+ // Both ends must land on the same text node — otherwise the user's
131
+ // original selection has been split across nodes and we cannot paint
132
+ // a clean highlight without further work in slice 5.
133
+ if (absFrom.type !== absTo.type) return null
134
+
135
+ if (absFrom.index >= absTo.index) return null
136
+
137
+ return {
138
+ yType: absFrom.type,
139
+ from: absFrom.index,
140
+ to: absTo.index,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Base64 encode without depending on Node's `Buffer` (the admin bundle
146
+ * is browser-first). We hand-roll the encode because both `btoa` and
147
+ * `Buffer` have ergonomic mis-fits for `Uint8Array` payloads.
148
+ */
149
+ function encodeBase64(bytes: Uint8Array): string {
150
+ if (typeof btoa === 'function') {
151
+ let binary = ''
152
+ for (let i = 0; i < bytes.byteLength; i++) {
153
+ binary += String.fromCharCode(bytes[i]!)
154
+ }
155
+ return btoa(binary)
156
+ }
157
+ const BufferCtor = (
158
+ globalThis as {
159
+ Buffer?: { from(arr: Uint8Array): { toString(enc: string): string } }
160
+ }
161
+ ).Buffer
162
+ if (BufferCtor) return BufferCtor.from(bytes).toString('base64')
163
+ throw new Error('No base64 encoder available')
164
+ }
165
+
166
+ function decodeBase64(s: string): Uint8Array {
167
+ if (typeof atob === 'function') {
168
+ const binary = atob(s)
169
+ const out = new Uint8Array(binary.length)
170
+ for (let i = 0; i < binary.length; i++) {
171
+ out[i] = binary.charCodeAt(i)
172
+ }
173
+ return out
174
+ }
175
+ const BufferCtor = (globalThis as { Buffer?: { from(s: string, enc: string): Uint8Array } })
176
+ .Buffer
177
+ if (BufferCtor) return new Uint8Array(BufferCtor.from(s, 'base64'))
178
+ throw new Error('No base64 decoder available')
179
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Comments REST client — thin, typed wrapper around the
3
+ * `/documents/:id/comments` + `/comments/:id` endpoints shipped in slice 4
4
+ * of Phase 3 (see `packages/cms-core/src/realtime/comments.ts`).
5
+ *
6
+ * The client lives in `cms-admin` so the side panel, the bubble-menu
7
+ * "comment on selection" action, and any future MCP/AI hooks all share one
8
+ * request path, error model, and DTO shape.
9
+ *
10
+ * Mirrors the server contract:
11
+ * POST /documents/:documentId/comments -> { body, anchor?, parentId? }
12
+ * GET /documents/:documentId/comments?… -> { data: CommentDTO[] }
13
+ * PATCH /comments/:id -> { body }
14
+ * POST /comments/:id/resolve -> 200 with updated DTO
15
+ * POST /comments/:id/reopen -> 200 with updated DTO
16
+ * DELETE /comments/:id -> 200 with soft-deleted DTO
17
+ */
18
+
19
+ import { cmsApi } from './api.js'
20
+
21
+ /**
22
+ * Yjs relative-position anchor returned by the server. The encoding is
23
+ * intentionally opaque on the wire (Base64 of `Y.encodeRelativePosition`);
24
+ * `lib/comment-anchor.ts` handles the round-trip when slice 5 paints a
25
+ * highlight in the editor.
26
+ */
27
+ export interface CommentAnchor {
28
+ from: string
29
+ to: string
30
+ }
31
+
32
+ /**
33
+ * Public DTO returned by every comments endpoint. Mirrors `CommentDTO` in
34
+ * `@actuate-media/cms-core/realtime`; we duplicate the shape here so
35
+ * `cms-admin` doesn't take a hard dependency on the server-side service
36
+ * layer (which carries Prisma types we don't want pulled into the bundle).
37
+ */
38
+ export interface Comment {
39
+ id: string
40
+ documentId: string
41
+ parentId: string | null
42
+ userId: string | null
43
+ body: string
44
+ anchor: CommentAnchor | null
45
+ resolvedAt: string | null
46
+ resolvedById: string | null
47
+ deletedAt: string | null
48
+ createdAt: string
49
+ updatedAt: string
50
+ isResolved: boolean
51
+ }
52
+
53
+ export type CommentOutcome<T> =
54
+ | { ok: true; result: T }
55
+ | { ok: false; error: string; status: number }
56
+
57
+ export interface CreateCommentInput {
58
+ documentId: string
59
+ body: string
60
+ anchor?: CommentAnchor | null
61
+ parentId?: string | null
62
+ }
63
+
64
+ export interface ListCommentsOptions {
65
+ /** Default: false. Resolved threads are filtered out unless requested. */
66
+ includeResolved?: boolean
67
+ /** Default: false. Admin-only; the server rejects non-admin callers. */
68
+ includeDeleted?: boolean
69
+ /** Default: server-side cap (100). Hard upper bound is 200. */
70
+ pageSize?: number
71
+ }
72
+
73
+ function encodeOptions(opts: ListCommentsOptions = {}): string {
74
+ const params = new URLSearchParams()
75
+ if (opts.includeResolved) params.set('includeResolved', 'true')
76
+ if (opts.includeDeleted) params.set('includeDeleted', 'true')
77
+ if (opts.pageSize !== undefined) params.set('pageSize', String(opts.pageSize))
78
+ const qs = params.toString()
79
+ return qs ? `?${qs}` : ''
80
+ }
81
+
82
+ function failure(error: string, status: number): { ok: false; error: string; status: number } {
83
+ return { ok: false, error, status }
84
+ }
85
+
86
+ function envelope<T>(res: { data?: unknown; error?: string; status: number }): CommentOutcome<T> {
87
+ if (res.error || res.data === undefined) {
88
+ return failure(res.error ?? `Request failed (${res.status})`, res.status)
89
+ }
90
+ return { ok: true, result: res.data as T }
91
+ }
92
+
93
+ /**
94
+ * Create a top-level comment or a reply. Pass `parentId` to thread the
95
+ * comment under an existing one — the server enforces single-level
96
+ * threading (replies cannot themselves be parents).
97
+ */
98
+ export async function createComment(input: CreateCommentInput): Promise<CommentOutcome<Comment>> {
99
+ if (typeof input.documentId !== 'string' || input.documentId.length === 0) {
100
+ return failure('documentId is required', 0)
101
+ }
102
+ if (typeof input.body !== 'string' || input.body.trim().length === 0) {
103
+ return failure('Comment cannot be empty', 0)
104
+ }
105
+
106
+ const body: Record<string, unknown> = { body: input.body.trim() }
107
+ if (input.anchor) body.anchor = input.anchor
108
+ if (input.parentId) body.parentId = input.parentId
109
+
110
+ const res = await cmsApi<Comment>(`/documents/${encodeURIComponent(input.documentId)}/comments`, {
111
+ method: 'POST',
112
+ body: JSON.stringify(body),
113
+ })
114
+ return envelope<Comment>(res)
115
+ }
116
+
117
+ /**
118
+ * List comments for a document. Active comments (not resolved, not deleted)
119
+ * are returned by default; pass `includeResolved` to keep resolved threads
120
+ * in the response. The server rejects `includeDeleted` for non-admins.
121
+ */
122
+ export async function listComments(
123
+ documentId: string,
124
+ opts: ListCommentsOptions = {},
125
+ ): Promise<CommentOutcome<Comment[]>> {
126
+ if (typeof documentId !== 'string' || documentId.length === 0) {
127
+ return failure('documentId is required', 0)
128
+ }
129
+ const path = `/documents/${encodeURIComponent(documentId)}/comments${encodeOptions(opts)}`
130
+ const res = await cmsApi<Comment[]>(path, { method: 'GET' })
131
+ return envelope<Comment[]>(res)
132
+ }
133
+
134
+ /** Edit a comment body. Only the author or an admin is permitted. */
135
+ export async function updateComment(id: string, body: string): Promise<CommentOutcome<Comment>> {
136
+ if (!id) return failure('comment id is required', 0)
137
+ if (typeof body !== 'string' || body.trim().length === 0) {
138
+ return failure('Comment cannot be empty', 0)
139
+ }
140
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
141
+ method: 'PATCH',
142
+ body: JSON.stringify({ body: body.trim() }),
143
+ })
144
+ return envelope<Comment>(res)
145
+ }
146
+
147
+ /** Resolve a thread. Write-role or the author may resolve. */
148
+ export async function resolveComment(id: string): Promise<CommentOutcome<Comment>> {
149
+ if (!id) return failure('comment id is required', 0)
150
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/resolve`, {
151
+ method: 'POST',
152
+ body: '{}',
153
+ })
154
+ return envelope<Comment>(res)
155
+ }
156
+
157
+ /** Re-open a resolved thread. Same permission as resolve. */
158
+ export async function reopenComment(id: string): Promise<CommentOutcome<Comment>> {
159
+ if (!id) return failure('comment id is required', 0)
160
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}/reopen`, {
161
+ method: 'POST',
162
+ body: '{}',
163
+ })
164
+ return envelope<Comment>(res)
165
+ }
166
+
167
+ /**
168
+ * Soft-delete a comment. The row is preserved so its replies don't lose
169
+ * their thread root — the server simply stops returning it from the list
170
+ * endpoint unless an admin asks for `includeDeleted`.
171
+ */
172
+ export async function deleteComment(id: string): Promise<CommentOutcome<Comment>> {
173
+ if (!id) return failure('comment id is required', 0)
174
+ const res = await cmsApi<Comment>(`/comments/${encodeURIComponent(id)}`, {
175
+ method: 'DELETE',
176
+ })
177
+ return envelope<Comment>(res)
178
+ }
179
+
180
+ /**
181
+ * Sort comments into thread groups suitable for rendering. Roots are
182
+ * returned in chronological order; replies are ordered the same way and
183
+ * attached to their root. Replies whose root is missing (e.g. the root was
184
+ * filtered out by `includeResolved=false`) are dropped — the UI can fetch
185
+ * them explicitly if it needs to render orphaned replies.
186
+ */
187
+ export interface CommentThread {
188
+ root: Comment
189
+ replies: Comment[]
190
+ }
191
+
192
+ export function groupCommentsIntoThreads(comments: readonly Comment[]): CommentThread[] {
193
+ const roots = new Map<string, CommentThread>()
194
+ const sorted = [...comments].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
195
+
196
+ for (const c of sorted) {
197
+ if (c.parentId === null) {
198
+ roots.set(c.id, { root: c, replies: [] })
199
+ }
200
+ }
201
+ for (const c of sorted) {
202
+ if (c.parentId !== null) {
203
+ const root = roots.get(c.parentId)
204
+ if (root) root.replies.push(c)
205
+ }
206
+ }
207
+ return Array.from(roots.values())
208
+ }