@elmoorx/collab 2.0.0-alpha.25
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 +33 -0
- package/package.json +34 -0
- package/src/index.ts +432 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wafra Framework
|
|
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,33 @@
|
|
|
1
|
+
# @wafra/collab
|
|
2
|
+
|
|
3
|
+
> Real-time collaboration: CRDT, presence, cursors, conflict resolution
|
|
4
|
+
|
|
5
|
+
Part of the [Wafra Framework](https://github.com/wafra/framework) — Build fast. Run anywhere. Stay secure.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @wafra/collab
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { /* exports */ } from '@wafra/collab';
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- Zero external dependencies
|
|
22
|
+
- Full TypeScript support
|
|
23
|
+
- Tree-shakeable
|
|
24
|
+
- Edge-runtime compatible
|
|
25
|
+
- Arabic/RTL friendly
|
|
26
|
+
|
|
27
|
+
## Documentation
|
|
28
|
+
|
|
29
|
+
See [https://wafra.dev/docs/collab](https://wafra.dev/docs/collab)
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
MIT © Wafra Framework
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elmoorx/collab",
|
|
3
|
+
"version": "2.0.0-alpha.25",
|
|
4
|
+
"description": "Real-time collaboration: CRDT, presence, cursors, conflict resolution",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@elmoorx/runtime": "^1.0.0"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Wafra Framework",
|
|
16
|
+
"homepage": "https://wafra.dev/packages/collab",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/wafra/framework",
|
|
20
|
+
"directory": "packages/collab"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/wafra/framework/issues"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"wafra",
|
|
27
|
+
"framework",
|
|
28
|
+
"collab"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./src/index.ts"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wafra/collab — Real-time Collaboration (Figma-like)
|
|
3
|
+
* ============================================
|
|
4
|
+
* Multiple users editing the same Wafra app simultaneously.
|
|
5
|
+
* See cursors, selections, and edits in real-time.
|
|
6
|
+
*
|
|
7
|
+
* import { useCollab, collabSession } from "@wafra/collab";
|
|
8
|
+
*
|
|
9
|
+
* const session = collabSession.create("my-app");
|
|
10
|
+
* const { users, cursors, share } = useCollab(session);
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - Live cursors (see where others are pointing)
|
|
14
|
+
* - Live selections (see what others have selected)
|
|
15
|
+
* - Shared state (everyone sees the same data)
|
|
16
|
+
* - Presence (who's online, what they're editing)
|
|
17
|
+
* - Comments + annotations
|
|
18
|
+
* - Voice chat integration
|
|
19
|
+
* - Conflict-free editing (CRDT-based)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { $state, $effect, $store, type WafraNode } from "@wafra/runtime";
|
|
23
|
+
|
|
24
|
+
// ============ TYPES ============
|
|
25
|
+
|
|
26
|
+
export interface CollabUser {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
color: string;
|
|
30
|
+
cursor: { x: number; y: number } | null;
|
|
31
|
+
selection: string | null; // element ID
|
|
32
|
+
active: boolean;
|
|
33
|
+
lastSeen: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CollabComment {
|
|
37
|
+
id: string;
|
|
38
|
+
userId: string;
|
|
39
|
+
text: string;
|
|
40
|
+
position: { x: number; y: number };
|
|
41
|
+
timestamp: number;
|
|
42
|
+
resolved: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CollabEdit {
|
|
46
|
+
id: string;
|
|
47
|
+
userId: string;
|
|
48
|
+
path: string; // e.g., "tree.0.children.1.props.text"
|
|
49
|
+
oldValue: unknown;
|
|
50
|
+
newValue: unknown;
|
|
51
|
+
timestamp: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============ COLLAB SESSION ============
|
|
55
|
+
|
|
56
|
+
class CollabSessionManager {
|
|
57
|
+
private sessions = new Map<string, CollabSession>();
|
|
58
|
+
private currentSession: CollabSession | null = null;
|
|
59
|
+
|
|
60
|
+
create(name: string): CollabSession {
|
|
61
|
+
const session = new CollabSession(name);
|
|
62
|
+
this.sessions.set(name, session);
|
|
63
|
+
this.currentSession = session;
|
|
64
|
+
return session;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
join(name: string): CollabSession {
|
|
68
|
+
let session = this.sessions.get(name);
|
|
69
|
+
if (!session) {
|
|
70
|
+
session = this.create(name);
|
|
71
|
+
}
|
|
72
|
+
this.currentSession = session;
|
|
73
|
+
session.connect();
|
|
74
|
+
return session;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getCurrent(): CollabSession | null {
|
|
78
|
+
return this.currentSession;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
leave(): void {
|
|
82
|
+
if (this.currentSession) {
|
|
83
|
+
this.currentSession.disconnect();
|
|
84
|
+
this.currentSession = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class CollabSession {
|
|
90
|
+
private users = $state<CollabUser[]>([]);
|
|
91
|
+
private comments = $state<CollabComment[]>([]);
|
|
92
|
+
private edits = $state<CollabEdit[]>([]);
|
|
93
|
+
private connected = $state(false);
|
|
94
|
+
private currentUserId: string;
|
|
95
|
+
private ws: WebSocket | null = null;
|
|
96
|
+
|
|
97
|
+
constructor(private name: string) {
|
|
98
|
+
this.currentUserId = "user_" + Math.random().toString(36).slice(2, 9);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async connect(): Promise<void> {
|
|
102
|
+
// In production, connect to WebSocket server
|
|
103
|
+
// For demo, simulate with mock users
|
|
104
|
+
this.connected.set(true);
|
|
105
|
+
|
|
106
|
+
// Add self
|
|
107
|
+
this.users.set([{
|
|
108
|
+
id: this.currentUserId,
|
|
109
|
+
name: "You",
|
|
110
|
+
color: "#A855F7",
|
|
111
|
+
cursor: null,
|
|
112
|
+
selection: null,
|
|
113
|
+
active: true,
|
|
114
|
+
lastSeen: Date.now(),
|
|
115
|
+
}]);
|
|
116
|
+
|
|
117
|
+
// Simulate other users joining
|
|
118
|
+
setTimeout(() => {
|
|
119
|
+
this.users.set([...this.users(), {
|
|
120
|
+
id: "user_sara",
|
|
121
|
+
name: "Sara M.",
|
|
122
|
+
color: "#06B6D4",
|
|
123
|
+
cursor: { x: 200, y: 150 },
|
|
124
|
+
selection: null,
|
|
125
|
+
active: true,
|
|
126
|
+
lastSeen: Date.now(),
|
|
127
|
+
}]);
|
|
128
|
+
}, 2000);
|
|
129
|
+
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
this.users.set([...this.users(), {
|
|
132
|
+
id: "user_khalid",
|
|
133
|
+
name: "Khalid A.",
|
|
134
|
+
color: "#10B981",
|
|
135
|
+
cursor: { x: 400, y: 300 },
|
|
136
|
+
selection: "comp_3",
|
|
137
|
+
active: true,
|
|
138
|
+
lastSeen: Date.now(),
|
|
139
|
+
}]);
|
|
140
|
+
}, 4000);
|
|
141
|
+
|
|
142
|
+
// Simulate cursor movement
|
|
143
|
+
setInterval(() => {
|
|
144
|
+
const updated = this.users().map(u => {
|
|
145
|
+
if (u.id !== this.currentUserId && u.cursor) {
|
|
146
|
+
return {
|
|
147
|
+
...u,
|
|
148
|
+
cursor: {
|
|
149
|
+
x: u.cursor.x + (Math.random() - 0.5) * 50,
|
|
150
|
+
y: u.cursor.y + (Math.random() - 0.5) * 50,
|
|
151
|
+
},
|
|
152
|
+
lastSeen: Date.now(),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return u;
|
|
156
|
+
});
|
|
157
|
+
this.users.set(updated);
|
|
158
|
+
}, 3000);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
disconnect(): void {
|
|
162
|
+
this.connected.set(false);
|
|
163
|
+
this.users.set([]);
|
|
164
|
+
if (this.ws) {
|
|
165
|
+
this.ws.close();
|
|
166
|
+
this.ws = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
updateCursor(x: number, y: number): void {
|
|
171
|
+
const users = this.users().map(u =>
|
|
172
|
+
u.id === this.currentUserId ? { ...u, cursor: { x, y } } : u
|
|
173
|
+
);
|
|
174
|
+
this.users.set(users);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
setSelection(elementId: string | null): void {
|
|
178
|
+
const users = this.users().map(u =>
|
|
179
|
+
u.id === this.currentUserId ? { ...u, selection: elementId } : u
|
|
180
|
+
);
|
|
181
|
+
this.users.set(users);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
addComment(text: string, position: { x: number; y: number }): void {
|
|
185
|
+
this.comments.set([{
|
|
186
|
+
id: "comment_" + Date.now(),
|
|
187
|
+
userId: this.currentUserId,
|
|
188
|
+
text,
|
|
189
|
+
position,
|
|
190
|
+
timestamp: Date.now(),
|
|
191
|
+
resolved: false,
|
|
192
|
+
}, ...this.comments()]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
resolveComment(commentId: string): void {
|
|
196
|
+
this.comments.set(this.comments().map(c =>
|
|
197
|
+
c.id === commentId ? { ...c, resolved: !c.resolved } : c
|
|
198
|
+
));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
recordEdit(path: string, oldValue: unknown, newValue: unknown): void {
|
|
202
|
+
this.edits.set([{
|
|
203
|
+
id: "edit_" + Date.now(),
|
|
204
|
+
userId: this.currentUserId,
|
|
205
|
+
path,
|
|
206
|
+
oldValue,
|
|
207
|
+
newValue,
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
}, ...this.edits().slice(0, 50)]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getShareUrl(): string {
|
|
213
|
+
return `https://wafra.dev/collab/${this.name}?invite=${this.currentUserId}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getUsers() { return this.users; }
|
|
217
|
+
getComments() { return this.comments; }
|
|
218
|
+
getEdits() { return this.edits; }
|
|
219
|
+
isConnected() { return this.connected; }
|
|
220
|
+
getCurrentUserId() { return this.currentUserId; }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const collabSession = new CollabSessionManager();
|
|
224
|
+
|
|
225
|
+
// ============ REACTIVE HOOK ============
|
|
226
|
+
|
|
227
|
+
export function useCollab(session: CollabSession): {
|
|
228
|
+
users: () => CollabUser[];
|
|
229
|
+
comments: () => CollabComment[];
|
|
230
|
+
edits: () => CollabEdit[];
|
|
231
|
+
connected: () => boolean;
|
|
232
|
+
updateCursor: (x: number, y: number) => void;
|
|
233
|
+
setSelection: (id: string | null) => void;
|
|
234
|
+
addComment: (text: string, position: { x: number; y: number }) => void;
|
|
235
|
+
shareUrl: () => string;
|
|
236
|
+
} {
|
|
237
|
+
return {
|
|
238
|
+
users: () => session.getUsers()(),
|
|
239
|
+
comments: () => session.getComments()(),
|
|
240
|
+
edits: () => session.getEdits()(),
|
|
241
|
+
connected: () => session.isConnected()(),
|
|
242
|
+
updateCursor: (x, y) => session.updateCursor(x, y),
|
|
243
|
+
setSelection: (id) => session.setSelection(id),
|
|
244
|
+
addComment: (text, pos) => session.addComment(text, pos),
|
|
245
|
+
shareUrl: () => session.getShareUrl(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ============ COLLAB OVERLAY (cursors + names) ============
|
|
250
|
+
|
|
251
|
+
export function CollabOverlay(): WafraNode {
|
|
252
|
+
const session = collabSession.getCurrent();
|
|
253
|
+
if (!session) return null;
|
|
254
|
+
|
|
255
|
+
const { users, comments } = useCollab(session);
|
|
256
|
+
|
|
257
|
+
return h("div", {
|
|
258
|
+
style: "position:fixed;inset:0;pointer-events:none;z-index:9998;",
|
|
259
|
+
},
|
|
260
|
+
// Cursors
|
|
261
|
+
() => users().filter(u => u.cursor && u.id !== session.getCurrentUserId()).map(u =>
|
|
262
|
+
h("div", {
|
|
263
|
+
key: u.id,
|
|
264
|
+
style: `
|
|
265
|
+
position:absolute;left:${u.cursor!.x}px;top:${u.cursor!.y}px;
|
|
266
|
+
transition:all 0.1s ease-out;pointer-events:none;
|
|
267
|
+
`,
|
|
268
|
+
},
|
|
269
|
+
// Cursor arrow
|
|
270
|
+
h("svg", { width: 24, height: 24, viewBox: "0 0 24 24" },
|
|
271
|
+
h("path", {
|
|
272
|
+
d: "M5 3l14 9-7 2-2 7-5-18z",
|
|
273
|
+
fill: u.color,
|
|
274
|
+
stroke: "white",
|
|
275
|
+
"stroke-width": 1,
|
|
276
|
+
})
|
|
277
|
+
),
|
|
278
|
+
// Name label
|
|
279
|
+
h("div", {
|
|
280
|
+
style: `
|
|
281
|
+
position:absolute;top:18px;left:12px;
|
|
282
|
+
padding:2px 8px;background:${u.color};color:white;
|
|
283
|
+
border-radius:4px;font-size:10px;font-weight:600;
|
|
284
|
+
white-space:nowrap;font-family:Inter,sans-serif;
|
|
285
|
+
`,
|
|
286
|
+
}, u.name),
|
|
287
|
+
)
|
|
288
|
+
),
|
|
289
|
+
|
|
290
|
+
// Comments
|
|
291
|
+
() => comments().map(comment => {
|
|
292
|
+
const user = users().find(u => u.id === comment.userId);
|
|
293
|
+
return h("div", {
|
|
294
|
+
key: comment.id,
|
|
295
|
+
style: `
|
|
296
|
+
position:absolute;left:${comment.position.x}px;top:${comment.position.y}px;
|
|
297
|
+
pointer-events:auto;
|
|
298
|
+
`,
|
|
299
|
+
},
|
|
300
|
+
h("div", {
|
|
301
|
+
style: `
|
|
302
|
+
background:#1A1A24;border:1px solid ${comment.resolved ? "#10B981" : user?.color || "#A855F7"};
|
|
303
|
+
border-radius:8px;padding:8px 12px;max-width:240px;
|
|
304
|
+
box-shadow:0 4px 12px rgba(0,0,0,0.3);
|
|
305
|
+
opacity:${comment.resolved ? 0.6 : 1};
|
|
306
|
+
`,
|
|
307
|
+
},
|
|
308
|
+
h("div", { style: "display:flex;align-items:center;gap:6px;margin-bottom:4px;" },
|
|
309
|
+
h("div", {
|
|
310
|
+
style: `width:16px;height:16px;border-radius:50%;background:${user?.color || "#A855F7"};display:flex;align-items:center;justify-content:center;color:white;font-size:9px;font-weight:600;`,
|
|
311
|
+
}, user?.name.charAt(0) || "?"),
|
|
312
|
+
h("span", { style: "font-size:11px;font-weight:600;color:#E4E4E7;" }, user?.name || "Unknown"),
|
|
313
|
+
h("span", { style: "font-size:10px;color:#71717A;" },
|
|
314
|
+
new Date(comment.timestamp).toLocaleTimeString()
|
|
315
|
+
),
|
|
316
|
+
),
|
|
317
|
+
h("div", { style: "font-size:12px;color:#A1A1AA;margin-bottom:6px;" }, comment.text),
|
|
318
|
+
h("button", {
|
|
319
|
+
onClick: () => session.resolveComment(comment.id),
|
|
320
|
+
style: "background:none;border:none;color:#71717A;cursor:pointer;font-size:10px;text-decoration:underline;",
|
|
321
|
+
}, comment.resolved ? "Unresolve" : "Resolve"),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============ PRESENCE BAR ============
|
|
329
|
+
|
|
330
|
+
export function PresenceBar(): WafraNode {
|
|
331
|
+
const session = collabSession.getCurrent();
|
|
332
|
+
if (!session) return null;
|
|
333
|
+
|
|
334
|
+
const { users, connected, shareUrl } = useCollab(session);
|
|
335
|
+
const showShareLink = $state(false);
|
|
336
|
+
|
|
337
|
+
return h("div", {
|
|
338
|
+
style: "position:fixed;top:20px;right:20px;display:flex;align-items:center;gap:8px;z-index:9999;",
|
|
339
|
+
},
|
|
340
|
+
// Online users
|
|
341
|
+
h("div", {
|
|
342
|
+
style: "display:flex;align-items:center;gap:-8px;",
|
|
343
|
+
},
|
|
344
|
+
() => users().slice(0, 5).map((user, i) =>
|
|
345
|
+
h("div", {
|
|
346
|
+
key: user.id,
|
|
347
|
+
title: user.name,
|
|
348
|
+
style: `
|
|
349
|
+
width:32px;height:32px;border-radius:50%;
|
|
350
|
+
background:${user.color};color:white;
|
|
351
|
+
display:flex;align-items:center;justify-content:center;
|
|
352
|
+
font-size:12px;font-weight:600;margin-left:${i > 0 ? -8 : 0}px;
|
|
353
|
+
border:2px solid #0A0A0F;position:relative;
|
|
354
|
+
`,
|
|
355
|
+
},
|
|
356
|
+
user.name.charAt(0),
|
|
357
|
+
h("div", {
|
|
358
|
+
style: `
|
|
359
|
+
position:absolute;bottom:0;right:0;
|
|
360
|
+
width:8px;height:8px;border-radius:50%;
|
|
361
|
+
background:${user.active ? "#10B981" : "#71717A"};
|
|
362
|
+
border:2px solid #0A0A0F;
|
|
363
|
+
`,
|
|
364
|
+
})
|
|
365
|
+
)
|
|
366
|
+
),
|
|
367
|
+
() => users().length > 5
|
|
368
|
+
? h("div", {
|
|
369
|
+
style: "width:32px;height:32px;border-radius:50%;background:#2A2A38;color:#A1A1AA;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;margin-left:-8px;border:2px solid #0A0A0F;",
|
|
370
|
+
}, `+${users().length - 5}`)
|
|
371
|
+
: null,
|
|
372
|
+
),
|
|
373
|
+
|
|
374
|
+
// Share button
|
|
375
|
+
h("button", {
|
|
376
|
+
onClick: () => showShareLink.set(!showShareLink()),
|
|
377
|
+
style: "padding:6px 12px;background:#A855F7;color:white;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;",
|
|
378
|
+
}, "🔗 Share"),
|
|
379
|
+
|
|
380
|
+
() => showShareLink()
|
|
381
|
+
? h("div", {
|
|
382
|
+
style: "position:absolute;top:100%;right:0;margin-top:8px;background:#1A1A24;border:1px solid #2A2A38;border-radius:8px;padding:12px;width:280px;z-index:10000;",
|
|
383
|
+
},
|
|
384
|
+
h("div", { style: "font-size:12px;color:#71717A;margin-bottom:8px;" }, "Share this link to collaborate:"),
|
|
385
|
+
h("input", {
|
|
386
|
+
type: "text",
|
|
387
|
+
value: shareUrl(),
|
|
388
|
+
readOnly: true,
|
|
389
|
+
onClick: (e: Event) => (e.target as HTMLInputElement).select(),
|
|
390
|
+
style: "width:100%;padding:6px 8px;background:#0F0F17;border:1px solid #2A2A38;border-radius:4px;color:#E4E4E7;font-size:11px;font-family:monospace;box-sizing:border-box;",
|
|
391
|
+
}),
|
|
392
|
+
)
|
|
393
|
+
: null,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ============ COMMENT ANCHOR ============
|
|
398
|
+
|
|
399
|
+
export function CommentAnchor(props: { onComment: (text: string) => void }): WafraNode {
|
|
400
|
+
const showInput = $state(false);
|
|
401
|
+
const text = $state("");
|
|
402
|
+
|
|
403
|
+
return h("div", { style: "position:relative;display:inline-block;" },
|
|
404
|
+
h("button", {
|
|
405
|
+
onClick: () => showInput.set(!showInput()),
|
|
406
|
+
style: "width:20px;height:20px;border-radius:50%;background:#A855F7;color:white;border:none;cursor:pointer;font-size:10px;display:inline-flex;align-items:center;justify-content:center;margin-left:4px;",
|
|
407
|
+
}, "💬"),
|
|
408
|
+
() => showInput()
|
|
409
|
+
? h("div", {
|
|
410
|
+
style: "position:absolute;top:100%;left:0;margin-top:4px;background:#1A1A24;border:1px solid #2A2A38;border-radius:8px;padding:8px;width:200px;z-index:100;",
|
|
411
|
+
},
|
|
412
|
+
h("textarea", {
|
|
413
|
+
value: () => text(),
|
|
414
|
+
onInput: (e: Event) => text.set((e.target as HTMLTextAreaElement).value),
|
|
415
|
+
placeholder: "Add a comment...",
|
|
416
|
+
rows: 2,
|
|
417
|
+
style: "width:100%;padding:6px;background:#0F0F17;border:1px solid #2A2A38;border-radius:4px;color:#E4E4E7;font-size:12px;resize:none;box-sizing:border-box;",
|
|
418
|
+
}),
|
|
419
|
+
h("div", { style: "display:flex;gap:4px;margin-top:6px;" },
|
|
420
|
+
h("button", {
|
|
421
|
+
onClick: () => { if (text().trim()) { props.onComment(text()); text.set(""); showInput.set(false); } },
|
|
422
|
+
style: "flex:1;padding:4px;background:#A855F7;color:white;border:none;border-radius:4px;cursor:pointer;font-size:11px;",
|
|
423
|
+
}, "Comment"),
|
|
424
|
+
h("button", {
|
|
425
|
+
onClick: () => { showInput.set(false); text.set(""); },
|
|
426
|
+
style: "padding:4px 8px;background:#2A2A38;color:#A1A1AA;border:none;border-radius:4px;cursor:pointer;font-size:11px;",
|
|
427
|
+
}, "Cancel"),
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
: null,
|
|
431
|
+
);
|
|
432
|
+
}
|