@object-ui/collaboration 2.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 ObjectQL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # @object-ui/collaboration
2
+
3
+ Real-time collaboration for Object UI โ€” live cursors, presence tracking, comment threads, and conflict resolution.
4
+
5
+ ## Features
6
+
7
+ - ๐Ÿ–ฑ๏ธ **Live Cursors** - Display remote user cursors in real time with `LiveCursors`
8
+ - ๐Ÿ‘ฅ **Presence Avatars** - Show active users with `PresenceAvatars`
9
+ - ๐Ÿ’ฌ **Comment Threads** - Threaded comments with @mentions via `CommentThread`
10
+ - ๐Ÿ”„ **Realtime Subscriptions** - WebSocket data subscriptions with `useRealtimeSubscription`
11
+ - ๐Ÿ‘๏ธ **Presence Tracking** - Track who's viewing or editing with `usePresence`
12
+ - โš”๏ธ **Conflict Resolution** - Version history and merge conflicts with `useConflictResolution`
13
+ - ๐ŸŽฏ **Type-Safe** - Full TypeScript support with exported types
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @object-ui/collaboration
19
+ ```
20
+
21
+ **Peer Dependencies:**
22
+ - `react` ^18.0.0 || ^19.0.0
23
+ - `react-dom` ^18.0.0 || ^19.0.0
24
+
25
+ ## Quick Start
26
+
27
+ ```tsx
28
+ import {
29
+ usePresence,
30
+ useRealtimeSubscription,
31
+ LiveCursors,
32
+ PresenceAvatars,
33
+ CommentThread,
34
+ } from '@object-ui/collaboration';
35
+
36
+ function CollaborativeEditor() {
37
+ const { users, updatePresence } = usePresence({
38
+ channel: 'document-123',
39
+ });
40
+
41
+ const { data, connectionState } = useRealtimeSubscription({
42
+ channel: 'document-123',
43
+ event: 'update',
44
+ });
45
+
46
+ return (
47
+ <div>
48
+ <PresenceAvatars users={users} />
49
+ <LiveCursors users={users} />
50
+ <Editor data={data} onCursorMove={(pos) => updatePresence({ cursor: pos })} />
51
+ <CommentThread threadId="thread-1" />
52
+ </div>
53
+ );
54
+ }
55
+ ```
56
+
57
+ ## API
58
+
59
+ ### useRealtimeSubscription
60
+
61
+ Hook for WebSocket data subscriptions:
62
+
63
+ ```tsx
64
+ const { data, connectionState, error } = useRealtimeSubscription({
65
+ channel: 'orders',
66
+ event: 'update',
67
+ });
68
+ ```
69
+
70
+ ### usePresence
71
+
72
+ Hook for tracking user presence:
73
+
74
+ ```tsx
75
+ const { users, updatePresence } = usePresence({
76
+ channel: 'document-123',
77
+ user: { id: 'user-1', name: 'Alice' },
78
+ });
79
+ ```
80
+
81
+ ### useConflictResolution
82
+
83
+ Hook for version history and conflict management:
84
+
85
+ ```tsx
86
+ const { versions, conflicts, resolve } = useConflictResolution({
87
+ resourceId: 'doc-123',
88
+ });
89
+ ```
90
+
91
+ ### LiveCursors
92
+
93
+ Displays remote user cursors on the page:
94
+
95
+ ```tsx
96
+ <LiveCursors users={presenceUsers} />
97
+ ```
98
+
99
+ ### PresenceAvatars
100
+
101
+ Shows avatar badges for active users:
102
+
103
+ ```tsx
104
+ <PresenceAvatars users={presenceUsers} maxVisible={5} />
105
+ ```
106
+
107
+ ### CommentThread
108
+
109
+ Threaded comment component with @mentions:
110
+
111
+ ```tsx
112
+ <CommentThread
113
+ threadId="thread-1"
114
+ comments={comments}
115
+ onSubmit={(comment) => saveComment(comment)}
116
+ />
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT
@@ -0,0 +1,61 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import React from 'react';
9
+ export interface Comment {
10
+ id: string;
11
+ author: {
12
+ id: string;
13
+ name: string;
14
+ avatar?: string;
15
+ };
16
+ content: string;
17
+ mentions: string[];
18
+ createdAt: string;
19
+ updatedAt?: string;
20
+ parentId?: string;
21
+ resolved?: boolean;
22
+ reactions?: Record<string, string[]>;
23
+ }
24
+ export interface CommentThreadProps {
25
+ /** Thread ID */
26
+ threadId: string;
27
+ /** Comments in the thread */
28
+ comments: Comment[];
29
+ /** Current user */
30
+ currentUser: {
31
+ id: string;
32
+ name: string;
33
+ avatar?: string;
34
+ };
35
+ /** Available users for @mentions */
36
+ mentionableUsers?: {
37
+ id: string;
38
+ name: string;
39
+ avatar?: string;
40
+ }[];
41
+ /** Callback when a new comment is posted */
42
+ onAddComment?: (content: string, mentions: string[], parentId?: string) => void;
43
+ /** Callback when a comment is edited */
44
+ onEditComment?: (commentId: string, content: string) => void;
45
+ /** Callback when a comment is deleted */
46
+ onDeleteComment?: (commentId: string) => void;
47
+ /** Callback when thread is resolved/reopened */
48
+ onResolve?: (resolved: boolean) => void;
49
+ /** Whether the thread is resolved */
50
+ resolved?: boolean;
51
+ /** Additional className */
52
+ className?: string;
53
+ }
54
+ /**
55
+ * Comment thread component with @mentions support.
56
+ *
57
+ * Renders a list of comments with author avatars, timestamps,
58
+ * reply functionality, and an @mention suggestions popup.
59
+ */
60
+ export declare function CommentThread({ threadId, comments, currentUser, mentionableUsers, onAddComment, onEditComment, onDeleteComment, onResolve, resolved, className, }: CommentThreadProps): React.ReactElement;
61
+ //# sourceMappingURL=CommentThread.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CommentThread.d.ts","sourceRoot":"","sources":["../src/CommentThread.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA4D,MAAM,OAAO,CAAC;AAEjF,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,kBAAkB;IACjC,gBAAgB;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,mBAAmB;IACnB,WAAW,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3D,oCAAoC;IACpC,gBAAgB,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACnE,4CAA4C;IAC5C,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,QAAQ,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAChF,wCAAwC;IACxC,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,yCAAyC;IACzC,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,gDAAgD;IAChD,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2BAA2B;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAiOD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,gBAAqB,EACrB,YAAY,EACZ,aAAa,EACb,eAAe,EACf,SAAS,EACT,QAAgB,EAChB,SAAS,GACV,EAAE,kBAAkB,GAAG,KAAK,CAAC,YAAY,CAuQzC"}
@@ -0,0 +1,438 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
9
+ function formatTimestamp(iso) {
10
+ try {
11
+ const date = new Date(iso);
12
+ const now = new Date();
13
+ const diff = now.getTime() - date.getTime();
14
+ const minutes = Math.floor(diff / 60000);
15
+ if (minutes < 1)
16
+ return 'just now';
17
+ if (minutes < 60)
18
+ return `${minutes}m ago`;
19
+ const hours = Math.floor(minutes / 60);
20
+ if (hours < 24)
21
+ return `${hours}h ago`;
22
+ const days = Math.floor(hours / 24);
23
+ if (days < 7)
24
+ return `${days}d ago`;
25
+ return date.toLocaleDateString();
26
+ }
27
+ catch {
28
+ return iso;
29
+ }
30
+ }
31
+ function getInitials(name) {
32
+ return name
33
+ .split(' ')
34
+ .map(part => part[0])
35
+ .join('')
36
+ .toUpperCase()
37
+ .slice(0, 2);
38
+ }
39
+ /** Parse @mentions from text content */
40
+ function parseMentions(content, users) {
41
+ const mentions = [];
42
+ const mentionPattern = /@(\w+)/g;
43
+ let match;
44
+ while ((match = mentionPattern.exec(content)) !== null) {
45
+ const matchStr = match[1];
46
+ const mentioned = users.find(u => u.name.toLowerCase().replace(/\s+/g, '') === matchStr.toLowerCase()
47
+ || u.id === matchStr);
48
+ if (mentioned && !mentions.includes(mentioned.id)) {
49
+ mentions.push(mentioned.id);
50
+ }
51
+ }
52
+ return mentions;
53
+ }
54
+ const styles = {
55
+ thread: {
56
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
57
+ fontSize: '14px',
58
+ lineHeight: '1.5',
59
+ border: '1px solid #e2e8f0',
60
+ borderRadius: '8px',
61
+ overflow: 'hidden',
62
+ backgroundColor: '#fff',
63
+ },
64
+ header: {
65
+ display: 'flex',
66
+ alignItems: 'center',
67
+ justifyContent: 'space-between',
68
+ padding: '8px 12px',
69
+ borderBottom: '1px solid #e2e8f0',
70
+ backgroundColor: '#f8fafc',
71
+ fontSize: '12px',
72
+ color: '#64748b',
73
+ },
74
+ resolveBtn: {
75
+ background: 'none',
76
+ border: '1px solid #cbd5e1',
77
+ borderRadius: '4px',
78
+ padding: '2px 8px',
79
+ fontSize: '12px',
80
+ cursor: 'pointer',
81
+ color: '#475569',
82
+ },
83
+ commentList: {
84
+ maxHeight: '400px',
85
+ overflowY: 'auto',
86
+ },
87
+ comment: {
88
+ display: 'flex',
89
+ gap: '8px',
90
+ padding: '10px 12px',
91
+ borderBottom: '1px solid #f1f5f9',
92
+ },
93
+ reply: {
94
+ paddingLeft: '32px',
95
+ },
96
+ avatar: {
97
+ width: '28px',
98
+ height: '28px',
99
+ borderRadius: '50%',
100
+ flexShrink: 0,
101
+ display: 'flex',
102
+ alignItems: 'center',
103
+ justifyContent: 'center',
104
+ fontSize: '11px',
105
+ fontWeight: 600,
106
+ color: '#fff',
107
+ backgroundColor: '#94a3b8',
108
+ overflow: 'hidden',
109
+ },
110
+ avatarImg: {
111
+ width: '100%',
112
+ height: '100%',
113
+ objectFit: 'cover',
114
+ },
115
+ commentBody: {
116
+ flex: 1,
117
+ minWidth: 0,
118
+ },
119
+ commentHeader: {
120
+ display: 'flex',
121
+ alignItems: 'center',
122
+ gap: '6px',
123
+ marginBottom: '2px',
124
+ },
125
+ authorName: {
126
+ fontWeight: 600,
127
+ fontSize: '13px',
128
+ color: '#1e293b',
129
+ },
130
+ timestamp: {
131
+ fontSize: '12px',
132
+ color: '#94a3b8',
133
+ },
134
+ content: {
135
+ color: '#334155',
136
+ wordBreak: 'break-word',
137
+ },
138
+ mention: {
139
+ color: '#3b82f6',
140
+ fontWeight: 500,
141
+ },
142
+ actions: {
143
+ display: 'flex',
144
+ gap: '8px',
145
+ marginTop: '4px',
146
+ },
147
+ actionBtn: {
148
+ background: 'none',
149
+ border: 'none',
150
+ fontSize: '12px',
151
+ color: '#64748b',
152
+ cursor: 'pointer',
153
+ padding: 0,
154
+ },
155
+ inputArea: {
156
+ display: 'flex',
157
+ gap: '8px',
158
+ padding: '10px 12px',
159
+ borderTop: '1px solid #e2e8f0',
160
+ position: 'relative',
161
+ },
162
+ textarea: {
163
+ flex: 1,
164
+ border: '1px solid #e2e8f0',
165
+ borderRadius: '6px',
166
+ padding: '6px 10px',
167
+ fontSize: '13px',
168
+ fontFamily: 'inherit',
169
+ resize: 'none',
170
+ outline: 'none',
171
+ minHeight: '36px',
172
+ maxHeight: '120px',
173
+ lineHeight: '1.5',
174
+ },
175
+ submitBtn: {
176
+ alignSelf: 'flex-end',
177
+ backgroundColor: '#3b82f6',
178
+ color: '#fff',
179
+ border: 'none',
180
+ borderRadius: '6px',
181
+ padding: '6px 14px',
182
+ fontSize: '13px',
183
+ fontWeight: 500,
184
+ cursor: 'pointer',
185
+ whiteSpace: 'nowrap',
186
+ },
187
+ submitBtnDisabled: {
188
+ backgroundColor: '#cbd5e1',
189
+ cursor: 'default',
190
+ },
191
+ mentionPopup: {
192
+ position: 'absolute',
193
+ bottom: '100%',
194
+ left: '12px',
195
+ backgroundColor: '#fff',
196
+ border: '1px solid #e2e8f0',
197
+ borderRadius: '6px',
198
+ boxShadow: '0 4px 6px -1px rgba(0,0,0,.1)',
199
+ maxHeight: '150px',
200
+ overflowY: 'auto',
201
+ zIndex: 10,
202
+ minWidth: '180px',
203
+ },
204
+ mentionItem: {
205
+ display: 'flex',
206
+ alignItems: 'center',
207
+ gap: '8px',
208
+ padding: '6px 10px',
209
+ cursor: 'pointer',
210
+ fontSize: '13px',
211
+ color: '#1e293b',
212
+ },
213
+ mentionItemHighlighted: {
214
+ backgroundColor: '#f1f5f9',
215
+ },
216
+ };
217
+ /** Render comment content with highlighted @mentions */
218
+ function renderContent(content) {
219
+ const parts = content.split(/(@\w+)/g);
220
+ return parts.map((part, i) => {
221
+ if (part.startsWith('@')) {
222
+ return React.createElement('span', { key: i, style: styles.mention }, part);
223
+ }
224
+ return part;
225
+ });
226
+ }
227
+ /**
228
+ * Comment thread component with @mentions support.
229
+ *
230
+ * Renders a list of comments with author avatars, timestamps,
231
+ * reply functionality, and an @mention suggestions popup.
232
+ */
233
+ export function CommentThread({ threadId, comments, currentUser, mentionableUsers = [], onAddComment, onEditComment, onDeleteComment, onResolve, resolved = false, className, }) {
234
+ const [inputValue, setInputValue] = useState('');
235
+ const [replyTo, setReplyTo] = useState(null);
236
+ const [editingId, setEditingId] = useState(null);
237
+ const [editValue, setEditValue] = useState('');
238
+ const [mentionQuery, setMentionQuery] = useState(null);
239
+ const [mentionIndex, setMentionIndex] = useState(0);
240
+ const inputRef = useRef(null);
241
+ const filteredMentions = useMemo(() => {
242
+ if (mentionQuery === null)
243
+ return [];
244
+ const query = mentionQuery.toLowerCase();
245
+ return mentionableUsers.filter(u => u.name.toLowerCase().includes(query) || u.id.toLowerCase().includes(query));
246
+ }, [mentionQuery, mentionableUsers]);
247
+ const handleInputChange = useCallback((e) => {
248
+ const value = e.target.value;
249
+ setInputValue(value);
250
+ // Detect @mention trigger
251
+ const cursorPos = e.target.selectionStart;
252
+ const textBeforeCursor = value.slice(0, cursorPos);
253
+ const mentionMatch = textBeforeCursor.match(/@(\w*)$/);
254
+ if (mentionMatch) {
255
+ setMentionQuery(mentionMatch[1]);
256
+ setMentionIndex(0);
257
+ }
258
+ else {
259
+ setMentionQuery(null);
260
+ }
261
+ }, []);
262
+ const insertMention = useCallback((user) => {
263
+ const textarea = inputRef.current;
264
+ if (!textarea)
265
+ return;
266
+ const cursorPos = textarea.selectionStart;
267
+ const textBeforeCursor = inputValue.slice(0, cursorPos);
268
+ const mentionMatch = textBeforeCursor.match(/@(\w*)$/);
269
+ if (mentionMatch) {
270
+ const before = textBeforeCursor.slice(0, mentionMatch.index);
271
+ const after = inputValue.slice(cursorPos);
272
+ const mentionText = `@${user.name.replace(/\s+/g, '')}`;
273
+ setInputValue(`${before}${mentionText} ${after}`);
274
+ }
275
+ setMentionQuery(null);
276
+ }, [inputValue]);
277
+ const handleKeyDown = useCallback((e) => {
278
+ if (mentionQuery !== null && filteredMentions.length > 0) {
279
+ if (e.key === 'ArrowDown') {
280
+ e.preventDefault();
281
+ setMentionIndex(prev => Math.min(prev + 1, filteredMentions.length - 1));
282
+ }
283
+ else if (e.key === 'ArrowUp') {
284
+ e.preventDefault();
285
+ setMentionIndex(prev => Math.max(prev - 1, 0));
286
+ }
287
+ else if (e.key === 'Enter' || e.key === 'Tab') {
288
+ e.preventDefault();
289
+ insertMention(filteredMentions[mentionIndex]);
290
+ }
291
+ else if (e.key === 'Escape') {
292
+ setMentionQuery(null);
293
+ }
294
+ return;
295
+ }
296
+ if (e.key === 'Enter' && !e.shiftKey) {
297
+ e.preventDefault();
298
+ handleSubmit();
299
+ }
300
+ }, [mentionQuery, filteredMentions, mentionIndex, insertMention]); // eslint-disable-line react-hooks/exhaustive-deps
301
+ const handleSubmit = useCallback(() => {
302
+ const trimmed = inputValue.trim();
303
+ if (!trimmed || !onAddComment)
304
+ return;
305
+ const mentions = parseMentions(trimmed, mentionableUsers);
306
+ onAddComment(trimmed, mentions, replyTo ?? undefined);
307
+ setInputValue('');
308
+ setReplyTo(null);
309
+ setMentionQuery(null);
310
+ }, [inputValue, onAddComment, mentionableUsers, replyTo]);
311
+ const handleEdit = useCallback((commentId) => {
312
+ const comment = comments.find(c => c.id === commentId);
313
+ if (comment) {
314
+ setEditingId(commentId);
315
+ setEditValue(comment.content);
316
+ }
317
+ }, [comments]);
318
+ const handleEditSave = useCallback(() => {
319
+ if (editingId && editValue.trim() && onEditComment) {
320
+ onEditComment(editingId, editValue.trim());
321
+ }
322
+ setEditingId(null);
323
+ setEditValue('');
324
+ }, [editingId, editValue, onEditComment]);
325
+ // Keep mention index in bounds
326
+ useEffect(() => {
327
+ if (mentionIndex >= filteredMentions.length) {
328
+ setMentionIndex(Math.max(0, filteredMentions.length - 1));
329
+ }
330
+ }, [filteredMentions.length, mentionIndex]);
331
+ const rootComments = useMemo(() => comments.filter(c => !c.parentId), [comments]);
332
+ const replies = useMemo(() => comments.filter(c => c.parentId), [comments]);
333
+ const renderComment = (comment, isReply = false) => {
334
+ const isEditing = editingId === comment.id;
335
+ const isOwner = comment.author.id === currentUser.id;
336
+ return React.createElement('div', {
337
+ key: comment.id,
338
+ style: { ...styles.comment, ...(isReply ? styles.reply : {}) },
339
+ 'data-comment-id': comment.id,
340
+ },
341
+ // Avatar
342
+ React.createElement('div', { style: styles.avatar }, comment.author.avatar
343
+ ? React.createElement('img', {
344
+ src: comment.author.avatar,
345
+ alt: comment.author.name,
346
+ style: styles.avatarImg,
347
+ })
348
+ : getInitials(comment.author.name)),
349
+ // Body
350
+ React.createElement('div', { style: styles.commentBody },
351
+ // Header
352
+ React.createElement('div', { style: styles.commentHeader }, React.createElement('span', { style: styles.authorName }, comment.author.name), React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt)), comment.updatedAt
353
+ ? React.createElement('span', { style: styles.timestamp }, '(edited)')
354
+ : null),
355
+ // Content or edit input
356
+ isEditing
357
+ ? React.createElement('div', { style: { display: 'flex', gap: '4px' } }, React.createElement('textarea', {
358
+ value: editValue,
359
+ onChange: (e) => setEditValue(e.target.value),
360
+ style: { ...styles.textarea, flex: 1 },
361
+ rows: 2,
362
+ }), React.createElement('button', {
363
+ onClick: handleEditSave,
364
+ style: { ...styles.submitBtn, padding: '4px 10px', fontSize: '12px' },
365
+ }, 'Save'), React.createElement('button', {
366
+ onClick: () => { setEditingId(null); setEditValue(''); },
367
+ style: { ...styles.actionBtn },
368
+ }, 'Cancel'))
369
+ : React.createElement('div', { style: styles.content }, renderContent(comment.content)),
370
+ // Actions
371
+ !isEditing && React.createElement('div', { style: styles.actions }, React.createElement('button', {
372
+ style: styles.actionBtn,
373
+ onClick: () => setReplyTo(comment.id),
374
+ }, 'Reply'), isOwner && onEditComment && React.createElement('button', {
375
+ style: styles.actionBtn,
376
+ onClick: () => handleEdit(comment.id),
377
+ }, 'Edit'), isOwner && onDeleteComment && React.createElement('button', {
378
+ style: styles.actionBtn,
379
+ onClick: () => onDeleteComment(comment.id),
380
+ }, 'Delete'))));
381
+ };
382
+ return React.createElement('div', {
383
+ style: styles.thread,
384
+ className,
385
+ 'data-thread-id': threadId,
386
+ },
387
+ // Header
388
+ React.createElement('div', { style: styles.header }, React.createElement('span', null, `${comments.length} comment${comments.length !== 1 ? 's' : ''}`, resolved ? ' ยท Resolved' : ''), onResolve && React.createElement('button', {
389
+ style: styles.resolveBtn,
390
+ onClick: () => onResolve(!resolved),
391
+ }, resolved ? 'Reopen' : 'Resolve')),
392
+ // Comments list
393
+ React.createElement('div', { style: styles.commentList }, rootComments.map(comment => React.createElement(React.Fragment, { key: comment.id }, renderComment(comment), replies
394
+ .filter(r => r.parentId === comment.id)
395
+ .map(r => renderComment(r, true))))),
396
+ // Reply indicator
397
+ replyTo && React.createElement('div', {
398
+ style: { padding: '4px 12px', fontSize: '12px', color: '#64748b', backgroundColor: '#f8fafc', display: 'flex', justifyContent: 'space-between' },
399
+ }, React.createElement('span', null, `Replying to ${comments.find(c => c.id === replyTo)?.author.name ?? 'comment'}...`), React.createElement('button', {
400
+ style: styles.actionBtn,
401
+ onClick: () => setReplyTo(null),
402
+ }, 'โœ•')),
403
+ // Input area
404
+ React.createElement('div', { style: styles.inputArea },
405
+ // Mention popup
406
+ mentionQuery !== null && filteredMentions.length > 0 && React.createElement('div', { style: styles.mentionPopup }, filteredMentions.map((user, idx) => React.createElement('div', {
407
+ key: user.id,
408
+ style: { ...styles.mentionItem, ...(idx === mentionIndex ? styles.mentionItemHighlighted : {}) },
409
+ onMouseDown: (e) => {
410
+ e.preventDefault();
411
+ insertMention(user);
412
+ },
413
+ onMouseEnter: () => setMentionIndex(idx),
414
+ }, user.avatar
415
+ ? React.createElement('img', {
416
+ src: user.avatar,
417
+ alt: user.name,
418
+ style: { width: '20px', height: '20px', borderRadius: '50%' },
419
+ })
420
+ : React.createElement('span', {
421
+ style: { ...styles.avatar, width: '20px', height: '20px', fontSize: '9px' },
422
+ }, getInitials(user.name)), user.name))), React.createElement('textarea', {
423
+ ref: inputRef,
424
+ value: inputValue,
425
+ onChange: handleInputChange,
426
+ onKeyDown: handleKeyDown,
427
+ placeholder: 'Add a comment... (use @ to mention)',
428
+ style: styles.textarea,
429
+ rows: 1,
430
+ }), React.createElement('button', {
431
+ onClick: handleSubmit,
432
+ disabled: !inputValue.trim(),
433
+ style: {
434
+ ...styles.submitBtn,
435
+ ...(!inputValue.trim() ? styles.submitBtnDisabled : {}),
436
+ },
437
+ }, 'Send')));
438
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import React from 'react';
9
+ import type { PresenceUser } from './usePresence';
10
+ export interface LiveCursorsProps {
11
+ /** Other users' presence data */
12
+ users: PresenceUser[];
13
+ /** Container ref for relative positioning */
14
+ containerRef?: React.RefObject<HTMLElement>;
15
+ /** Whether to show user names next to cursors */
16
+ showNames?: boolean;
17
+ /** Whether to show user avatars */
18
+ showAvatars?: boolean;
19
+ /** Cursor size in pixels (default: 20) */
20
+ cursorSize?: number;
21
+ /** Fade out idle cursors */
22
+ fadeIdle?: boolean;
23
+ /** Additional className */
24
+ className?: string;
25
+ }
26
+ /**
27
+ * Live cursors component displaying other users' cursor positions.
28
+ *
29
+ * Renders absolutely-positioned cursor SVGs with smooth CSS transitions,
30
+ * user name labels, and fade-out for idle users.
31
+ */
32
+ export declare function LiveCursors({ users, containerRef: _containerRef, showNames, showAvatars, cursorSize, fadeIdle, className, }: LiveCursorsProps): React.ReactElement;
33
+ //# sourceMappingURL=LiveCursors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LiveCursors.d.ts","sourceRoot":"","sources":["../src/LiveCursors.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAkB,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,WAAW,gBAAgB;IAC/B,iCAAiC;IACjC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,6CAA6C;IAC7C,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC;IAC5C,iDAAiD;IACjD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,mCAAmC;IACnC,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2BAA2B;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA2DD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,YAAY,EAAE,aAAa,EAC3B,SAAgB,EAChB,WAAmB,EACnB,UAAe,EACf,QAAe,EACf,SAAS,GACV,EAAE,gBAAgB,GAAG,KAAK,CAAC,YAAY,CA2CvC"}