@actuate-media/cms-admin 0.12.0 → 0.13.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/__tests__/components/comment-mark.test.d.ts +2 -0
- package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-mark.test.js +89 -0
- package/dist/__tests__/components/comment-mark.test.js.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.js +291 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
- package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +346 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -0
- package/dist/__tests__/components/offline-status.test.d.ts +2 -0
- package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
- package/dist/__tests__/components/offline-status.test.js +56 -0
- package/dist/__tests__/components/offline-status.test.js.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
- package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
- package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/active-editor.test.js +57 -0
- package/dist/__tests__/lib/active-editor.test.js.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.js +173 -0
- package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
- package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.js +173 -0
- package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
- package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
- package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comments-client.test.js +202 -0
- package/dist/__tests__/lib/comments-client.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +283 -0
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
- package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
- package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
- package/dist/__tests__/lib/slash-command.test.js +123 -0
- package/dist/__tests__/lib/slash-command.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
- package/dist/components/AICoauthorBubbleMenu.js +113 -0
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +28 -0
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
- package/dist/components/CoauthorResultPopover.js +42 -0
- package/dist/components/CoauthorResultPopover.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +138 -40
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/CommentMark.d.ts +54 -0
- package/dist/components/CommentMark.d.ts.map +1 -0
- package/dist/components/CommentMark.js +104 -0
- package/dist/components/CommentMark.js.map +1 -0
- package/dist/components/CommentSidePanel.d.ts +86 -0
- package/dist/components/CommentSidePanel.d.ts.map +1 -0
- package/dist/components/CommentSidePanel.js +165 -0
- package/dist/components/CommentSidePanel.js.map +1 -0
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +80 -16
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +66 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +142 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/OfflineStatus.d.ts +39 -0
- package/dist/components/OfflineStatus.d.ts.map +1 -0
- package/dist/components/OfflineStatus.js +47 -0
- package/dist/components/OfflineStatus.js.map +1 -0
- package/dist/components/PresenceChips.d.ts +31 -0
- package/dist/components/PresenceChips.d.ts.map +1 -0
- package/dist/components/PresenceChips.js +58 -0
- package/dist/components/PresenceChips.js.map +1 -0
- package/dist/components/TipTapEditor.d.ts +61 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +211 -8
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/active-editor.d.ts +6 -0
- package/dist/lib/active-editor.d.ts.map +1 -0
- package/dist/lib/active-editor.js +35 -0
- package/dist/lib/active-editor.js.map +1 -0
- package/dist/lib/api.d.ts +8 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +10 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/coauthor-client.d.ts +72 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -0
- package/dist/lib/coauthor-client.js +98 -0
- package/dist/lib/coauthor-client.js.map +1 -0
- package/dist/lib/collaboration-provider.d.ts +142 -0
- package/dist/lib/collaboration-provider.d.ts.map +1 -0
- package/dist/lib/collaboration-provider.js +168 -0
- package/dist/lib/collaboration-provider.js.map +1 -0
- package/dist/lib/comment-anchor.d.ts +86 -0
- package/dist/lib/comment-anchor.d.ts.map +1 -0
- package/dist/lib/comment-anchor.js +139 -0
- package/dist/lib/comment-anchor.js.map +1 -0
- package/dist/lib/comments-client.d.ts +106 -0
- package/dist/lib/comments-client.d.ts.map +1 -0
- package/dist/lib/comments-client.js +138 -0
- package/dist/lib/comments-client.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +121 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +173 -0
- package/dist/lib/notifications-client.js.map +1 -0
- package/dist/lib/slash-command.d.ts +39 -0
- package/dist/lib/slash-command.d.ts.map +1 -0
- package/dist/lib/slash-command.js +50 -0
- package/dist/lib/slash-command.js.map +1 -0
- package/package.json +12 -3
- package/src/__tests__/components/comment-mark.test.ts +99 -0
- package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
- package/src/__tests__/components/notification-bell.test.tsx +379 -0
- package/src/__tests__/components/offline-status.test.tsx +66 -0
- package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
- package/src/__tests__/lib/active-editor.test.ts +70 -0
- package/src/__tests__/lib/coauthor-client.test.ts +213 -0
- package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
- package/src/__tests__/lib/comment-anchor.test.ts +188 -0
- package/src/__tests__/lib/comments-client.test.ts +239 -0
- package/src/__tests__/lib/notifications-client.test.ts +339 -0
- package/src/__tests__/lib/slash-command.test.ts +146 -0
- package/src/components/AICoauthorBubbleMenu.tsx +187 -0
- package/src/components/CoauthorResultPopover.tsx +193 -0
- package/src/components/CommandPalette.tsx +397 -209
- package/src/components/CommentMark.ts +138 -0
- package/src/components/CommentSidePanel.tsx +521 -0
- package/src/components/LivePreview.tsx +117 -11
- package/src/components/NotificationBell.tsx +314 -0
- package/src/components/OfflineStatus.tsx +90 -0
- package/src/components/PresenceChips.tsx +150 -0
- package/src/components/TipTapEditor.tsx +310 -12
- package/src/index.ts +20 -0
- package/src/lib/active-editor.ts +39 -0
- package/src/lib/api.ts +11 -0
- package/src/lib/coauthor-client.ts +160 -0
- package/src/lib/collaboration-provider.ts +306 -0
- package/src/lib/comment-anchor.ts +179 -0
- package/src/lib/comments-client.ts +208 -0
- package/src/lib/notifications-client.ts +292 -0
- package/src/lib/slash-command.ts +67 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react'
|
|
4
|
+
import { Awareness } from 'y-protocols/awareness'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
summarisePresence,
|
|
8
|
+
type CollaborationStatus,
|
|
9
|
+
type PresenceSnapshot,
|
|
10
|
+
} from '../lib/collaboration-provider.js'
|
|
11
|
+
|
|
12
|
+
export interface PresenceChipsProps {
|
|
13
|
+
/** Awareness instance returned from `createCollaborationProvider`. */
|
|
14
|
+
awareness: Awareness
|
|
15
|
+
/**
|
|
16
|
+
* Optional connection status. When provided the strip renders a small
|
|
17
|
+
* status pill (Connected / Connecting / Disconnected) on the left side.
|
|
18
|
+
*/
|
|
19
|
+
status?: CollaborationStatus
|
|
20
|
+
/**
|
|
21
|
+
* Hide the local user from the strip. Defaults to `true` — the local user
|
|
22
|
+
* already knows they're editing.
|
|
23
|
+
*/
|
|
24
|
+
hideSelf?: boolean
|
|
25
|
+
/** Max chips to show before collapsing to "+N". Defaults to 5. */
|
|
26
|
+
maxVisible?: number
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Small avatar / initials strip showing who else is currently editing the
|
|
32
|
+
* document. Subscribes to the supplied `Awareness` instance for live
|
|
33
|
+
* updates.
|
|
34
|
+
*
|
|
35
|
+
* The component is intentionally stateless about transport — it only reads
|
|
36
|
+
* from Awareness and `status`. Wiring is the caller's job.
|
|
37
|
+
*/
|
|
38
|
+
export function PresenceChips({
|
|
39
|
+
awareness,
|
|
40
|
+
status,
|
|
41
|
+
hideSelf = true,
|
|
42
|
+
maxVisible = 5,
|
|
43
|
+
className,
|
|
44
|
+
}: PresenceChipsProps) {
|
|
45
|
+
const [snapshot, setSnapshot] = useState<PresenceSnapshot>(() => summarisePresence(awareness))
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const update = () => {
|
|
49
|
+
setSnapshot(summarisePresence(awareness))
|
|
50
|
+
}
|
|
51
|
+
update()
|
|
52
|
+
awareness.on('update', update)
|
|
53
|
+
awareness.on('change', update)
|
|
54
|
+
return () => {
|
|
55
|
+
awareness.off('update', update)
|
|
56
|
+
awareness.off('change', update)
|
|
57
|
+
}
|
|
58
|
+
}, [awareness])
|
|
59
|
+
|
|
60
|
+
const visible = hideSelf
|
|
61
|
+
? snapshot.users.filter((u) => u.clientId !== snapshot.selfClientId)
|
|
62
|
+
: snapshot.users
|
|
63
|
+
const overflow = Math.max(0, visible.length - maxVisible)
|
|
64
|
+
const shown = visible.slice(0, maxVisible)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
className={`inline-flex items-center gap-1.5 text-xs ${className ?? ''}`}
|
|
69
|
+
data-testid="presence-chips"
|
|
70
|
+
>
|
|
71
|
+
{status && <StatusPill status={status} />}
|
|
72
|
+
{shown.length === 0 && status !== 'connecting' && (
|
|
73
|
+
<span className="text-gray-500" data-testid="presence-empty">
|
|
74
|
+
You're the only one here
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
{shown.map(({ clientId, user }) => (
|
|
78
|
+
<Chip key={clientId} name={user.name} color={user.color} avatarUrl={user.avatarUrl} />
|
|
79
|
+
))}
|
|
80
|
+
{overflow > 0 && (
|
|
81
|
+
<span
|
|
82
|
+
className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-700"
|
|
83
|
+
data-testid="presence-overflow"
|
|
84
|
+
>
|
|
85
|
+
+{overflow}
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function StatusPill({ status }: { status: CollaborationStatus }) {
|
|
93
|
+
const palette: Record<CollaborationStatus, { dot: string; label: string; text: string }> = {
|
|
94
|
+
connected: { dot: 'bg-emerald-500', label: 'Live', text: 'text-emerald-700' },
|
|
95
|
+
connecting: { dot: 'bg-amber-400 animate-pulse', label: 'Connecting', text: 'text-amber-700' },
|
|
96
|
+
disconnected: { dot: 'bg-gray-300', label: 'Offline', text: 'text-gray-500' },
|
|
97
|
+
}
|
|
98
|
+
const cfg = palette[status]
|
|
99
|
+
return (
|
|
100
|
+
<span
|
|
101
|
+
className={`inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-0.5 ${cfg.text}`}
|
|
102
|
+
data-testid={`presence-status-${status}`}
|
|
103
|
+
>
|
|
104
|
+
<span className={`h-1.5 w-1.5 rounded-full ${cfg.dot}`} />
|
|
105
|
+
{cfg.label}
|
|
106
|
+
</span>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Chip({ name, color, avatarUrl }: { name: string; color: string; avatarUrl?: string }) {
|
|
111
|
+
const initials = nameToInitials(name)
|
|
112
|
+
return (
|
|
113
|
+
<span
|
|
114
|
+
className="inline-flex items-center gap-1 rounded-full bg-white px-1.5 py-0.5 ring-1 ring-gray-200"
|
|
115
|
+
title={name}
|
|
116
|
+
data-testid="presence-chip"
|
|
117
|
+
>
|
|
118
|
+
{avatarUrl ? (
|
|
119
|
+
<img
|
|
120
|
+
src={avatarUrl}
|
|
121
|
+
alt=""
|
|
122
|
+
className="h-4 w-4 rounded-full object-cover"
|
|
123
|
+
style={{ boxShadow: `0 0 0 1.5px ${color}` }}
|
|
124
|
+
/>
|
|
125
|
+
) : (
|
|
126
|
+
<span
|
|
127
|
+
aria-hidden="true"
|
|
128
|
+
className="flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-semibold text-white"
|
|
129
|
+
style={{ backgroundColor: color }}
|
|
130
|
+
>
|
|
131
|
+
{initials}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
<span className="max-w-[80px] truncate text-gray-700">{name}</span>
|
|
135
|
+
</span>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Convert "Alice Cooper" → "AC", single names → "A", empty → "?". */
|
|
140
|
+
export function nameToInitials(name: string): string {
|
|
141
|
+
const cleaned = name.trim()
|
|
142
|
+
if (!cleaned) return '?'
|
|
143
|
+
const parts = cleaned.split(/\s+/).slice(0, 2)
|
|
144
|
+
return (
|
|
145
|
+
parts
|
|
146
|
+
.map((p) => (p[0] ?? '').toUpperCase())
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join('') || '?'
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import React, { useState } from 'react'
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
|
5
5
|
import { MediaPickerModal } from './MediaPickerModal.js'
|
|
6
|
+
import { AICoauthorBubbleMenu } from './AICoauthorBubbleMenu.js'
|
|
7
|
+
import { PresenceChips } from './PresenceChips.js'
|
|
8
|
+
import { OfflineStatus } from './OfflineStatus.js'
|
|
9
|
+
import { CommentMark, COMMENT_MARK_STYLES } from './CommentMark.js'
|
|
10
|
+
import { CommentSidePanel } from './CommentSidePanel.js'
|
|
11
|
+
import { clearActiveEditorIf, setActiveEditor } from '../lib/active-editor.js'
|
|
12
|
+
import {
|
|
13
|
+
createCollaborationProvider,
|
|
14
|
+
type CollaborationContext,
|
|
15
|
+
type CollaborationStatus,
|
|
16
|
+
type CollaborationUser,
|
|
17
|
+
type OfflinePersistenceStatus,
|
|
18
|
+
} from '../lib/collaboration-provider.js'
|
|
19
|
+
import { encodeAnchor } from '../lib/comment-anchor.js'
|
|
20
|
+
import type { CommentAnchor } from '../lib/comments-client.js'
|
|
21
|
+
import * as Y from 'yjs'
|
|
6
22
|
import StarterKit from '@tiptap/starter-kit'
|
|
7
23
|
import UnderlineExt from '@tiptap/extension-underline'
|
|
8
24
|
import Link from '@tiptap/extension-link'
|
|
@@ -15,6 +31,8 @@ import TextAlign from '@tiptap/extension-text-align'
|
|
|
15
31
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
16
32
|
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
|
17
33
|
import HorizontalRule from '@tiptap/extension-horizontal-rule'
|
|
34
|
+
import Collaboration from '@tiptap/extension-collaboration'
|
|
35
|
+
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
|
|
18
36
|
import { common, createLowlight } from 'lowlight'
|
|
19
37
|
import {
|
|
20
38
|
Bold,
|
|
@@ -45,12 +63,73 @@ import {
|
|
|
45
63
|
|
|
46
64
|
const lowlight = createLowlight(common)
|
|
47
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Configuration for real-time collaboration on this editor.
|
|
68
|
+
*
|
|
69
|
+
* When supplied the editor switches into CRDT mode: history is delegated to
|
|
70
|
+
* Yjs, edits sync through the cms-core realtime gateway, and a presence
|
|
71
|
+
* strip with live cursors appears beside the toolbar. The `content` prop is
|
|
72
|
+
* ignored in collaboration mode (the gateway is the source of truth);
|
|
73
|
+
* `onChange` still fires on every local update so existing autosave wiring
|
|
74
|
+
* keeps working.
|
|
75
|
+
*/
|
|
76
|
+
export interface TipTapCollaborationConfig {
|
|
77
|
+
documentId: string
|
|
78
|
+
/**
|
|
79
|
+
* Base URL of the realtime gateway. `wss://your.app/api/cms/realtime/sync`
|
|
80
|
+
* in production, `ws://localhost:3000/api/cms/realtime/sync` for dev.
|
|
81
|
+
*/
|
|
82
|
+
url: string
|
|
83
|
+
user: CollaborationUser
|
|
84
|
+
/** Extra query params (e.g. auth token for cross-origin deployments). */
|
|
85
|
+
params?: Record<string, string>
|
|
86
|
+
}
|
|
87
|
+
|
|
48
88
|
export interface TipTapEditorProps {
|
|
49
89
|
content: string
|
|
50
90
|
onChange: (html: string) => void
|
|
51
91
|
placeholder?: string
|
|
52
92
|
editable?: boolean
|
|
53
93
|
className?: string
|
|
94
|
+
/**
|
|
95
|
+
* Toggle the AI co-author bubble menu on text selection.
|
|
96
|
+
* Defaults to `true` when the editor is editable. Set to `false` to opt
|
|
97
|
+
* out (e.g. for preview-only renders, or environments where the
|
|
98
|
+
* `/ai/coauthor` endpoint is intentionally disabled).
|
|
99
|
+
*/
|
|
100
|
+
enableAI?: boolean
|
|
101
|
+
/**
|
|
102
|
+
* Enable real-time collaboration via the cms-core realtime gateway. When
|
|
103
|
+
* set, the editor swaps in Yjs-backed history + the collaboration cursor
|
|
104
|
+
* extension and renders a presence strip above the toolbar.
|
|
105
|
+
*/
|
|
106
|
+
collaboration?: TipTapCollaborationConfig
|
|
107
|
+
/**
|
|
108
|
+
* Enable inline comments. When set, the `CommentMark` extension is
|
|
109
|
+
* loaded and a `CommentSidePanel` renders next to the editor. Anchors
|
|
110
|
+
* are encoded against the active Yjs text node when collaboration is
|
|
111
|
+
* also on, so highlights survive concurrent edits.
|
|
112
|
+
*
|
|
113
|
+
* Comments are independent of the collaboration prop — you can attach
|
|
114
|
+
* comments to a static document, but cross-user "live" highlighting
|
|
115
|
+
* only kicks in when collaboration is also enabled.
|
|
116
|
+
*/
|
|
117
|
+
comments?: TipTapCommentsConfig
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Configuration for the comments side panel. The panel issues REST calls
|
|
122
|
+
* against the slice-4 endpoints; this prop is just the small set of
|
|
123
|
+
* presentation + identity bits the panel can't infer from the editor.
|
|
124
|
+
*/
|
|
125
|
+
export interface TipTapCommentsConfig {
|
|
126
|
+
documentId: string
|
|
127
|
+
/** Logged-in user id — gates edit + delete affordances. */
|
|
128
|
+
currentUserId: string
|
|
129
|
+
/** Set to true to expose moderation actions on every comment. */
|
|
130
|
+
isAdmin?: boolean
|
|
131
|
+
/** Error sink so the host shell can show a toast. */
|
|
132
|
+
onError?: (message: string) => void
|
|
54
133
|
}
|
|
55
134
|
|
|
56
135
|
const editorStyles = `
|
|
@@ -401,14 +480,66 @@ export function TipTapEditor({
|
|
|
401
480
|
placeholder,
|
|
402
481
|
editable = true,
|
|
403
482
|
className,
|
|
483
|
+
enableAI = true,
|
|
484
|
+
collaboration,
|
|
485
|
+
comments,
|
|
404
486
|
}: TipTapEditorProps) {
|
|
405
487
|
const [mediaPickerOpen, setMediaPickerOpen] = useState(false)
|
|
488
|
+
// The collaboration context is created once per (documentId, url) tuple
|
|
489
|
+
// and torn down on prop changes / unmount. We deliberately key on a
|
|
490
|
+
// stringified tuple so swapping users keeps the same Y.Doc (their seat at
|
|
491
|
+
// the document doesn't drop) while swapping documents creates a new one.
|
|
492
|
+
const collabKey = collaboration ? `${collaboration.documentId}::${collaboration.url}` : null
|
|
493
|
+
const collabRef = useRef<CollaborationContext | null>(null)
|
|
494
|
+
const [collabStatus, setCollabStatus] = useState<CollaborationStatus>('connecting')
|
|
495
|
+
const [offlineStatus, setOfflineStatus] = useState<OfflinePersistenceStatus>('pending')
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
if (!collaboration) {
|
|
498
|
+
collabRef.current = null
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
const ctx = createCollaborationProvider({
|
|
502
|
+
documentId: collaboration.documentId,
|
|
503
|
+
url: collaboration.url,
|
|
504
|
+
user: collaboration.user,
|
|
505
|
+
params: collaboration.params,
|
|
506
|
+
onStatus: setCollabStatus,
|
|
507
|
+
onOfflineStatus: setOfflineStatus,
|
|
508
|
+
})
|
|
509
|
+
collabRef.current = ctx
|
|
510
|
+
return () => {
|
|
511
|
+
ctx.destroy()
|
|
512
|
+
if (collabRef.current === ctx) collabRef.current = null
|
|
513
|
+
}
|
|
514
|
+
// We rely on collabKey to scope effect identity. user / params are
|
|
515
|
+
// tolerated to mutate freely; the Yjs awareness state is updated
|
|
516
|
+
// separately in the awareness-seeding effect below.
|
|
517
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
518
|
+
}, [collabKey])
|
|
519
|
+
|
|
520
|
+
// Keep the local awareness state in sync with user info changes without
|
|
521
|
+
// tearing down the collaboration session.
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
if (!collaboration || !collabRef.current) return
|
|
524
|
+
collabRef.current.awareness.setLocalState({ user: collaboration.user })
|
|
525
|
+
}, [
|
|
526
|
+
collaboration?.user.name,
|
|
527
|
+
collaboration?.user.color,
|
|
528
|
+
collaboration?.user.avatarUrl,
|
|
529
|
+
collaboration?.user.userId,
|
|
530
|
+
collaboration,
|
|
531
|
+
])
|
|
406
532
|
|
|
407
|
-
const
|
|
408
|
-
|
|
533
|
+
const extensions = useMemo(() => {
|
|
534
|
+
// The TipTap extension union is hostile to mutation (the inferred
|
|
535
|
+
// element type is too narrow). We collect into `unknown[]` and let
|
|
536
|
+
// `useEditor`'s prop typing carry the contract from the call site.
|
|
537
|
+
const base: unknown[] = [
|
|
409
538
|
StarterKit.configure({
|
|
410
539
|
codeBlock: false,
|
|
411
540
|
horizontalRule: false,
|
|
541
|
+
// History is owned by Yjs when collaboration is enabled.
|
|
542
|
+
history: collaboration ? false : undefined,
|
|
412
543
|
}),
|
|
413
544
|
UnderlineExt,
|
|
414
545
|
Link.configure({
|
|
@@ -429,13 +560,54 @@ export function TipTapEditor({
|
|
|
429
560
|
}),
|
|
430
561
|
CodeBlockLowlight.configure({ lowlight }),
|
|
431
562
|
HorizontalRule,
|
|
432
|
-
]
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
563
|
+
]
|
|
564
|
+
if (collaboration && collabRef.current) {
|
|
565
|
+
base.push(
|
|
566
|
+
Collaboration.configure({ document: collabRef.current.ydoc }),
|
|
567
|
+
CollaborationCursor.configure({
|
|
568
|
+
provider: collabRef.current.provider,
|
|
569
|
+
user: collaboration.user,
|
|
570
|
+
}),
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
if (comments) {
|
|
574
|
+
base.push(CommentMark)
|
|
575
|
+
}
|
|
576
|
+
return base
|
|
577
|
+
// Re-derive extensions when the collaboration session or comments
|
|
578
|
+
// toggle changes.
|
|
579
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
580
|
+
}, [collabKey, placeholder, comments != null])
|
|
581
|
+
|
|
582
|
+
const editor = useEditor(
|
|
583
|
+
{
|
|
584
|
+
// TipTap's `Extensions` type is the union of every loaded extension's
|
|
585
|
+
// typed shape, which we can't satisfy when we conditionally append
|
|
586
|
+
// collaboration extensions. The runtime contract is correct — TipTap
|
|
587
|
+
// duck-types — so we cast through the narrow `any` slot.
|
|
588
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
589
|
+
extensions: extensions as any[],
|
|
590
|
+
// When collaboration is enabled the Y.Doc is the source of truth; we
|
|
591
|
+
// pass `content: ''` to avoid double-applying the initial HTML.
|
|
592
|
+
content: collaboration ? '' : content,
|
|
593
|
+
editable: editable !== false,
|
|
594
|
+
onUpdate: ({ editor: ed }) => {
|
|
595
|
+
onChange(ed.getHTML())
|
|
596
|
+
},
|
|
597
|
+
onFocus: ({ editor: ed }) => {
|
|
598
|
+
setActiveEditor(ed)
|
|
599
|
+
},
|
|
437
600
|
},
|
|
438
|
-
|
|
601
|
+
// Force re-init when collaboration extensions change.
|
|
602
|
+
[collabKey],
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
useEffect(() => {
|
|
606
|
+
if (!editor) return
|
|
607
|
+
return () => {
|
|
608
|
+
clearActiveEditorIf(editor)
|
|
609
|
+
}
|
|
610
|
+
}, [editor])
|
|
439
611
|
|
|
440
612
|
if (!editor) return null
|
|
441
613
|
|
|
@@ -447,13 +619,55 @@ export function TipTapEditor({
|
|
|
447
619
|
.run()
|
|
448
620
|
}
|
|
449
621
|
|
|
450
|
-
|
|
622
|
+
const awareness = collabRef.current?.awareness ?? null
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Encode an anchor for the current selection. Returns `null` when the
|
|
626
|
+
* selection is empty (the panel falls back to a doc-level comment),
|
|
627
|
+
* or when collaboration isn't enabled (we have no Yjs text node to
|
|
628
|
+
* anchor against — slice 5's anchors are CRDT-relative by design).
|
|
629
|
+
*/
|
|
630
|
+
const composeAnchor = (): CommentAnchor | null => {
|
|
631
|
+
if (!collabRef.current) return null
|
|
632
|
+
const sel = editor.state.selection
|
|
633
|
+
if (sel.empty) return null
|
|
634
|
+
// The Y.Doc shape that TipTap collaboration uses is
|
|
635
|
+
// doc.getXmlFragment('default') → [paragraph, …] → [text, …]
|
|
636
|
+
// We anchor against the first XmlText child of the paragraph that
|
|
637
|
+
// contains the selection start. This is the simple-but-correct
|
|
638
|
+
// single-node case; multi-paragraph selections fall back to a
|
|
639
|
+
// doc-level comment (anchor: null) and the user can re-anchor
|
|
640
|
+
// later.
|
|
641
|
+
const fragment = collabRef.current.ydoc.getXmlFragment('default')
|
|
642
|
+
const yType = findTextNodeForPosition(fragment, sel.from)
|
|
643
|
+
if (!yType) return null
|
|
644
|
+
// The relative offset within the text node mirrors the PM offset
|
|
645
|
+
// minus the paragraph's opening token. For the canonical single-
|
|
646
|
+
// text-child case this is `sel.from - paragraphStart - 1`.
|
|
647
|
+
const offset = computeTextOffset(fragment, sel.from)
|
|
648
|
+
if (offset === null) return null
|
|
649
|
+
return encodeAnchor({
|
|
650
|
+
doc: collabRef.current.ydoc,
|
|
651
|
+
yType,
|
|
652
|
+
from: offset,
|
|
653
|
+
to: offset + (sel.to - sel.from),
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const editorPane = (
|
|
451
658
|
<div
|
|
452
|
-
className={`overflow-hidden rounded-lg border border-gray-200 bg-white ${className ?? ''}`}
|
|
659
|
+
className={`flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white ${className ?? ''}`}
|
|
453
660
|
>
|
|
661
|
+
{collaboration && awareness && (
|
|
662
|
+
<div className="flex items-center justify-between gap-2 border-b border-gray-100 bg-white px-2 py-1.5">
|
|
663
|
+
<PresenceChips awareness={awareness} status={collabStatus} />
|
|
664
|
+
<OfflineStatus connection={collabStatus} offline={offlineStatus} />
|
|
665
|
+
</div>
|
|
666
|
+
)}
|
|
454
667
|
{editable && <Toolbar editor={editor} onOpenMediaPicker={() => setMediaPickerOpen(true)} />}
|
|
668
|
+
{editable && enableAI && <AICoauthorBubbleMenu editor={editor} />}
|
|
455
669
|
<EditorContent editor={editor} />
|
|
456
|
-
<style dangerouslySetInnerHTML={{ __html: editorStyles }} />
|
|
670
|
+
<style dangerouslySetInnerHTML={{ __html: editorStyles + COMMENT_MARK_STYLES }} />
|
|
457
671
|
<MediaPickerModal
|
|
458
672
|
open={mediaPickerOpen}
|
|
459
673
|
onClose={() => setMediaPickerOpen(false)}
|
|
@@ -462,4 +676,88 @@ export function TipTapEditor({
|
|
|
462
676
|
/>
|
|
463
677
|
</div>
|
|
464
678
|
)
|
|
679
|
+
|
|
680
|
+
if (!comments) return editorPane
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<div className="flex h-full gap-3">
|
|
684
|
+
{editorPane}
|
|
685
|
+
<div className="w-80 shrink-0">
|
|
686
|
+
<CommentSidePanel
|
|
687
|
+
documentId={comments.documentId}
|
|
688
|
+
currentUserId={comments.currentUserId}
|
|
689
|
+
isAdmin={comments.isAdmin}
|
|
690
|
+
onComposeAnchor={composeAnchor}
|
|
691
|
+
onError={comments.onError}
|
|
692
|
+
onActiveCommentChange={(id) => {
|
|
693
|
+
// When a thread is opened in the panel, paint the matching
|
|
694
|
+
// CommentMark with the active modifier so it stands out in
|
|
695
|
+
// the doc. We do this by toggling a class on the rendered
|
|
696
|
+
// span — purely visual; no doc mutation.
|
|
697
|
+
if (typeof document === 'undefined') return
|
|
698
|
+
document
|
|
699
|
+
.querySelectorAll('span.actuate-comment--active')
|
|
700
|
+
.forEach((el) => el.classList.remove('actuate-comment--active'))
|
|
701
|
+
if (id) {
|
|
702
|
+
document
|
|
703
|
+
.querySelectorAll(`span[data-comment-id="${cssEscape(id)}"]`)
|
|
704
|
+
.forEach((el) => el.classList.add('actuate-comment--active'))
|
|
705
|
+
}
|
|
706
|
+
}}
|
|
707
|
+
/>
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Walk the XmlFragment's children until we reach the paragraph
|
|
715
|
+
* containing the given ProseMirror position. Return its first XmlText
|
|
716
|
+
* child, or null when the structure is anything other than the canonical
|
|
717
|
+
* `paragraph → [text]` shape we anchor against.
|
|
718
|
+
*
|
|
719
|
+
* This is the *simple-case* mapping — slice 5 anchors to a single text
|
|
720
|
+
* node per comment. Selections that span multiple paragraphs fall back
|
|
721
|
+
* to a document-level comment (anchor: null) and the user can re-anchor
|
|
722
|
+
* later from the side panel. y-prosemirror has a richer mapping we may
|
|
723
|
+
* pull in once slice 5 ships.
|
|
724
|
+
*/
|
|
725
|
+
function findTextNodeForPosition(fragment: Y.XmlFragment, pmPos: number): Y.XmlText | null {
|
|
726
|
+
let walked = 0
|
|
727
|
+
for (const child of fragment.toArray()) {
|
|
728
|
+
if (!(child instanceof Y.XmlElement)) continue
|
|
729
|
+
const textChild = child.firstChild
|
|
730
|
+
const textLen = textChild instanceof Y.XmlText ? textChild.length : 0
|
|
731
|
+
// Each paragraph contributes `1 + textLen + 1` PM positions
|
|
732
|
+
// (open token, contents, close token).
|
|
733
|
+
const blockSpan = 2 + textLen
|
|
734
|
+
if (pmPos <= walked + blockSpan) {
|
|
735
|
+
return textChild instanceof Y.XmlText ? textChild : null
|
|
736
|
+
}
|
|
737
|
+
walked += blockSpan
|
|
738
|
+
}
|
|
739
|
+
return null
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function computeTextOffset(fragment: Y.XmlFragment, pmPos: number): number | null {
|
|
743
|
+
let walked = 0
|
|
744
|
+
for (const child of fragment.toArray()) {
|
|
745
|
+
if (!(child instanceof Y.XmlElement)) continue
|
|
746
|
+
const textChild = child.firstChild
|
|
747
|
+
const textLen = textChild instanceof Y.XmlText ? textChild.length : 0
|
|
748
|
+
const blockSpan = 2 + textLen
|
|
749
|
+
if (pmPos <= walked + blockSpan) {
|
|
750
|
+
// PM positions skip the paragraph's open token (1 char).
|
|
751
|
+
const inside = pmPos - walked - 1
|
|
752
|
+
if (inside < 0 || inside > textLen) return null
|
|
753
|
+
return inside
|
|
754
|
+
}
|
|
755
|
+
walked += blockSpan
|
|
756
|
+
}
|
|
757
|
+
return null
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Cheap CSS attr-value escaper for `data-comment-id` lookups. */
|
|
761
|
+
function cssEscape(value: string): string {
|
|
762
|
+
return value.replace(/(["\\])/g, '\\$1')
|
|
465
763
|
}
|
package/src/index.ts
CHANGED
|
@@ -97,6 +97,26 @@ export type { MediaPickerModalProps } from './components/MediaPickerModal.js'
|
|
|
97
97
|
export { ThemeProvider, useTheme } from './components/ThemeProvider.js'
|
|
98
98
|
export { FocalPointPicker } from './components/FocalPointPicker.js'
|
|
99
99
|
export { PresenceIndicator } from './components/PresenceIndicator.js'
|
|
100
|
+
export { NotificationBell } from './components/NotificationBell.js'
|
|
101
|
+
export type { NotificationBellProps, NotificationsApi } from './components/NotificationBell.js'
|
|
102
|
+
export {
|
|
103
|
+
listNotifications,
|
|
104
|
+
unreadCount as listNotificationsUnread,
|
|
105
|
+
markNotificationRead,
|
|
106
|
+
markAllNotificationsRead,
|
|
107
|
+
notificationSummary,
|
|
108
|
+
} from './lib/notifications-client.js'
|
|
109
|
+
export type {
|
|
110
|
+
Notification,
|
|
111
|
+
NotificationKind,
|
|
112
|
+
NotificationPayload,
|
|
113
|
+
NotificationOutcome,
|
|
114
|
+
ListNotificationsOptions,
|
|
115
|
+
} from './lib/notifications-client.js'
|
|
116
|
+
export { OfflineStatus, describeOfflineStatus } from './components/OfflineStatus.js'
|
|
117
|
+
export type { OfflineStatusProps } from './components/OfflineStatus.js'
|
|
118
|
+
export { CommentSidePanel } from './components/CommentSidePanel.js'
|
|
119
|
+
export type { CommentSidePanelProps } from './components/CommentSidePanel.js'
|
|
100
120
|
export { LocaleProvider, useLocale } from './components/LocaleProvider.js'
|
|
101
121
|
export type { LocaleProviderProps } from './components/LocaleProvider.js'
|
|
102
122
|
export { LocaleSwitcher } from './components/LocaleSwitcher.js'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Singleton registry for the most-recently focused TipTap editor in the
|
|
5
|
+
* admin shell. Lets out-of-tree surfaces (Cmd+K palette, slash commands,
|
|
6
|
+
* future global shortcuts) target the editor a user was last working in
|
|
7
|
+
* without prop-drilling refs through the layout.
|
|
8
|
+
*
|
|
9
|
+
* The `TipTapEditor` component calls `setActiveEditor(editor)` from its
|
|
10
|
+
* `onFocus` callback and clears it on unmount. Subscribers receive a no-op
|
|
11
|
+
* payload — they should re-read via `getActiveEditor()` after the
|
|
12
|
+
* notification.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let activeEditor: Editor | null = null
|
|
16
|
+
const listeners = new Set<() => void>()
|
|
17
|
+
|
|
18
|
+
export function setActiveEditor(editor: Editor | null): void {
|
|
19
|
+
if (activeEditor === editor) return
|
|
20
|
+
activeEditor = editor
|
|
21
|
+
for (const listener of listeners) listener()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getActiveEditor(): Editor | null {
|
|
25
|
+
return activeEditor
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clearActiveEditorIf(editor: Editor): void {
|
|
29
|
+
if (activeEditor === editor) {
|
|
30
|
+
setActiveEditor(null)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function subscribeActiveEditor(listener: () => void): () => void {
|
|
35
|
+
listeners.add(listener)
|
|
36
|
+
return () => {
|
|
37
|
+
listeners.delete(listener)
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/lib/api.ts
CHANGED
|
@@ -6,6 +6,17 @@ export function setApiBase(path: string) {
|
|
|
6
6
|
basePath = path
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Current API base path. Exposed so non-`fetch` consumers (in
|
|
11
|
+
* particular `EventSource` for SSE channels) can resolve URLs against
|
|
12
|
+
* the same prefix configured via `setApiBase()`. Keep this read-only
|
|
13
|
+
* — mutation must always flow through `setApiBase()` so we never have
|
|
14
|
+
* two callers with diverging views of the base.
|
|
15
|
+
*/
|
|
16
|
+
export function getApiBase(): string {
|
|
17
|
+
return basePath
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
function getCsrfToken(): string {
|
|
10
21
|
if (typeof document === 'undefined') return ''
|
|
11
22
|
const match = document.cookie.match(/(?:^|;\s*)actuate_csrf=([^;]*)/)
|