@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 +21 -0
- package/README.md +121 -0
- package/dist/CommentThread.d.ts +61 -0
- package/dist/CommentThread.d.ts.map +1 -0
- package/dist/CommentThread.js +438 -0
- package/dist/LiveCursors.d.ts +33 -0
- package/dist/LiveCursors.d.ts.map +1 -0
- package/dist/LiveCursors.js +100 -0
- package/dist/PresenceAvatars.d.ts +29 -0
- package/dist/PresenceAvatars.d.ts.map +1 -0
- package/dist/PresenceAvatars.js +113 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/useConflictResolution.d.ts +72 -0
- package/dist/useConflictResolution.d.ts.map +1 -0
- package/dist/useConflictResolution.js +211 -0
- package/dist/usePresence.d.ts +82 -0
- package/dist/usePresence.d.ts.map +1 -0
- package/dist/usePresence.js +174 -0
- package/dist/useRealtimeSubscription.d.ts +59 -0
- package/dist/useRealtimeSubscription.d.ts.map +1 -0
- package/dist/useRealtimeSubscription.js +224 -0
- package/package.json +44 -0
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"}
|