mnfst 0.5.163 → 0.5.165

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.
@@ -1,1650 +0,0 @@
1
- /* Manifest Data Sources - Presence Utilities */
2
-
3
- // Track active presence subscriptions
4
- const presenceSubscriptions = new Map(); // Map<channelId, { unsubscribe, cursors, updateInterval }>
5
-
6
- // Cursor position tracking per channel
7
- const cursorPositions = new Map(); // Map<channelId, { x, y }>
8
-
9
- // Generate a color for a user based on their ID
10
- function getUserColor(userId) {
11
- if (!userId) return '#666';
12
-
13
- // Simple hash function to generate consistent color
14
- let hash = 0;
15
- for (let i = 0; i < userId.length; i++) {
16
- hash = userId.charCodeAt(i) + ((hash << 5) - hash);
17
- }
18
-
19
- // Generate a bright, saturated color
20
- const hue = Math.abs(hash) % 360;
21
- return `hsl(${hue}, 70%, 50%)`;
22
- }
23
-
24
- // Smooth cursor interpolation using velocity (dead reckoning)
25
- // This allows smooth cursor rendering between updates without frequent server writes
26
- // Based on techniques used by Figma, Google Docs, and other collaborative tools
27
- function interpolateCursorPosition(lastKnown, velocity, elapsedMs) {
28
- if (!lastKnown || !velocity) {
29
- return lastKnown;
30
- }
31
-
32
- // Calculate predicted position based on velocity (dead reckoning)
33
- // Position = LastKnown + Velocity * Time
34
- const elapsedSeconds = elapsedMs / 1000;
35
- const predictedX = lastKnown.x + (velocity.vx || 0) * elapsedSeconds;
36
- const predictedY = lastKnown.y + (velocity.vy || 0) * elapsedSeconds;
37
-
38
- // Apply damping to velocity (gradually slow down if no new updates)
39
- // This prevents cursors from flying off screen if user stops moving
40
- const dampingFactor = Math.max(0, 1 - (elapsedMs / 2000)); // Full stop after 2 seconds
41
- const dampedVx = (velocity.vx || 0) * dampingFactor;
42
- const dampedVy = (velocity.vy || 0) * dampingFactor;
43
-
44
- return {
45
- x: predictedX,
46
- y: predictedY,
47
- vx: dampedVx,
48
- vy: dampedVy,
49
- interpolated: true // Flag to indicate this is interpolated, not actual position
50
- };
51
- }
52
-
53
- // Linear interpolation between two points (for rendering smooth paths)
54
- function lerp(start, end, t) {
55
- // t should be between 0 and 1
56
- return start + (end - start) * t;
57
- }
58
-
59
- // Smooth interpolation with easing (ease-out for natural deceleration)
60
- function smoothInterpolate(start, end, t) {
61
- // Ease-out cubic: t * (2 - t)
62
- const easedT = t * (2 - t);
63
- return lerp(start, end, easedT);
64
- }
65
-
66
- // Get user info from auth store
67
- function getUserInfo() {
68
- if (typeof Alpine === 'undefined') return null;
69
-
70
- const authStore = Alpine.store('auth');
71
- if (!authStore || !authStore.user) return null;
72
-
73
- return {
74
- id: authStore.user.$id,
75
- name: authStore.user.name || authStore.user.email || 'Anonymous',
76
- email: authStore.user.email || null,
77
- color: getUserColor(authStore.user.$id)
78
- };
79
- }
80
-
81
- // Get Appwrite services
82
- async function getAppwriteServices() {
83
- return await window.ManifestDataAppwrite._getAppwriteDataServices();
84
- }
85
-
86
- // Read CSS variable value (returns number in specified unit, or fallback)
87
- function getCSSVariableValue(variableName, unit = 'ms', fallback = 0) {
88
- if (typeof document === 'undefined') return fallback;
89
-
90
- const value = getComputedStyle(document.documentElement)
91
- .getPropertyValue(variableName)
92
- .trim();
93
-
94
- if (!value) return fallback;
95
-
96
- // Remove unit and parse as number
97
- const numValue = parseFloat(value);
98
- if (isNaN(numValue)) return fallback;
99
-
100
- // Convert to milliseconds if needed (for px values, return as-is)
101
- if (unit === 'ms' && value.endsWith('px')) {
102
- // For pixel values, return as-is (they're already in pixels)
103
- return numValue;
104
- } else if (unit === 'ms' && value.endsWith('ms')) {
105
- return numValue;
106
- } else if (unit === 'px' && value.endsWith('px')) {
107
- return numValue;
108
- }
109
-
110
- return numValue;
111
- }
112
-
113
- // Get presence configuration from manifest
114
- async function getPresenceConfig() {
115
- try {
116
- const manifest = await window.ManifestDataConfig?.ensureManifest?.();
117
- return manifest?.data?.presence || {};
118
- } catch (error) {
119
- console.warn('[Manifest Presence] Failed to load manifest config:', error);
120
- return {};
121
- }
122
- }
123
-
124
-
125
- /* Manifest Data Sources - Presence Element Utilities */
126
-
127
- // Generate a unique ID for an element if it doesn't have one
128
- function getElementId(element, containerElement) {
129
- if (!element) return null;
130
- if (element.id) return element.id;
131
- if (element.dataset && element.dataset.presenceId) return element.dataset.presenceId;
132
- // Generate a stable ID based on element's position in DOM
133
- const path = [];
134
- let current = element;
135
- while (current && current !== containerElement && current !== document.body) {
136
- const parent = current.parentElement;
137
- if (parent) {
138
- const index = Array.from(parent.children).indexOf(current);
139
- path.unshift(`${current.tagName.toLowerCase()}:${index}`);
140
- }
141
- current = parent;
142
- }
143
- return path.join('>') || null;
144
- }
145
-
146
- // Helper function to find element by ID (supports id, data-presence-id, and DOM path)
147
- function findElementById(elementId, containerElement) {
148
- if (!elementId) return null;
149
- // Try by ID first
150
- let targetElement = document.getElementById(elementId);
151
- if (targetElement && containerElement.contains(targetElement)) return targetElement;
152
- // Try by data-presence-id
153
- targetElement = document.querySelector(`[data-presence-id="${elementId}"]`);
154
- if (targetElement && containerElement.contains(targetElement)) return targetElement;
155
- // Try to find by DOM path (generated by getElementId)
156
- // IMPORTANT: getElementId counts ALL children, not filtered by tagName
157
- if (elementId.includes('>')) {
158
- const pathParts = elementId.split('>');
159
- let current = containerElement;
160
- for (const part of pathParts) {
161
- if (!current) break;
162
- const [tagName, indexStr] = part.split(':');
163
- const index = parseInt(indexStr, 10);
164
- if (isNaN(index)) break;
165
- // Count ALL children (not filtered), matching getElementId behavior
166
- const allChildren = Array.from(current.children);
167
- if (index >= 0 && index < allChildren.length) {
168
- const child = allChildren[index];
169
- // Verify tagName matches (safety check)
170
- if (child.tagName.toLowerCase() === tagName.toLowerCase()) {
171
- current = child;
172
- } else {
173
- console.warn(`[Presence Debug] TagName mismatch at path step "${part}": expected ${tagName}, got ${child.tagName.toLowerCase()}`);
174
- current = null;
175
- break;
176
- }
177
- } else {
178
- console.warn(`[Presence Debug] Index out of bounds at path step "${part}": index ${index}, children count ${allChildren.length}`);
179
- current = null;
180
- break;
181
- }
182
- }
183
- if (current && containerElement.contains(current)) return current;
184
- }
185
- return null;
186
- }
187
-
188
- // Helper function to apply visual indicators for a user's presence state
189
- function applyVisualIndicators(userId, focus, selection, editing, userColor, containerElement) {
190
- const color = userColor || getUserColor(userId);
191
-
192
- // Apply focus indicator
193
- if (focus && focus.elementId) {
194
- const targetElement = findElementById(focus.elementId, containerElement);
195
- if (targetElement) {
196
- targetElement.classList.add('presence-focused');
197
- targetElement.setAttribute('data-presence-focus-user', userId);
198
- targetElement.setAttribute('data-presence-focus-color', color);
199
- } else {
200
- }
201
- }
202
-
203
- // Apply selection indicator
204
- if (selection && selection.elementId) {
205
- const targetElement = findElementById(selection.elementId, containerElement);
206
- if (targetElement) {
207
- const start = selection.start !== undefined ? selection.start : (selection.startOffset || 0);
208
- const end = selection.end !== undefined ? selection.end : (selection.endOffset || start);
209
- targetElement.setAttribute('data-presence-selection-user', userId);
210
- targetElement.setAttribute('data-presence-selection-start', start.toString());
211
- targetElement.setAttribute('data-presence-selection-end', end.toString());
212
- targetElement.setAttribute('data-presence-selection-color', color);
213
- // Trigger custom event for selection rendering
214
- targetElement.dispatchEvent(new CustomEvent('presence:selection', {
215
- detail: { userId, selection: { start, end }, color }
216
- }));
217
- }
218
- }
219
-
220
- // Apply caret indicator
221
- if (editing && editing.elementId && editing.caretPosition !== null && editing.caretPosition !== undefined) {
222
- const targetElement = findElementById(editing.elementId, containerElement);
223
- if (targetElement) {
224
- targetElement.setAttribute('data-presence-caret-user', userId);
225
- targetElement.setAttribute('data-presence-caret-position', editing.caretPosition.toString());
226
- targetElement.setAttribute('data-presence-caret-color', color);
227
- // Trigger custom event for caret rendering
228
- targetElement.dispatchEvent(new CustomEvent('presence:caret', {
229
- detail: { userId, caretPosition: editing.caretPosition, color }
230
- }));
231
- }
232
- }
233
- }
234
-
235
- // Helper function to update element value (agnostic for input/textarea/contenteditable)
236
- function updateElementValue(targetElement, newValue, caretPosition) {
237
- if (!targetElement) return false;
238
-
239
- try {
240
- // Check if element is editable
241
- const isEditable = targetElement.tagName === 'INPUT' ||
242
- targetElement.tagName === 'TEXTAREA' ||
243
- targetElement.isContentEditable;
244
-
245
- if (!isEditable) return false;
246
-
247
- // Temporarily disable input event to prevent infinite loop
248
- const wasDisabled = targetElement.hasAttribute('data-presence-syncing');
249
- targetElement.setAttribute('data-presence-syncing', 'true');
250
-
251
- // Update value based on element type
252
- if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') {
253
- const currentValue = targetElement.value || '';
254
- if (currentValue !== newValue) {
255
- targetElement.value = newValue;
256
- // Set caret position if provided
257
- if (caretPosition !== null && caretPosition !== undefined &&
258
- targetElement.setSelectionRange) {
259
- try {
260
- targetElement.setSelectionRange(caretPosition, caretPosition);
261
- } catch (e) {
262
- // Some input types don't support setSelectionRange
263
- }
264
- }
265
- // Trigger input event for reactivity (but mark it as synced)
266
- const inputEvent = new Event('input', { bubbles: true });
267
- targetElement.dispatchEvent(inputEvent);
268
- }
269
- } else if (targetElement.isContentEditable) {
270
- const currentValue = targetElement.textContent || '';
271
- if (currentValue !== newValue) {
272
- targetElement.textContent = newValue;
273
- // Try to set caret position for contenteditable
274
- if (caretPosition !== null && caretPosition !== undefined) {
275
- try {
276
- const range = document.createRange();
277
- const selection = window.getSelection();
278
- const textNode = targetElement.firstChild;
279
- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
280
- const pos = Math.min(caretPosition, textNode.textContent.length);
281
- range.setStart(textNode, pos);
282
- range.setEnd(textNode, pos);
283
- selection.removeAllRanges();
284
- selection.addRange(range);
285
- }
286
- } catch (e) {
287
- // Ignore caret positioning errors
288
- }
289
- }
290
- }
291
- }
292
-
293
- // Remove sync flag after a short delay
294
- setTimeout(() => {
295
- targetElement.removeAttribute('data-presence-syncing');
296
- }, 50);
297
-
298
- return true;
299
- } catch (e) {
300
- console.warn('[Manifest Presence] Failed to update element value:', e);
301
- targetElement.removeAttribute('data-presence-syncing');
302
- return false;
303
- }
304
- }
305
-
306
-
307
- /* Manifest Data Sources - Presence Event Tracking */
308
-
309
- // Create event handlers for presence tracking
310
- function createPresenceEventHandlers(element, state, callbacks) {
311
- const {
312
- currentCursor,
313
- currentFocus,
314
- currentSelection,
315
- currentEditing,
316
- isLocalUserEditing,
317
- lastPosition,
318
- lastVelocity,
319
- lastActivityTime
320
- } = state;
321
-
322
- const {
323
- getElementId,
324
- updateCursorPosition
325
- } = callbacks;
326
-
327
- // Throttle mousemove to prevent performance issues
328
- let lastMouseMoveTime = 0;
329
- const mouseMoveThrottle = 16; // ~60fps max
330
-
331
- // Cursor position tracker with velocity calculation for smooth interpolation
332
- const handleMouseMove = (e) => {
333
- const now = Date.now();
334
- // Throttle mousemove events to prevent forced reflows
335
- if (now - lastMouseMoveTime < mouseMoveThrottle) {
336
- return;
337
- }
338
- lastMouseMoveTime = now;
339
-
340
- // Use requestAnimationFrame to batch DOM reads
341
- requestAnimationFrame(() => {
342
- const rect = element.getBoundingClientRect();
343
- const x = e.clientX - rect.left;
344
- const y = e.clientY - rect.top;
345
- const now = Date.now();
346
-
347
- // Calculate velocity for smooth interpolation (pixels per second)
348
- const dt = (now - lastPosition.time) / 1000; // Convert to seconds
349
- if (dt > 0 && dt < 1) { // Only calculate if reasonable time difference
350
- lastVelocity.vx = (x - lastPosition.x) / dt;
351
- lastVelocity.vy = (y - lastPosition.y) / dt;
352
- }
353
-
354
- currentCursor.x = x;
355
- currentCursor.y = y;
356
- lastPosition.x = x;
357
- lastPosition.y = y;
358
- lastPosition.time = now;
359
- lastActivityTime.value = now; // Update activity timestamp
360
- });
361
- };
362
-
363
- const handleTouchMove = (e) => {
364
- if (e.touches.length > 0) {
365
- const rect = element.getBoundingClientRect();
366
- const x = e.touches[0].clientX - rect.left;
367
- const y = e.touches[0].clientY - rect.top;
368
- const now = Date.now();
369
-
370
- // Calculate velocity
371
- const dt = (now - lastPosition.time) / 1000;
372
- if (dt > 0 && dt < 1) {
373
- lastVelocity.vx = (x - lastPosition.x) / dt;
374
- lastVelocity.vy = (y - lastPosition.y) / dt;
375
- }
376
-
377
- currentCursor.x = x;
378
- currentCursor.y = y;
379
- lastPosition.x = x;
380
- lastPosition.y = y;
381
- lastPosition.time = now;
382
- lastActivityTime.value = now;
383
- }
384
- };
385
-
386
- // Focus tracking
387
- const handleFocus = (e) => {
388
- const target = e.target;
389
- if (target && element.contains(target)) {
390
- const elementId = getElementId(target, element);
391
- currentFocus.value = {
392
- elementId: elementId,
393
- elementType: target.type || target.tagName.toLowerCase(),
394
- tagName: target.tagName.toLowerCase(),
395
- placeholder: target.placeholder || null,
396
- name: target.name || null
397
- };
398
- lastActivityTime.value = Date.now(); // Update activity on focus
399
- // Trigger immediate update for focus changes (important for UX)
400
- updateCursorPosition(true); // Force immediate update
401
- }
402
- };
403
-
404
- const handleBlur = (e) => {
405
- currentFocus.value = null;
406
- currentEditing.value = null;
407
- currentSelection.value = null;
408
- isLocalUserEditing.value = false;
409
- // Trigger immediate update when focus is lost
410
- updateCursorPosition(true); // Force immediate update
411
- };
412
-
413
- // Text selection tracking
414
- const handleSelectionChange = () => {
415
- const selection = window.getSelection();
416
- if (selection && selection.rangeCount > 0 && !selection.isCollapsed) {
417
- const range = selection.getRangeAt(0);
418
- const target = range.commonAncestorContainer;
419
- const targetElement = target.nodeType === Node.TEXT_NODE ? target.parentElement : target;
420
-
421
- // Check if the selection is within our tracked element
422
- if (targetElement && element.contains && element.contains(targetElement)) {
423
- const elementId = getElementId(targetElement, element);
424
- if (elementId) {
425
- const selectedText = selection.toString();
426
- if (selectedText && selectedText.trim().length > 0) {
427
- currentSelection.value = {
428
- elementId: elementId,
429
- start: range.startOffset,
430
- end: range.endOffset,
431
- text: selectedText.substring(0, 100) // Limit text length
432
- };
433
- lastActivityTime.value = Date.now(); // Update activity on selection
434
- } else {
435
- currentSelection.value = null;
436
- }
437
- } else {
438
- currentSelection.value = null;
439
- }
440
- } else {
441
- currentSelection.value = null;
442
- }
443
- } else {
444
- currentSelection.value = null;
445
- }
446
- };
447
-
448
- // Text editing tracking (for input/textarea/contenteditable)
449
- const handleInput = (e) => {
450
- const target = e.target;
451
- // Skip if this is a sync event (to prevent infinite loops)
452
- if (target && target.hasAttribute('data-presence-syncing')) {
453
- return;
454
- }
455
-
456
- if (target && element.contains(target)) {
457
- const elementId = getElementId(target, element);
458
- if (elementId && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
459
- const selection = window.getSelection();
460
- let caretPosition = null;
461
-
462
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
463
- caretPosition = target.selectionStart || 0;
464
- } else if (selection && selection.rangeCount > 0) {
465
- const range = selection.getRangeAt(0);
466
- caretPosition = range.startOffset;
467
- }
468
-
469
- currentEditing.value = {
470
- elementId: elementId,
471
- value: target.value || target.textContent || '',
472
- caretPosition: caretPosition,
473
- length: (target.value || target.textContent || '').length
474
- };
475
- isLocalUserEditing.value = true;
476
- lastActivityTime.value = Date.now(); // Update activity on input
477
- }
478
- }
479
- };
480
-
481
- // Set up event listeners
482
- element.addEventListener('mousemove', handleMouseMove);
483
- element.addEventListener('touchmove', handleTouchMove, { passive: true });
484
- element.addEventListener('focusin', handleFocus, true); // Use capture to catch all focus events
485
- element.addEventListener('focusout', handleBlur, true);
486
- element.addEventListener('selectionchange', handleSelectionChange);
487
- element.addEventListener('input', handleInput, true);
488
- element.addEventListener('keyup', handleInput, true); // Also track on keyup for caret position
489
-
490
- // Return cleanup function
491
- return () => {
492
- element.removeEventListener('mousemove', handleMouseMove);
493
- element.removeEventListener('touchmove', handleTouchMove);
494
- element.removeEventListener('focusin', handleFocus, true);
495
- element.removeEventListener('focusout', handleBlur, true);
496
- element.removeEventListener('selectionchange', handleSelectionChange);
497
- element.removeEventListener('input', handleInput, true);
498
- element.removeEventListener('keyup', handleInput, true);
499
- };
500
- }
501
-
502
-
503
- /* Manifest Data Sources - Presence Database Operations */
504
-
505
- // Helper function to check if data has changed significantly
506
- function hasSignificantChange(currentCursor, lastSentCursor, currentFocus, lastSentFocus, currentSelection, lastSentSelection, currentEditing, lastSentEditing, minChangeThreshold) {
507
- // Check cursor position change (minimum threshold)
508
- const cursorDeltaX = Math.abs(currentCursor.x - (lastSentCursor.x || 0));
509
- const cursorDeltaY = Math.abs(currentCursor.y - (lastSentCursor.y || 0));
510
- const cursorMoved = cursorDeltaX >= minChangeThreshold || cursorDeltaY >= minChangeThreshold;
511
-
512
- // Check if focus changed
513
- const focusChanged = JSON.stringify(currentFocus) !== JSON.stringify(lastSentFocus);
514
-
515
- // Check if selection changed
516
- const selectionChanged = JSON.stringify(currentSelection) !== JSON.stringify(lastSentSelection);
517
-
518
- // Check if editing changed (always send editing updates - they're important)
519
- const editingChanged = JSON.stringify(currentEditing) !== JSON.stringify(lastSentEditing);
520
-
521
- // Update if cursor moved significantly OR any state changed
522
- return cursorMoved || focusChanged || selectionChanged || editingChanged;
523
- }
524
-
525
- // Broadcast cursor position to database table
526
- async function updateCursorPosition(
527
- services,
528
- databaseId,
529
- tableId,
530
- channelId,
531
- userInfo,
532
- currentCursor,
533
- currentFocus,
534
- currentSelection,
535
- currentEditing,
536
- lastVelocity,
537
- includeVelocity,
538
- lastBroadcastTime,
539
- lastActivityTime,
540
- throttle,
541
- idleThreshold,
542
- minChangeThreshold,
543
- lastSentCursor,
544
- lastSentFocus,
545
- lastSentSelection,
546
- lastSentEditing,
547
- forceImmediate = false
548
- ) {
549
- const now = Date.now();
550
-
551
- // Throttle broadcasts (unless forced)
552
- if (!forceImmediate && now - lastBroadcastTime.value < throttle) {
553
- return;
554
- }
555
-
556
- // Idle detection: Skip updates if user has been inactive
557
- const timeSinceActivity = now - lastActivityTime.value;
558
- if (timeSinceActivity > idleThreshold) {
559
- // User is idle - don't send updates (saves writes)
560
- return;
561
- }
562
-
563
- // Change detection: Only update if something actually changed
564
- if (!forceImmediate && !hasSignificantChange(
565
- currentCursor,
566
- lastSentCursor.value,
567
- currentFocus.value,
568
- lastSentFocus.value,
569
- currentSelection.value,
570
- lastSentSelection.value,
571
- currentEditing.value,
572
- lastSentEditing.value,
573
- minChangeThreshold
574
- ) && lastSentCursor.value.x !== null) {
575
- // Nothing significant changed - skip this update (saves writes)
576
- return;
577
- }
578
-
579
- lastBroadcastTime.value = now;
580
-
581
- try {
582
- // Create or update cursor position in database
583
- // Use userId as the unique identifier (upsert pattern)
584
- // Store focus/selection/editing as JSON strings (Appwrite doesn't have native JSON type)
585
- // Include velocity for client-side interpolation (optional, requires table schema)
586
- const presenceData = {
587
- userId: userInfo.id,
588
- channelId: channelId,
589
- x: currentCursor.x,
590
- y: currentCursor.y,
591
- name: userInfo.name,
592
- color: userInfo.color,
593
- lastSeen: now,
594
- focus: currentFocus.value ? JSON.stringify(currentFocus.value) : null,
595
- selection: currentSelection.value ? JSON.stringify(currentSelection.value) : null,
596
- editing: currentEditing.value ? JSON.stringify(currentEditing.value) : null
597
- };
598
-
599
- // Only include velocity if enabled and table schema supports it
600
- if (includeVelocity) {
601
- presenceData.vx = lastVelocity.vx; // Velocity X (pixels per second) for interpolation
602
- presenceData.vy = lastVelocity.vy; // Velocity Y (pixels per second) for interpolation
603
- }
604
-
605
- // Update last sent values for change detection
606
- lastSentCursor.value = { x: currentCursor.x, y: currentCursor.y };
607
- lastSentFocus.value = currentFocus.value ? JSON.parse(JSON.stringify(currentFocus.value)) : null;
608
- lastSentSelection.value = currentSelection.value ? JSON.parse(JSON.stringify(currentSelection.value)) : null;
609
- lastSentEditing.value = currentEditing.value ? JSON.parse(JSON.stringify(currentEditing.value)) : null;
610
-
611
- // Upsert logic: try update first, then create if not found
612
- try {
613
- // Try to update existing row first
614
- await services.tablesDB.updateRow({
615
- databaseId,
616
- tableId,
617
- rowId: userInfo.id,
618
- data: presenceData
619
- });
620
- } catch (updateError) {
621
- // If 404, row doesn't exist yet - create it
622
- // Check multiple error properties (Appwrite SDK may structure errors differently)
623
- const isNotFound = updateError?.code === 404 ||
624
- updateError?.response?.code === 404 ||
625
- updateError?.statusCode === 404 ||
626
- updateError?.message?.includes('404') ||
627
- updateError?.message?.includes('not found');
628
-
629
- if (isNotFound) {
630
- try {
631
- await services.tablesDB.createRow({
632
- databaseId,
633
- tableId,
634
- rowId: userInfo.id, // Use userId as row ID
635
- data: presenceData
636
- });
637
- } catch (createError) {
638
- // If 409 (conflict), row was created between update and create - try update again
639
- if (createError?.code === 409) {
640
- try {
641
- await services.tablesDB.updateRow({
642
- databaseId,
643
- tableId,
644
- rowId: userInfo.id,
645
- data: presenceData
646
- });
647
- } catch (retryError) {
648
- // Suppress 401 errors (permission issues)
649
- if (retryError?.code !== 401) {
650
- console.error('[Manifest Presence] Failed to update cursor position after conflict:', retryError);
651
- }
652
- throw retryError;
653
- }
654
- } else if (createError?.code !== 401) {
655
- // Suppress 401 errors (permission issues) - user needs to set table permissions
656
- console.error('[Manifest Presence] Failed to create cursor position:', createError);
657
- throw createError;
658
- } else {
659
- throw createError;
660
- }
661
- }
662
- } else if (updateError?.code === 401) {
663
- // Permission issue - suppress repeated logs
664
- throw updateError;
665
- } else {
666
- // Other error - suppress 404 errors (they're handled by creating the row)
667
- const isNotFoundError = updateError?.code === 404 ||
668
- updateError?.response?.code === 404 ||
669
- updateError?.statusCode === 404 ||
670
- updateError?.message?.includes('404') ||
671
- updateError?.message?.includes('not found');
672
- if (!isNotFoundError && updateError?.code !== 401) {
673
- console.error('[Manifest Presence] Failed to update cursor position:', updateError);
674
- }
675
- // Don't rethrow 404 errors - they're expected and handled
676
- if (!isNotFoundError) {
677
- throw updateError;
678
- }
679
- }
680
- }
681
- } catch (error) {
682
- // Suppress 401 and 404 errors (permission issues and expected not-found during initial creation)
683
- const isNotFoundError = error?.code === 404 ||
684
- error?.response?.code === 404 ||
685
- error?.statusCode === 404 ||
686
- error?.message?.includes('404') ||
687
- error?.message?.includes('not found');
688
- if (error?.code !== 401 && !isNotFoundError) {
689
- console.error('[Manifest Presence] Failed to update cursor position:', error);
690
- }
691
- }
692
- }
693
-
694
- // Load initial cursor positions from database
695
- async function loadInitialCursors(services, databaseId, tableId, channelId, userInfo, cursors, includeVelocity, applyVisualIndicators, containerElement, onCursorUpdate) {
696
- try {
697
- const allCursors = await services.tablesDB.listRows({
698
- databaseId,
699
- tableId,
700
- queries: [
701
- window.Appwrite.Query.equal('channelId', channelId)
702
- ]
703
- });
704
-
705
- if (allCursors && allCursors.rows) {
706
- allCursors.rows.forEach(row => {
707
- if (row.userId && row.userId !== userInfo.id) {
708
- // Parse JSON strings for focus/selection/editing
709
- let focus = null, selection = null, editing = null;
710
- try {
711
- focus = row.focus ? (typeof row.focus === 'string' ? JSON.parse(row.focus) : row.focus) : null;
712
- selection = row.selection ? (typeof row.selection === 'string' ? JSON.parse(row.selection) : row.selection) : null;
713
- editing = row.editing ? (typeof row.editing === 'string' ? JSON.parse(row.editing) : row.editing) : null;
714
- } catch (e) {
715
- console.warn('[Manifest Presence] Failed to parse presence data from row:', e);
716
- }
717
-
718
- const userColor = row.color || getUserColor(row.userId);
719
- cursors.set(row.userId, {
720
- x: row.x || 0,
721
- y: row.y || 0,
722
- vx: (includeVelocity && row.vx !== undefined) ? row.vx : 0, // Velocity X for interpolation (optional)
723
- vy: (includeVelocity && row.vy !== undefined) ? row.vy : 0, // Velocity Y for interpolation (optional)
724
- name: row.name || 'Anonymous',
725
- color: userColor,
726
- lastSeen: row.lastSeen || Date.now(),
727
- lastUpdateTime: Date.now(), // Track when we loaded this for interpolation
728
- focus: focus,
729
- selection: selection,
730
- editing: editing
731
- });
732
-
733
- // Apply visual indicators for initial load
734
- applyVisualIndicators(row.userId, focus, selection, editing, userColor, containerElement);
735
- }
736
- });
737
-
738
-
739
- // Trigger callback after loading initial cursors
740
- if (onCursorUpdate) {
741
- const cursorArray = Array.from(cursors.values());
742
- onCursorUpdate(cursorArray);
743
- }
744
- }
745
- } catch (error) {
746
- // Suppress 401 errors (permission issues) - user needs to set table permissions
747
- if (error?.code !== 401) {
748
- console.warn('[Manifest Presence] Failed to load initial cursors:', error);
749
- }
750
- }
751
- }
752
-
753
-
754
- /* Manifest Data Sources - Presence Realtime Subscription */
755
-
756
- // Create realtime subscription callback
757
- function createPresenceRealtimeCallback(
758
- channelId,
759
- userInfo,
760
- cursors,
761
- containerElement,
762
- isLocalUserEditing,
763
- currentEditing,
764
- onUserJoin,
765
- onUserLeave,
766
- onCursorUpdate
767
- ) {
768
- return (response) => {
769
-
770
- // Handle both array and single event formats (like in manifest.data.realtime.js)
771
- if (!response) {
772
- return;
773
- }
774
-
775
- // Check if events exist, handle both array and single event
776
- const events = response.events ?
777
- (Array.isArray(response.events) ? response.events : [response.events]) :
778
- [];
779
-
780
- if (events.length === 0) {
781
- return;
782
- }
783
-
784
- events.forEach(event => {
785
- if (typeof event !== 'string') {
786
- return;
787
- }
788
-
789
- const payload = response.payload || response;
790
- const userId = payload.userId || payload.$id;
791
-
792
- // Ignore our own updates
793
- if (!userId || userId === userInfo.id) {
794
- return;
795
- }
796
-
797
- if (event.includes('create') || event.includes('rows.create')) {
798
- handleUserJoin(event, payload, channelId, userId, cursors, containerElement, onUserJoin, onCursorUpdate);
799
- } else if (event.includes('update') || event.includes('rows.update')) {
800
- handleUserUpdate(event, payload, channelId, userId, cursors, containerElement, isLocalUserEditing, currentEditing, onCursorUpdate);
801
- } else if (event.includes('delete') || event.includes('rows.delete')) {
802
- handleUserLeave(event, userId, onUserLeave, onCursorUpdate, cursors);
803
- }
804
- });
805
- };
806
- }
807
-
808
- // Handle user join event
809
- function handleUserJoin(event, payload, channelId, userId, cursors, containerElement, onUserJoin, onCursorUpdate) {
810
- // User joined
811
- if (payload.channelId === channelId) {
812
- // Parse JSON strings for focus/selection/editing
813
- let focus = null, selection = null, editing = null;
814
- try {
815
- focus = payload.focus ? (typeof payload.focus === 'string' ? JSON.parse(payload.focus) : payload.focus) : null;
816
- selection = payload.selection ? (typeof payload.selection === 'string' ? JSON.parse(payload.selection) : payload.selection) : null;
817
- editing = payload.editing ? (typeof payload.editing === 'string' ? JSON.parse(payload.editing) : payload.editing) : null;
818
- } catch (e) {
819
- console.warn('[Manifest Presence] Failed to parse presence data:', e);
820
- }
821
-
822
- const userColor = payload.color || getUserColor(userId);
823
- cursors.set(userId, {
824
- x: payload.x || 0,
825
- y: payload.y || 0,
826
- vx: payload.vx || 0, // Velocity X for interpolation
827
- vy: payload.vy || 0, // Velocity Y for interpolation
828
- name: payload.name || 'Anonymous',
829
- color: userColor,
830
- lastSeen: payload.lastSeen || Date.now(),
831
- lastUpdateTime: Date.now(), // Track when we received this update for interpolation
832
- focus: focus,
833
- selection: selection,
834
- editing: editing
835
- });
836
-
837
- // Apply visual indicators when user joins
838
- applyVisualIndicators(userId, focus, selection, editing, userColor, containerElement);
839
-
840
- if (onUserJoin) {
841
- onUserJoin({ userId, name: payload.name, color: userColor });
842
- }
843
-
844
- if (onCursorUpdate) {
845
- onCursorUpdate(Array.from(cursors.values()));
846
- }
847
- } else {
848
- }
849
- }
850
-
851
- // Handle user update event
852
- function handleUserUpdate(event, payload, channelId, userId, cursors, containerElement, isLocalUserEditing, currentEditing, onCursorUpdate) {
853
- // Presence updated (cursor, focus, selection, editing)
854
-
855
- if (payload.channelId === channelId) {
856
- const existing = cursors.get(userId) || {};
857
-
858
- // Parse JSON strings for focus/selection/editing
859
- let focus = existing.focus, selection = existing.selection, editing = existing.editing;
860
- if (payload.focus !== undefined) {
861
- try {
862
- focus = payload.focus ? (typeof payload.focus === 'string' ? JSON.parse(payload.focus) : payload.focus) : null;
863
-
864
- // Add CSS class to element for customizable styling
865
- if (focus && focus.elementId) {
866
- const targetElement = findElementById(focus.elementId, containerElement);
867
- if (targetElement) {
868
- targetElement.classList.add('presence-focused');
869
- targetElement.setAttribute('data-presence-focus-user', userId);
870
- targetElement.setAttribute('data-presence-focus-color', existing.color || getUserColor(userId));
871
- }
872
- }
873
- // Remove focus class from elements that are no longer focused by this user
874
- if (!focus || !focus.elementId) {
875
- document.querySelectorAll(`[data-presence-focus-user="${userId}"]`).forEach(el => {
876
- el.classList.remove('presence-focused');
877
- el.removeAttribute('data-presence-focus-user');
878
- el.removeAttribute('data-presence-focus-color');
879
- });
880
- }
881
- } catch (e) {
882
- console.warn('[Manifest Presence] Failed to parse focus:', e);
883
- }
884
- }
885
- if (payload.selection !== undefined) {
886
- try {
887
- selection = payload.selection ? (typeof payload.selection === 'string' ? JSON.parse(payload.selection) : payload.selection) : null;
888
-
889
- // Update selection indicator
890
- if (selection && selection.elementId) {
891
- const targetElement = findElementById(selection.elementId, containerElement);
892
- if (targetElement) {
893
- // Store selection data for rendering
894
- const start = selection.start !== undefined ? selection.start : (selection.startOffset || 0);
895
- const end = selection.end !== undefined ? selection.end : (selection.endOffset || start);
896
- targetElement.setAttribute('data-presence-selection-user', userId);
897
- targetElement.setAttribute('data-presence-selection-start', start.toString());
898
- targetElement.setAttribute('data-presence-selection-end', end.toString());
899
- targetElement.setAttribute('data-presence-selection-color', existing.color || getUserColor(userId));
900
- // Trigger custom event for selection rendering
901
- targetElement.dispatchEvent(new CustomEvent('presence:selection', {
902
- detail: { userId, selection: { start, end }, color: existing.color || getUserColor(userId) }
903
- }));
904
- }
905
- } else {
906
- // Remove selection indicators
907
- document.querySelectorAll(`[data-presence-selection-user="${userId}"]`).forEach(el => {
908
- el.removeAttribute('data-presence-selection-user');
909
- el.removeAttribute('data-presence-selection-start');
910
- el.removeAttribute('data-presence-selection-end');
911
- el.removeAttribute('data-presence-selection-color');
912
- // Remove visual indicator
913
- const indicator = el.querySelector('.presence-selection');
914
- if (indicator) indicator.remove();
915
- });
916
- }
917
- } catch (e) {
918
- console.warn('[Manifest Presence] Failed to parse selection:', e);
919
- }
920
- }
921
- if (payload.editing !== undefined) {
922
- try {
923
- editing = payload.editing ? (typeof payload.editing === 'string' ? JSON.parse(payload.editing) : payload.editing) : null;
924
-
925
- // Real-time text syncing: Update element if local user is not editing it
926
- if (editing && editing.elementId && editing.value !== undefined) {
927
- // Check if local user is currently editing this element
928
- const isLocalEditingThis = isLocalUserEditing.value &&
929
- currentEditing.value &&
930
- currentEditing.value.elementId === editing.elementId;
931
-
932
- if (!isLocalEditingThis) {
933
- // Local user is not editing this element - safe to sync
934
- const targetElement = findElementById(editing.elementId, containerElement);
935
- if (targetElement) {
936
- // Check if element is currently being synced (to prevent conflicts)
937
- if (!targetElement.hasAttribute('data-presence-syncing')) {
938
- updateElementValue(targetElement, editing.value, editing.caretPosition);
939
- }
940
-
941
- // Add caret position indicator
942
- if (editing.caretPosition !== null && editing.caretPosition !== undefined) {
943
- targetElement.setAttribute('data-presence-caret-user', userId);
944
- targetElement.setAttribute('data-presence-caret-position', editing.caretPosition);
945
- targetElement.setAttribute('data-presence-caret-color', existing.color || getUserColor(userId));
946
- // Trigger custom event for caret rendering
947
- targetElement.dispatchEvent(new CustomEvent('presence:caret', {
948
- detail: { userId, caretPosition: editing.caretPosition, color: existing.color || getUserColor(userId) }
949
- }));
950
- }
951
- } else {
952
- console.warn('[Manifest Presence] Element not found for syncing:', editing.elementId);
953
- }
954
- }
955
- } else if (!editing || !editing.elementId) {
956
- // Remove caret indicators when editing stops
957
- document.querySelectorAll(`[data-presence-caret-user="${userId}"]`).forEach(el => {
958
- el.removeAttribute('data-presence-caret-user');
959
- el.removeAttribute('data-presence-caret-position');
960
- el.removeAttribute('data-presence-caret-color');
961
- // Remove visual indicator
962
- const indicator = el.querySelector('.presence-caret');
963
- if (indicator) indicator.remove();
964
- });
965
- }
966
- } catch (e) {
967
- console.warn('[Manifest Presence] Failed to parse editing:', e);
968
- }
969
- }
970
-
971
- cursors.set(userId, {
972
- ...existing,
973
- x: payload.x !== undefined ? payload.x : existing.x || 0,
974
- y: payload.y !== undefined ? payload.y : existing.y || 0,
975
- vx: payload.vx !== undefined ? payload.vx : (existing.vx || 0), // Velocity X
976
- vy: payload.vy !== undefined ? payload.vy : (existing.vy || 0), // Velocity Y
977
- name: payload.name || existing.name || 'Anonymous',
978
- color: payload.color || existing.color || getUserColor(userId),
979
- lastSeen: payload.lastSeen || Date.now(),
980
- lastUpdateTime: Date.now(), // Track when we received this update for interpolation
981
- focus: focus,
982
- selection: selection,
983
- editing: editing
984
- });
985
-
986
- // Apply visual indicators for update events
987
- applyVisualIndicators(userId, focus, selection, editing, existing.color || getUserColor(userId), containerElement);
988
-
989
- if (onCursorUpdate) {
990
- onCursorUpdate(Array.from(cursors.values()));
991
- }
992
- } else {
993
- }
994
- }
995
-
996
- // Handle user leave event
997
- function handleUserLeave(event, userId, onUserLeave, onCursorUpdate, cursors) {
998
- // User left - clean up all indicators
999
- document.querySelectorAll(`[data-presence-focus-user="${userId}"]`).forEach(el => {
1000
- el.classList.remove('presence-focused');
1001
- el.removeAttribute('data-presence-focus-user');
1002
- el.removeAttribute('data-presence-focus-color');
1003
- });
1004
- document.querySelectorAll(`[data-presence-caret-user="${userId}"]`).forEach(el => {
1005
- el.removeAttribute('data-presence-caret-user');
1006
- el.removeAttribute('data-presence-caret-position');
1007
- el.removeAttribute('data-presence-caret-color');
1008
- });
1009
- document.querySelectorAll(`[data-presence-selection-user="${userId}"]`).forEach(el => {
1010
- el.removeAttribute('data-presence-selection-user');
1011
- el.removeAttribute('data-presence-selection-start');
1012
- el.removeAttribute('data-presence-selection-end');
1013
- el.removeAttribute('data-presence-selection-color');
1014
- });
1015
-
1016
- if (cursors.has(userId)) {
1017
- cursors.delete(userId);
1018
-
1019
- if (onUserLeave) {
1020
- onUserLeave({ userId });
1021
- }
1022
-
1023
- if (onCursorUpdate) {
1024
- onCursorUpdate(Array.from(cursors.values()));
1025
- }
1026
- }
1027
- }
1028
-
1029
- // Setup realtime subscription
1030
- async function setupPresenceRealtimeSubscription(
1031
- services,
1032
- presenceChannel,
1033
- channelId,
1034
- userInfo,
1035
- cursors,
1036
- containerElement,
1037
- isLocalUserEditing,
1038
- currentEditing,
1039
- onUserJoin,
1040
- onUserLeave,
1041
- onCursorUpdate
1042
- ) {
1043
- // Initialize unsubscribe as a no-op function in case subscription fails
1044
- let unsubscribe = () => {
1045
- console.warn('[Manifest Presence] Unsubscribe called but subscription was not successful');
1046
- };
1047
-
1048
- try {
1049
- // Verify we're using the same realtime service instance
1050
- // Call subscribe with inline callback
1051
- // Note: subscribe() returns a Promise that resolves when subscription is active
1052
- const subscribeResult = services.realtime.subscribe(presenceChannel, (response) => {
1053
-
1054
- // Handle both array and single event formats (like in manifest.data.realtime.js)
1055
- if (!response) {
1056
- return;
1057
- }
1058
-
1059
- // Check if events exist, handle both array and single event
1060
- const events = response.events ?
1061
- (Array.isArray(response.events) ? response.events : [response.events]) :
1062
- [];
1063
-
1064
- if (events.length === 0) {
1065
- return;
1066
- }
1067
-
1068
- events.forEach(event => {
1069
- if (typeof event !== 'string') {
1070
- return;
1071
- }
1072
-
1073
- const payload = response.payload || response;
1074
- const userId = payload.userId || payload.$id;
1075
-
1076
- // Ignore our own updates
1077
- if (!userId || userId === userInfo.id) {
1078
- return;
1079
- }
1080
-
1081
- if (event.includes('create') || event.includes('rows.create')) {
1082
- handleUserJoin(event, payload, channelId, userId, cursors, containerElement, onUserJoin, onCursorUpdate);
1083
- } else if (event.includes('update') || event.includes('rows.update')) {
1084
- handleUserUpdate(event, payload, channelId, userId, cursors, containerElement, isLocalUserEditing, currentEditing, onCursorUpdate);
1085
- } else if (event.includes('delete') || event.includes('rows.delete')) {
1086
- handleUserLeave(event, userId, onUserLeave, onCursorUpdate, cursors);
1087
- }
1088
- });
1089
- });
1090
-
1091
- // Handle Promise resolution asynchronously (don't await - callback is already registered)
1092
- // Match the pattern from working subscribeToTable which doesn't await
1093
- if (subscribeResult && typeof subscribeResult.then === 'function') {
1094
- // Callback is already registered synchronously, Promise is just for unsubscribe function
1095
- subscribeResult.then((resolvedUnsubscribe) => {
1096
- if (typeof resolvedUnsubscribe === 'function') {
1097
- unsubscribe = resolvedUnsubscribe;
1098
- } else if (resolvedUnsubscribe && typeof resolvedUnsubscribe.close === 'function') {
1099
- unsubscribe = () => resolvedUnsubscribe.close();
1100
- }
1101
- }).catch((error) => {
1102
- });
1103
- // Set temporary unsubscribe that will be replaced when Promise resolves
1104
- unsubscribe = () => {
1105
- if (subscribeResult && typeof subscribeResult.then === 'function') {
1106
- subscribeResult.then((resolved) => {
1107
- if (resolved && typeof resolved.close === 'function') {
1108
- resolved.close();
1109
- } else if (typeof resolved === 'function') {
1110
- resolved();
1111
- }
1112
- });
1113
- }
1114
- };
1115
- } else if (typeof subscribeResult === 'function') {
1116
- unsubscribe = subscribeResult;
1117
- } else if (subscribeResult && typeof subscribeResult.close === 'function') {
1118
- unsubscribe = () => subscribeResult.close();
1119
- }
1120
-
1121
- // Test: Log a message after a delay to verify subscription is still active
1122
- setTimeout(() => {
1123
- }, 5000);
1124
- return unsubscribe;
1125
- } catch (error) {
1126
- console.error('[Manifest Presence] Failed to subscribe to presence:', error);
1127
- return unsubscribe; // Return no-op function
1128
- }
1129
- }
1130
-
1131
-
1132
- /* Manifest Data Sources - Presence Visual Rendering */
1133
-
1134
- // Render caret position indicator (optional visual rendering)
1135
- function renderCaret(element, caretPosition, color) {
1136
- if (!element) return;
1137
-
1138
- // Remove existing caret for this element
1139
- const existing = element.querySelector('.presence-caret');
1140
- if (existing) existing.remove();
1141
-
1142
- if (caretPosition === null || caretPosition === undefined) return;
1143
-
1144
- // Create caret indicator
1145
- const caret = document.createElement('div');
1146
- caret.className = 'presence-caret';
1147
- if (color) {
1148
- caret.style.setProperty('--presence-caret-color', color);
1149
- }
1150
-
1151
- // Calculate caret position
1152
- if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
1153
- // For input/textarea, measure text width
1154
- const text = element.value.substring(0, caretPosition);
1155
- const measure = document.createElement('span');
1156
- measure.style.position = 'absolute';
1157
- measure.style.visibility = 'hidden';
1158
- measure.style.whiteSpace = 'pre';
1159
- measure.style.font = window.getComputedStyle(element).font;
1160
- measure.textContent = text;
1161
- document.body.appendChild(measure);
1162
-
1163
- const textWidth = measure.offsetWidth;
1164
- document.body.removeChild(measure);
1165
-
1166
- const rect = element.getBoundingClientRect();
1167
- const paddingLeft = parseInt(window.getComputedStyle(element).paddingLeft) || 0;
1168
- const borderLeft = parseInt(window.getComputedStyle(element).borderLeftWidth) || 0;
1169
-
1170
- caret.style.left = (textWidth + paddingLeft + borderLeft) + 'px';
1171
- caret.style.top = (paddingLeft || 2) + 'px';
1172
- } else if (element.isContentEditable) {
1173
- // For contenteditable, use Range API
1174
- try {
1175
- const range = document.createRange();
1176
- const textNode = element.firstChild;
1177
- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1178
- const pos = Math.min(caretPosition, textNode.textContent.length);
1179
- range.setStart(textNode, pos);
1180
- range.setEnd(textNode, pos);
1181
- const rect = range.getBoundingClientRect();
1182
- const parentRect = element.getBoundingClientRect();
1183
- caret.style.left = (rect.left - parentRect.left) + 'px';
1184
- caret.style.top = (rect.top - parentRect.top) + 'px';
1185
- }
1186
- } catch (e) {
1187
- console.warn('[Manifest Presence] Failed to position caret:', e);
1188
- return;
1189
- }
1190
- }
1191
-
1192
- element.style.position = 'relative';
1193
- element.appendChild(caret);
1194
- }
1195
-
1196
- // Render text selection highlight (optional visual rendering)
1197
- function renderSelection(element, start, end, color) {
1198
- if (!element) return;
1199
-
1200
- // Remove existing selection for this element
1201
- const existing = element.querySelector('.presence-selection');
1202
- if (existing) existing.remove();
1203
-
1204
- if (start === null || end === null || start === end) return;
1205
-
1206
- const selection = document.createElement('div');
1207
- selection.className = 'presence-selection';
1208
- if (color) {
1209
- selection.style.setProperty('--presence-selection-color', color);
1210
- }
1211
-
1212
- // Calculate selection bounds
1213
- if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
1214
- const text = element.value;
1215
- const startText = text.substring(0, start);
1216
- const selectedText = text.substring(start, end);
1217
-
1218
- // Measure start position
1219
- const measureStart = document.createElement('span');
1220
- measureStart.style.position = 'absolute';
1221
- measureStart.style.visibility = 'hidden';
1222
- measureStart.style.whiteSpace = 'pre';
1223
- measureStart.style.font = window.getComputedStyle(element).font;
1224
- measureStart.textContent = startText;
1225
- document.body.appendChild(measureStart);
1226
-
1227
- // Measure selection width
1228
- const measureSelected = document.createElement('span');
1229
- measureSelected.style.position = 'absolute';
1230
- measureSelected.style.visibility = 'hidden';
1231
- measureSelected.style.whiteSpace = 'pre';
1232
- measureSelected.style.font = window.getComputedStyle(element).font;
1233
- measureSelected.textContent = selectedText;
1234
- document.body.appendChild(measureSelected);
1235
-
1236
- const startWidth = measureStart.offsetWidth;
1237
- const selectedWidth = measureSelected.offsetWidth;
1238
- document.body.removeChild(measureStart);
1239
- document.body.removeChild(measureSelected);
1240
-
1241
- const rect = element.getBoundingClientRect();
1242
- const paddingLeft = parseInt(window.getComputedStyle(element).paddingLeft) || 0;
1243
- const borderLeft = parseInt(window.getComputedStyle(element).borderLeftWidth) || 0;
1244
- const lineHeight = parseInt(window.getComputedStyle(element).lineHeight) || 20;
1245
-
1246
- selection.style.left = (startWidth + paddingLeft + borderLeft) + 'px';
1247
- selection.style.top = (paddingLeft || 2) + 'px';
1248
- selection.style.width = selectedWidth + 'px';
1249
- selection.style.height = lineHeight + 'px';
1250
- } else if (element.isContentEditable) {
1251
- // For contenteditable, use Range API
1252
- try {
1253
- const textNode = element.firstChild;
1254
- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1255
- const range = document.createRange();
1256
- const textLength = textNode.textContent.length;
1257
- const safeStart = Math.min(start, textLength);
1258
- const safeEnd = Math.min(end, textLength);
1259
- range.setStart(textNode, safeStart);
1260
- range.setEnd(textNode, safeEnd);
1261
- const rect = range.getBoundingClientRect();
1262
- const parentRect = element.getBoundingClientRect();
1263
- selection.style.left = (rect.left - parentRect.left) + 'px';
1264
- selection.style.top = (rect.top - parentRect.top) + 'px';
1265
- selection.style.width = rect.width + 'px';
1266
- selection.style.height = rect.height + 'px';
1267
- }
1268
- } catch (e) {
1269
- console.warn('[Manifest Presence] Failed to position selection:', e);
1270
- return;
1271
- }
1272
- }
1273
-
1274
- element.style.position = 'relative';
1275
- element.appendChild(selection);
1276
- }
1277
-
1278
- // Initialize optional visual rendering (caret/selection indicators)
1279
- function initializeVisualRendering() {
1280
- // Listen for presence events
1281
- document.addEventListener('presence:caret', (e) => {
1282
- const { userId, caretPosition, color } = e.detail;
1283
- const element = e.target;
1284
- if (element && caretPosition !== null && caretPosition !== undefined) {
1285
- renderCaret(element, caretPosition, color);
1286
- }
1287
- });
1288
-
1289
- document.addEventListener('presence:selection', (e) => {
1290
- const { userId, selection, color } = e.detail;
1291
- const element = e.target;
1292
- if (element && selection && selection.start !== undefined && selection.end !== undefined) {
1293
- renderSelection(element, selection.start, selection.end, color);
1294
- }
1295
- });
1296
-
1297
- // Update caret/selection when attributes change
1298
- const observer = new MutationObserver((mutations) => {
1299
- mutations.forEach((mutation) => {
1300
- if (mutation.type === 'attributes') {
1301
- const element = mutation.target;
1302
- const caretUser = element.getAttribute('data-presence-caret-user');
1303
- const caretPos = element.getAttribute('data-presence-caret-position');
1304
- const caretColor = element.getAttribute('data-presence-caret-color');
1305
-
1306
- if (caretUser && caretPos !== null) {
1307
- renderCaret(element, parseInt(caretPos), caretColor || null);
1308
- }
1309
-
1310
- const selUser = element.getAttribute('data-presence-selection-user');
1311
- const selStart = element.getAttribute('data-presence-selection-start');
1312
- const selEnd = element.getAttribute('data-presence-selection-end');
1313
- const selColor = element.getAttribute('data-presence-selection-color');
1314
-
1315
- if (selUser && selStart !== null && selEnd !== null) {
1316
- renderSelection(element, parseInt(selStart), parseInt(selEnd), selColor || null);
1317
- }
1318
- }
1319
- });
1320
- });
1321
-
1322
- // Observe all editable elements
1323
- function initObserver() {
1324
- const editableElements = document.querySelectorAll('input, textarea, [contenteditable="true"]');
1325
- editableElements.forEach(el => {
1326
- observer.observe(el, {
1327
- attributes: true,
1328
- attributeFilter: [
1329
- 'data-presence-caret-user', 'data-presence-caret-position', 'data-presence-caret-color',
1330
- 'data-presence-selection-user', 'data-presence-selection-start', 'data-presence-selection-end', 'data-presence-selection-color'
1331
- ]
1332
- });
1333
- });
1334
- }
1335
-
1336
- if (document.readyState === 'loading') {
1337
- document.addEventListener('DOMContentLoaded', initObserver);
1338
- } else {
1339
- initObserver();
1340
- }
1341
- }
1342
-
1343
-
1344
- /* Manifest Data Sources - Presence Main Subscription */
1345
-
1346
- // Subscribe to presence channel for cursor tracking
1347
- // Uses a database table to store cursor positions (Appwrite Realtime is read-only)
1348
- async function subscribeToPresence(channelId, options = {}) {
1349
- // Get configuration from manifest (if available)
1350
- const manifestConfig = await getPresenceConfig();
1351
-
1352
- // Read CSS variables for timing/threshold values (with fallbacks)
1353
- const cssThrottle = getCSSVariableValue('--presence-throttle', 300);
1354
- const cssCleanupInterval = getCSSVariableValue('--presence-cleanup-interval', 30000);
1355
- const cssMinChangeThreshold = getCSSVariableValue('--presence-min-change-threshold', 5);
1356
- const cssIdleThreshold = getCSSVariableValue('--presence-idle-threshold', 5000);
1357
-
1358
- // Merge defaults: options > manifest > CSS variables > hardcoded defaults
1359
- const finalOptions = {
1360
- element: options.element ?? document.body,
1361
- databaseId: options.databaseId ?? manifestConfig.appwriteDatabaseId ?? null,
1362
- tableId: options.tableId ?? manifestConfig.appwriteTableId ?? 'presence',
1363
- onCursorUpdate: options.onCursorUpdate ?? null,
1364
- onUserJoin: options.onUserJoin ?? null,
1365
- onUserLeave: options.onUserLeave ?? null,
1366
- throttle: options.throttle ?? manifestConfig.throttle ?? cssThrottle,
1367
- cleanupInterval: options.cleanupInterval ?? manifestConfig.cleanupInterval ?? cssCleanupInterval,
1368
- minChangeThreshold: options.minChangeThreshold ?? manifestConfig.minChangeThreshold ?? cssMinChangeThreshold,
1369
- idleThreshold: options.idleThreshold ?? manifestConfig.idleThreshold ?? cssIdleThreshold,
1370
- enableVisualRendering: options.enableVisualRendering ?? manifestConfig.enableVisualRendering ?? true,
1371
- includeVelocity: options.includeVelocity ?? manifestConfig.includeVelocity ?? false
1372
- };
1373
-
1374
- const {
1375
- element,
1376
- databaseId,
1377
- tableId,
1378
- onCursorUpdate,
1379
- onUserJoin,
1380
- onUserLeave,
1381
- throttle,
1382
- cleanupInterval,
1383
- minChangeThreshold,
1384
- idleThreshold,
1385
- enableVisualRendering,
1386
- includeVelocity
1387
- } = finalOptions;
1388
-
1389
- // Unsubscribe from existing subscription if any
1390
- if (presenceSubscriptions.has(channelId)) {
1391
- const existing = presenceSubscriptions.get(channelId);
1392
- if (existing.unsubscribe) {
1393
- existing.unsubscribe();
1394
- }
1395
- if (existing.updateInterval) {
1396
- clearInterval(existing.updateInterval);
1397
- }
1398
- if (existing.cleanupInterval) {
1399
- clearInterval(existing.cleanupInterval);
1400
- }
1401
- if (existing.cursorTracker && existing.cursorTracker.cleanup) {
1402
- existing.cursorTracker.cleanup();
1403
- }
1404
- presenceSubscriptions.delete(channelId);
1405
- }
1406
-
1407
- const services = await getAppwriteServices();
1408
- if (!services?.tablesDB || !services?.realtime) {
1409
- console.warn('[Manifest Presence] Appwrite services not available');
1410
- return null;
1411
- }
1412
-
1413
- if (!databaseId) {
1414
- console.error('[Manifest Presence] databaseId is required');
1415
- return null;
1416
- }
1417
-
1418
- const userInfo = getUserInfo();
1419
- if (!userInfo) {
1420
- console.warn('[Manifest Presence] User not authenticated');
1421
- return null;
1422
- }
1423
-
1424
- // Track cursors for other users
1425
- const cursors = new Map(); // Map<userId, { x, y, name, color, lastSeen }>
1426
-
1427
- // Current presence state for this user (wrapped in objects for reference passing)
1428
- const currentCursor = { x: 0, y: 0 };
1429
- const currentFocus = { value: null }; // { elementId, elementType, tagName }
1430
- const currentSelection = { value: null }; // { start, end, text }
1431
- const currentEditing = { value: null }; // { elementId, value, caretPosition }
1432
- const isLocalUserEditing = { value: false }; // Track if local user is actively editing
1433
- const lastBroadcastTime = { value: 0 };
1434
-
1435
- // Optimization: Track last sent position and state for change detection
1436
- const lastSentCursor = { value: { x: null, y: null } };
1437
- const lastSentFocus = { value: null };
1438
- const lastSentSelection = { value: null };
1439
- const lastSentEditing = { value: null };
1440
- const lastActivityTime = { value: Date.now() }; // Track user activity for idle detection
1441
- const lastVelocity = { vx: 0, vy: 0 }; // Velocity for smooth interpolation (future use)
1442
- const lastPosition = { x: 0, y: 0, time: Date.now() }; // For velocity calculation
1443
-
1444
- // Create updateCursorPosition wrapper that uses the state objects
1445
- const updateCursorPositionWrapper = (forceImmediate = false) => {
1446
- return updateCursorPosition(
1447
- services,
1448
- databaseId,
1449
- tableId,
1450
- channelId,
1451
- userInfo,
1452
- currentCursor,
1453
- currentFocus,
1454
- currentSelection,
1455
- currentEditing,
1456
- lastVelocity,
1457
- includeVelocity,
1458
- lastBroadcastTime,
1459
- lastActivityTime,
1460
- throttle,
1461
- idleThreshold,
1462
- minChangeThreshold,
1463
- lastSentCursor,
1464
- lastSentFocus,
1465
- lastSentSelection,
1466
- lastSentEditing,
1467
- forceImmediate
1468
- );
1469
- };
1470
-
1471
- // Create event handlers
1472
- const eventHandlersState = {
1473
- currentCursor,
1474
- currentFocus,
1475
- currentSelection,
1476
- currentEditing,
1477
- isLocalUserEditing,
1478
- lastPosition,
1479
- lastVelocity,
1480
- lastActivityTime
1481
- };
1482
-
1483
- const eventHandlersCallbacks = {
1484
- getElementId: (el) => getElementId(el, element),
1485
- updateCursorPosition: updateCursorPositionWrapper
1486
- };
1487
-
1488
- const cleanupEventHandlers = createPresenceEventHandlers(element, eventHandlersState, eventHandlersCallbacks);
1489
-
1490
- // Update cursor position periodically
1491
- const updateInterval = setInterval(updateCursorPositionWrapper, throttle);
1492
-
1493
- // Subscribe to real-time updates from the presence table
1494
- const presenceChannel = `databases.${databaseId}.tables.${tableId}.rows`;
1495
-
1496
- // Setup realtime subscription
1497
- const unsubscribe = await setupPresenceRealtimeSubscription(
1498
- services,
1499
- presenceChannel,
1500
- channelId,
1501
- userInfo,
1502
- cursors,
1503
- element,
1504
- isLocalUserEditing,
1505
- currentEditing,
1506
- onUserJoin,
1507
- onUserLeave,
1508
- onCursorUpdate
1509
- );
1510
-
1511
- // Load initial cursor positions from database
1512
- await loadInitialCursors(
1513
- services,
1514
- databaseId,
1515
- tableId,
1516
- channelId,
1517
- userInfo,
1518
- cursors,
1519
- includeVelocity,
1520
- applyVisualIndicators,
1521
- element,
1522
- onCursorUpdate
1523
- );
1524
-
1525
- // Ensure initial cursors trigger callback even if load failed
1526
- if (onCursorUpdate && cursors.size > 0) {
1527
- onCursorUpdate(Array.from(cursors.values()));
1528
- }
1529
-
1530
- // Clean up stale cursors (users who haven't updated in a while)
1531
- const cleanupIntervalId = setInterval(() => {
1532
- const now = Date.now();
1533
- let hasChanges = false;
1534
-
1535
- cursors.forEach((cursor, userId) => {
1536
- if (now - cursor.lastSeen > cleanupInterval) {
1537
- cursors.delete(userId);
1538
- hasChanges = true;
1539
-
1540
- if (onUserLeave) {
1541
- onUserLeave({ userId });
1542
- }
1543
- }
1544
- });
1545
-
1546
- if (hasChanges && onCursorUpdate) {
1547
- onCursorUpdate(Array.from(cursors.values()));
1548
- }
1549
- }, cleanupInterval);
1550
-
1551
- // Cleanup function
1552
- const cleanup = () => {
1553
- cleanupEventHandlers();
1554
- clearInterval(updateInterval);
1555
- clearInterval(cleanupIntervalId);
1556
-
1557
- // Remove our presence from database on cleanup
1558
- try {
1559
- services.tablesDB.deleteRow({
1560
- databaseId,
1561
- tableId,
1562
- rowId: userInfo.id
1563
- });
1564
- } catch (error) {
1565
- console.warn('[Manifest Presence] Failed to remove presence on cleanup:', error);
1566
- }
1567
- };
1568
-
1569
- // Store subscription info
1570
- presenceSubscriptions.set(channelId, {
1571
- unsubscribe,
1572
- cursorTracker: { cleanup },
1573
- cursors,
1574
- element,
1575
- userInfo,
1576
- updateInterval,
1577
- cleanupInterval: cleanupIntervalId
1578
- });
1579
-
1580
- return {
1581
- unsubscribe: () => {
1582
- cleanup();
1583
- // Only call unsubscribe if it's actually a function
1584
- if (typeof unsubscribe === 'function') {
1585
- try {
1586
- unsubscribe();
1587
- } catch (error) {
1588
- console.warn('[Manifest Presence] Error during unsubscribe:', error);
1589
- }
1590
- }
1591
- presenceSubscriptions.delete(channelId);
1592
- },
1593
- cursors, // Expose cursors map for rendering
1594
- userInfo
1595
- };
1596
- }
1597
-
1598
- // Unsubscribe from presence channel
1599
- function unsubscribeFromPresence(channelId) {
1600
- if (presenceSubscriptions.has(channelId)) {
1601
- const subscription = presenceSubscriptions.get(channelId);
1602
- if (subscription.unsubscribe) {
1603
- subscription.unsubscribe();
1604
- }
1605
- if (subscription.cursorTracker && subscription.cursorTracker.cleanup) {
1606
- subscription.cursorTracker.cleanup();
1607
- }
1608
- presenceSubscriptions.delete(channelId);
1609
- }
1610
- }
1611
-
1612
- // Unsubscribe from all presence channels
1613
- function unsubscribeAllPresence() {
1614
- presenceSubscriptions.forEach((subscription, channelId) => {
1615
- if (subscription.unsubscribe) {
1616
- subscription.unsubscribe();
1617
- }
1618
- if (subscription.cursorTracker && subscription.cursorTracker.cleanup) {
1619
- subscription.cursorTracker.cleanup();
1620
- }
1621
- });
1622
- presenceSubscriptions.clear();
1623
- }
1624
-
1625
- // Initialize visual rendering on plugin load (if DOM is ready)
1626
- if (typeof document !== 'undefined') {
1627
- if (document.readyState === 'loading') {
1628
- document.addEventListener('DOMContentLoaded', () => {
1629
- initializeVisualRendering();
1630
- });
1631
- } else {
1632
- initializeVisualRendering();
1633
- }
1634
- }
1635
-
1636
- // Export functions
1637
- window.ManifestDataPresence = {
1638
- subscribeToPresence,
1639
- unsubscribeFromPresence,
1640
- unsubscribeAllPresence,
1641
- getUserColor,
1642
- getUserInfo,
1643
- interpolateCursorPosition, // Export for UI rendering
1644
- lerp, // Export for UI rendering
1645
- smoothInterpolate, // Export for UI rendering
1646
- renderCaret, // Export for manual caret rendering
1647
- renderSelection, // Export for manual selection rendering
1648
- initializeVisualRendering, // Export for manual initialization
1649
- presenceSubscriptions // Expose for debugging
1650
- };