mnfst 0.5.14

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.
Files changed (47) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +58 -0
  3. package/dist/manifest.accordion.css +81 -0
  4. package/dist/manifest.appwrite.auth.js +6247 -0
  5. package/dist/manifest.appwrite.data.js +1586 -0
  6. package/dist/manifest.appwrite.presence.js +1845 -0
  7. package/dist/manifest.avatar.css +113 -0
  8. package/dist/manifest.button.css +79 -0
  9. package/dist/manifest.checkbox.css +58 -0
  10. package/dist/manifest.code.css +453 -0
  11. package/dist/manifest.code.js +958 -0
  12. package/dist/manifest.code.min.css +1 -0
  13. package/dist/manifest.components.js +737 -0
  14. package/dist/manifest.css +3124 -0
  15. package/dist/manifest.data.js +11413 -0
  16. package/dist/manifest.dialog.css +130 -0
  17. package/dist/manifest.divider.css +77 -0
  18. package/dist/manifest.dropdown.css +278 -0
  19. package/dist/manifest.dropdowns.js +378 -0
  20. package/dist/manifest.form.css +169 -0
  21. package/dist/manifest.icons.js +161 -0
  22. package/dist/manifest.input.css +129 -0
  23. package/dist/manifest.js +302 -0
  24. package/dist/manifest.localization.js +571 -0
  25. package/dist/manifest.markdown.js +738 -0
  26. package/dist/manifest.min.css +1 -0
  27. package/dist/manifest.radio.css +38 -0
  28. package/dist/manifest.resize.css +233 -0
  29. package/dist/manifest.resize.js +442 -0
  30. package/dist/manifest.router.js +1207 -0
  31. package/dist/manifest.sidebar.css +102 -0
  32. package/dist/manifest.slides.css +80 -0
  33. package/dist/manifest.slides.js +173 -0
  34. package/dist/manifest.switch.css +44 -0
  35. package/dist/manifest.table.css +74 -0
  36. package/dist/manifest.tabs.js +273 -0
  37. package/dist/manifest.tailwind.js +578 -0
  38. package/dist/manifest.theme.css +119 -0
  39. package/dist/manifest.themes.js +109 -0
  40. package/dist/manifest.toast.css +92 -0
  41. package/dist/manifest.toasts.js +285 -0
  42. package/dist/manifest.tooltip.css +156 -0
  43. package/dist/manifest.tooltips.js +331 -0
  44. package/dist/manifest.typography.css +341 -0
  45. package/dist/manifest.utilities.css +399 -0
  46. package/dist/manifest.utilities.js +3197 -0
  47. package/package.json +63 -0
@@ -0,0 +1,1845 @@
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
+ console.log(`[Presence Debug] Finding focus element:`, {
195
+ elementId: focus.elementId,
196
+ focusObject: focus
197
+ });
198
+ const targetElement = findElementById(focus.elementId, containerElement);
199
+ console.log(`[Presence Debug] Focus element search result:`, {
200
+ elementId: focus.elementId,
201
+ found: !!targetElement,
202
+ element: targetElement,
203
+ elementTag: targetElement?.tagName,
204
+ elementIdAttr: targetElement?.id
205
+ });
206
+ if (targetElement) {
207
+ targetElement.classList.add('presence-focused');
208
+ targetElement.setAttribute('data-presence-focus-user', userId);
209
+ targetElement.setAttribute('data-presence-focus-color', color);
210
+ console.log(`[Presence Debug] Applied focus indicator to element:`, {
211
+ element: targetElement,
212
+ elementId: focus.elementId,
213
+ userId,
214
+ color
215
+ });
216
+ } else {
217
+ console.warn(`[Presence Debug] Could not find focus element with ID: ${focus.elementId}`);
218
+ }
219
+ } else {
220
+ console.log(`[Presence Debug] No focus to apply:`, {
221
+ hasFocus: !!focus,
222
+ hasElementId: focus?.elementId,
223
+ focus
224
+ });
225
+ }
226
+
227
+ // Apply selection indicator
228
+ if (selection && selection.elementId) {
229
+ const targetElement = findElementById(selection.elementId, containerElement);
230
+ console.log(`[Presence Debug] Finding selection element:`, {
231
+ elementId: selection.elementId,
232
+ found: !!targetElement,
233
+ element: targetElement
234
+ });
235
+ if (targetElement) {
236
+ const start = selection.start !== undefined ? selection.start : (selection.startOffset || 0);
237
+ const end = selection.end !== undefined ? selection.end : (selection.endOffset || start);
238
+ targetElement.setAttribute('data-presence-selection-user', userId);
239
+ targetElement.setAttribute('data-presence-selection-start', start.toString());
240
+ targetElement.setAttribute('data-presence-selection-end', end.toString());
241
+ targetElement.setAttribute('data-presence-selection-color', color);
242
+ // Trigger custom event for selection rendering
243
+ targetElement.dispatchEvent(new CustomEvent('presence:selection', {
244
+ detail: { userId, selection: { start, end }, color }
245
+ }));
246
+ console.log(`[Presence Debug] Applied selection indicator to element:`, targetElement);
247
+ } else {
248
+ console.warn(`[Presence Debug] Could not find selection element with ID: ${selection.elementId}`);
249
+ }
250
+ }
251
+
252
+ // Apply caret indicator
253
+ if (editing && editing.elementId && editing.caretPosition !== null && editing.caretPosition !== undefined) {
254
+ const targetElement = findElementById(editing.elementId, containerElement);
255
+ console.log(`[Presence Debug] Finding caret element:`, {
256
+ elementId: editing.elementId,
257
+ caretPosition: editing.caretPosition,
258
+ found: !!targetElement,
259
+ element: targetElement
260
+ });
261
+ if (targetElement) {
262
+ targetElement.setAttribute('data-presence-caret-user', userId);
263
+ targetElement.setAttribute('data-presence-caret-position', editing.caretPosition.toString());
264
+ targetElement.setAttribute('data-presence-caret-color', color);
265
+ // Trigger custom event for caret rendering
266
+ targetElement.dispatchEvent(new CustomEvent('presence:caret', {
267
+ detail: { userId, caretPosition: editing.caretPosition, color }
268
+ }));
269
+ console.log(`[Presence Debug] Applied caret indicator to element:`, targetElement);
270
+ } else {
271
+ console.warn(`[Presence Debug] Could not find caret element with ID: ${editing.elementId}`);
272
+ }
273
+ }
274
+ }
275
+
276
+ // Helper function to update element value (agnostic for input/textarea/contenteditable)
277
+ function updateElementValue(targetElement, newValue, caretPosition) {
278
+ if (!targetElement) return false;
279
+
280
+ try {
281
+ // Check if element is editable
282
+ const isEditable = targetElement.tagName === 'INPUT' ||
283
+ targetElement.tagName === 'TEXTAREA' ||
284
+ targetElement.isContentEditable;
285
+
286
+ if (!isEditable) return false;
287
+
288
+ // Temporarily disable input event to prevent infinite loop
289
+ const wasDisabled = targetElement.hasAttribute('data-presence-syncing');
290
+ targetElement.setAttribute('data-presence-syncing', 'true');
291
+
292
+ // Update value based on element type
293
+ if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') {
294
+ const currentValue = targetElement.value || '';
295
+ if (currentValue !== newValue) {
296
+ targetElement.value = newValue;
297
+ // Set caret position if provided
298
+ if (caretPosition !== null && caretPosition !== undefined &&
299
+ targetElement.setSelectionRange) {
300
+ try {
301
+ targetElement.setSelectionRange(caretPosition, caretPosition);
302
+ } catch (e) {
303
+ // Some input types don't support setSelectionRange
304
+ }
305
+ }
306
+ // Trigger input event for reactivity (but mark it as synced)
307
+ const inputEvent = new Event('input', { bubbles: true });
308
+ targetElement.dispatchEvent(inputEvent);
309
+ }
310
+ } else if (targetElement.isContentEditable) {
311
+ const currentValue = targetElement.textContent || '';
312
+ if (currentValue !== newValue) {
313
+ targetElement.textContent = newValue;
314
+ // Try to set caret position for contenteditable
315
+ if (caretPosition !== null && caretPosition !== undefined) {
316
+ try {
317
+ const range = document.createRange();
318
+ const selection = window.getSelection();
319
+ const textNode = targetElement.firstChild;
320
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
321
+ const pos = Math.min(caretPosition, textNode.textContent.length);
322
+ range.setStart(textNode, pos);
323
+ range.setEnd(textNode, pos);
324
+ selection.removeAllRanges();
325
+ selection.addRange(range);
326
+ }
327
+ } catch (e) {
328
+ // Ignore caret positioning errors
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ // Remove sync flag after a short delay
335
+ setTimeout(() => {
336
+ targetElement.removeAttribute('data-presence-syncing');
337
+ }, 50);
338
+
339
+ return true;
340
+ } catch (e) {
341
+ console.warn('[Manifest Presence] Failed to update element value:', e);
342
+ targetElement.removeAttribute('data-presence-syncing');
343
+ return false;
344
+ }
345
+ }
346
+
347
+
348
+ /* Manifest Data Sources - Presence Event Tracking */
349
+
350
+ // Create event handlers for presence tracking
351
+ function createPresenceEventHandlers(element, state, callbacks) {
352
+ const {
353
+ currentCursor,
354
+ currentFocus,
355
+ currentSelection,
356
+ currentEditing,
357
+ isLocalUserEditing,
358
+ lastPosition,
359
+ lastVelocity,
360
+ lastActivityTime
361
+ } = state;
362
+
363
+ const {
364
+ getElementId,
365
+ updateCursorPosition
366
+ } = callbacks;
367
+
368
+ // Throttle mousemove to prevent performance issues
369
+ let lastMouseMoveTime = 0;
370
+ const mouseMoveThrottle = 16; // ~60fps max
371
+
372
+ // Cursor position tracker with velocity calculation for smooth interpolation
373
+ const handleMouseMove = (e) => {
374
+ const now = Date.now();
375
+ // Throttle mousemove events to prevent forced reflows
376
+ if (now - lastMouseMoveTime < mouseMoveThrottle) {
377
+ return;
378
+ }
379
+ lastMouseMoveTime = now;
380
+
381
+ // Use requestAnimationFrame to batch DOM reads
382
+ requestAnimationFrame(() => {
383
+ const rect = element.getBoundingClientRect();
384
+ const x = e.clientX - rect.left;
385
+ const y = e.clientY - rect.top;
386
+ const now = Date.now();
387
+
388
+ // Calculate velocity for smooth interpolation (pixels per second)
389
+ const dt = (now - lastPosition.time) / 1000; // Convert to seconds
390
+ if (dt > 0 && dt < 1) { // Only calculate if reasonable time difference
391
+ lastVelocity.vx = (x - lastPosition.x) / dt;
392
+ lastVelocity.vy = (y - lastPosition.y) / dt;
393
+ }
394
+
395
+ currentCursor.x = x;
396
+ currentCursor.y = y;
397
+ lastPosition.x = x;
398
+ lastPosition.y = y;
399
+ lastPosition.time = now;
400
+ lastActivityTime.value = now; // Update activity timestamp
401
+ });
402
+ };
403
+
404
+ const handleTouchMove = (e) => {
405
+ if (e.touches.length > 0) {
406
+ const rect = element.getBoundingClientRect();
407
+ const x = e.touches[0].clientX - rect.left;
408
+ const y = e.touches[0].clientY - rect.top;
409
+ const now = Date.now();
410
+
411
+ // Calculate velocity
412
+ const dt = (now - lastPosition.time) / 1000;
413
+ if (dt > 0 && dt < 1) {
414
+ lastVelocity.vx = (x - lastPosition.x) / dt;
415
+ lastVelocity.vy = (y - lastPosition.y) / dt;
416
+ }
417
+
418
+ currentCursor.x = x;
419
+ currentCursor.y = y;
420
+ lastPosition.x = x;
421
+ lastPosition.y = y;
422
+ lastPosition.time = now;
423
+ lastActivityTime.value = now;
424
+ }
425
+ };
426
+
427
+ // Focus tracking
428
+ const handleFocus = (e) => {
429
+ const target = e.target;
430
+ if (target && element.contains(target)) {
431
+ const elementId = getElementId(target, element);
432
+ currentFocus.value = {
433
+ elementId: elementId,
434
+ elementType: target.type || target.tagName.toLowerCase(),
435
+ tagName: target.tagName.toLowerCase(),
436
+ placeholder: target.placeholder || null,
437
+ name: target.name || null
438
+ };
439
+ lastActivityTime.value = Date.now(); // Update activity on focus
440
+ // Trigger immediate update for focus changes (important for UX)
441
+ updateCursorPosition(true); // Force immediate update
442
+ }
443
+ };
444
+
445
+ const handleBlur = (e) => {
446
+ currentFocus.value = null;
447
+ currentEditing.value = null;
448
+ currentSelection.value = null;
449
+ isLocalUserEditing.value = false;
450
+ // Trigger immediate update when focus is lost
451
+ updateCursorPosition(true); // Force immediate update
452
+ };
453
+
454
+ // Text selection tracking
455
+ const handleSelectionChange = () => {
456
+ const selection = window.getSelection();
457
+ if (selection && selection.rangeCount > 0 && !selection.isCollapsed) {
458
+ const range = selection.getRangeAt(0);
459
+ const target = range.commonAncestorContainer;
460
+ const targetElement = target.nodeType === Node.TEXT_NODE ? target.parentElement : target;
461
+
462
+ // Check if the selection is within our tracked element
463
+ if (targetElement && element.contains && element.contains(targetElement)) {
464
+ const elementId = getElementId(targetElement, element);
465
+ if (elementId) {
466
+ const selectedText = selection.toString();
467
+ if (selectedText && selectedText.trim().length > 0) {
468
+ currentSelection.value = {
469
+ elementId: elementId,
470
+ start: range.startOffset,
471
+ end: range.endOffset,
472
+ text: selectedText.substring(0, 100) // Limit text length
473
+ };
474
+ lastActivityTime.value = Date.now(); // Update activity on selection
475
+ } else {
476
+ currentSelection.value = null;
477
+ }
478
+ } else {
479
+ currentSelection.value = null;
480
+ }
481
+ } else {
482
+ currentSelection.value = null;
483
+ }
484
+ } else {
485
+ currentSelection.value = null;
486
+ }
487
+ };
488
+
489
+ // Text editing tracking (for input/textarea/contenteditable)
490
+ const handleInput = (e) => {
491
+ const target = e.target;
492
+ // Skip if this is a sync event (to prevent infinite loops)
493
+ if (target && target.hasAttribute('data-presence-syncing')) {
494
+ return;
495
+ }
496
+
497
+ if (target && element.contains(target)) {
498
+ const elementId = getElementId(target, element);
499
+ if (elementId && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
500
+ const selection = window.getSelection();
501
+ let caretPosition = null;
502
+
503
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
504
+ caretPosition = target.selectionStart || 0;
505
+ } else if (selection && selection.rangeCount > 0) {
506
+ const range = selection.getRangeAt(0);
507
+ caretPosition = range.startOffset;
508
+ }
509
+
510
+ currentEditing.value = {
511
+ elementId: elementId,
512
+ value: target.value || target.textContent || '',
513
+ caretPosition: caretPosition,
514
+ length: (target.value || target.textContent || '').length
515
+ };
516
+ isLocalUserEditing.value = true;
517
+ lastActivityTime.value = Date.now(); // Update activity on input
518
+ }
519
+ }
520
+ };
521
+
522
+ // Set up event listeners
523
+ element.addEventListener('mousemove', handleMouseMove);
524
+ element.addEventListener('touchmove', handleTouchMove, { passive: true });
525
+ element.addEventListener('focusin', handleFocus, true); // Use capture to catch all focus events
526
+ element.addEventListener('focusout', handleBlur, true);
527
+ element.addEventListener('selectionchange', handleSelectionChange);
528
+ element.addEventListener('input', handleInput, true);
529
+ element.addEventListener('keyup', handleInput, true); // Also track on keyup for caret position
530
+
531
+ // Return cleanup function
532
+ return () => {
533
+ element.removeEventListener('mousemove', handleMouseMove);
534
+ element.removeEventListener('touchmove', handleTouchMove);
535
+ element.removeEventListener('focusin', handleFocus, true);
536
+ element.removeEventListener('focusout', handleBlur, true);
537
+ element.removeEventListener('selectionchange', handleSelectionChange);
538
+ element.removeEventListener('input', handleInput, true);
539
+ element.removeEventListener('keyup', handleInput, true);
540
+ };
541
+ }
542
+
543
+
544
+ /* Manifest Data Sources - Presence Database Operations */
545
+
546
+ // Helper function to check if data has changed significantly
547
+ function hasSignificantChange(currentCursor, lastSentCursor, currentFocus, lastSentFocus, currentSelection, lastSentSelection, currentEditing, lastSentEditing, minChangeThreshold) {
548
+ // Check cursor position change (minimum threshold)
549
+ const cursorDeltaX = Math.abs(currentCursor.x - (lastSentCursor.x || 0));
550
+ const cursorDeltaY = Math.abs(currentCursor.y - (lastSentCursor.y || 0));
551
+ const cursorMoved = cursorDeltaX >= minChangeThreshold || cursorDeltaY >= minChangeThreshold;
552
+
553
+ // Check if focus changed
554
+ const focusChanged = JSON.stringify(currentFocus) !== JSON.stringify(lastSentFocus);
555
+
556
+ // Check if selection changed
557
+ const selectionChanged = JSON.stringify(currentSelection) !== JSON.stringify(lastSentSelection);
558
+
559
+ // Check if editing changed (always send editing updates - they're important)
560
+ const editingChanged = JSON.stringify(currentEditing) !== JSON.stringify(lastSentEditing);
561
+
562
+ // Update if cursor moved significantly OR any state changed
563
+ return cursorMoved || focusChanged || selectionChanged || editingChanged;
564
+ }
565
+
566
+ // Broadcast cursor position to database table
567
+ async function updateCursorPosition(
568
+ services,
569
+ databaseId,
570
+ tableId,
571
+ channelId,
572
+ userInfo,
573
+ currentCursor,
574
+ currentFocus,
575
+ currentSelection,
576
+ currentEditing,
577
+ lastVelocity,
578
+ includeVelocity,
579
+ lastBroadcastTime,
580
+ lastActivityTime,
581
+ throttle,
582
+ idleThreshold,
583
+ minChangeThreshold,
584
+ lastSentCursor,
585
+ lastSentFocus,
586
+ lastSentSelection,
587
+ lastSentEditing,
588
+ forceImmediate = false
589
+ ) {
590
+ const now = Date.now();
591
+
592
+ // Throttle broadcasts (unless forced)
593
+ if (!forceImmediate && now - lastBroadcastTime.value < throttle) {
594
+ return;
595
+ }
596
+
597
+ // Idle detection: Skip updates if user has been inactive
598
+ const timeSinceActivity = now - lastActivityTime.value;
599
+ if (timeSinceActivity > idleThreshold) {
600
+ // User is idle - don't send updates (saves writes)
601
+ return;
602
+ }
603
+
604
+ // Change detection: Only update if something actually changed
605
+ if (!forceImmediate && !hasSignificantChange(
606
+ currentCursor,
607
+ lastSentCursor.value,
608
+ currentFocus.value,
609
+ lastSentFocus.value,
610
+ currentSelection.value,
611
+ lastSentSelection.value,
612
+ currentEditing.value,
613
+ lastSentEditing.value,
614
+ minChangeThreshold
615
+ ) && lastSentCursor.value.x !== null) {
616
+ // Nothing significant changed - skip this update (saves writes)
617
+ return;
618
+ }
619
+
620
+ lastBroadcastTime.value = now;
621
+
622
+ try {
623
+ // Create or update cursor position in database
624
+ // Use userId as the unique identifier (upsert pattern)
625
+ // Store focus/selection/editing as JSON strings (Appwrite doesn't have native JSON type)
626
+ // Include velocity for client-side interpolation (optional, requires table schema)
627
+ const presenceData = {
628
+ userId: userInfo.id,
629
+ channelId: channelId,
630
+ x: currentCursor.x,
631
+ y: currentCursor.y,
632
+ name: userInfo.name,
633
+ color: userInfo.color,
634
+ lastSeen: now,
635
+ focus: currentFocus.value ? JSON.stringify(currentFocus.value) : null,
636
+ selection: currentSelection.value ? JSON.stringify(currentSelection.value) : null,
637
+ editing: currentEditing.value ? JSON.stringify(currentEditing.value) : null
638
+ };
639
+
640
+ console.log(`[Presence Debug] Broadcasting presence update:`, {
641
+ userId: userInfo.id,
642
+ channelId,
643
+ hasFocus: !!currentFocus.value,
644
+ hasSelection: !!currentSelection.value,
645
+ hasEditing: !!currentEditing.value,
646
+ focus: currentFocus.value,
647
+ selection: currentSelection.value,
648
+ editing: currentEditing.value,
649
+ presenceData
650
+ });
651
+
652
+ // Only include velocity if enabled and table schema supports it
653
+ if (includeVelocity) {
654
+ presenceData.vx = lastVelocity.vx; // Velocity X (pixels per second) for interpolation
655
+ presenceData.vy = lastVelocity.vy; // Velocity Y (pixels per second) for interpolation
656
+ }
657
+
658
+ // Update last sent values for change detection
659
+ lastSentCursor.value = { x: currentCursor.x, y: currentCursor.y };
660
+ lastSentFocus.value = currentFocus.value ? JSON.parse(JSON.stringify(currentFocus.value)) : null;
661
+ lastSentSelection.value = currentSelection.value ? JSON.parse(JSON.stringify(currentSelection.value)) : null;
662
+ lastSentEditing.value = currentEditing.value ? JSON.parse(JSON.stringify(currentEditing.value)) : null;
663
+
664
+ // Upsert logic: try update first, then create if not found
665
+ try {
666
+ // Try to update existing row first
667
+ await services.tablesDB.updateRow({
668
+ databaseId,
669
+ tableId,
670
+ rowId: userInfo.id,
671
+ data: presenceData
672
+ });
673
+ } catch (updateError) {
674
+ // If 404, row doesn't exist yet - create it
675
+ // Check multiple error properties (Appwrite SDK may structure errors differently)
676
+ const isNotFound = updateError?.code === 404 ||
677
+ updateError?.response?.code === 404 ||
678
+ updateError?.statusCode === 404 ||
679
+ updateError?.message?.includes('404') ||
680
+ updateError?.message?.includes('not found');
681
+
682
+ if (isNotFound) {
683
+ try {
684
+ await services.tablesDB.createRow({
685
+ databaseId,
686
+ tableId,
687
+ rowId: userInfo.id, // Use userId as row ID
688
+ data: presenceData
689
+ });
690
+ } catch (createError) {
691
+ // If 409 (conflict), row was created between update and create - try update again
692
+ if (createError?.code === 409) {
693
+ try {
694
+ await services.tablesDB.updateRow({
695
+ databaseId,
696
+ tableId,
697
+ rowId: userInfo.id,
698
+ data: presenceData
699
+ });
700
+ } catch (retryError) {
701
+ // Suppress 401 errors (permission issues)
702
+ if (retryError?.code !== 401) {
703
+ console.error('[Manifest Presence] Failed to update cursor position after conflict:', retryError);
704
+ }
705
+ throw retryError;
706
+ }
707
+ } else if (createError?.code !== 401) {
708
+ // Suppress 401 errors (permission issues) - user needs to set table permissions
709
+ console.error('[Manifest Presence] Failed to create cursor position:', createError);
710
+ throw createError;
711
+ } else {
712
+ throw createError;
713
+ }
714
+ }
715
+ } else if (updateError?.code === 401) {
716
+ // Permission issue - suppress repeated logs
717
+ throw updateError;
718
+ } else {
719
+ // Other error - suppress 404 errors (they're handled by creating the row)
720
+ const isNotFoundError = updateError?.code === 404 ||
721
+ updateError?.response?.code === 404 ||
722
+ updateError?.statusCode === 404 ||
723
+ updateError?.message?.includes('404') ||
724
+ updateError?.message?.includes('not found');
725
+ if (!isNotFoundError && updateError?.code !== 401) {
726
+ console.error('[Manifest Presence] Failed to update cursor position:', updateError);
727
+ }
728
+ // Don't rethrow 404 errors - they're expected and handled
729
+ if (!isNotFoundError) {
730
+ throw updateError;
731
+ }
732
+ }
733
+ }
734
+ } catch (error) {
735
+ // Suppress 401 and 404 errors (permission issues and expected not-found during initial creation)
736
+ const isNotFoundError = error?.code === 404 ||
737
+ error?.response?.code === 404 ||
738
+ error?.statusCode === 404 ||
739
+ error?.message?.includes('404') ||
740
+ error?.message?.includes('not found');
741
+ if (error?.code !== 401 && !isNotFoundError) {
742
+ console.error('[Manifest Presence] Failed to update cursor position:', error);
743
+ }
744
+ }
745
+ }
746
+
747
+ // Load initial cursor positions from database
748
+ async function loadInitialCursors(services, databaseId, tableId, channelId, userInfo, cursors, includeVelocity, applyVisualIndicators, containerElement, onCursorUpdate) {
749
+ try {
750
+ const allCursors = await services.tablesDB.listRows({
751
+ databaseId,
752
+ tableId,
753
+ queries: [
754
+ window.Appwrite.Query.equal('channelId', channelId)
755
+ ]
756
+ });
757
+
758
+ if (allCursors && allCursors.rows) {
759
+ allCursors.rows.forEach(row => {
760
+ if (row.userId && row.userId !== userInfo.id) {
761
+ // Parse JSON strings for focus/selection/editing
762
+ let focus = null, selection = null, editing = null;
763
+ try {
764
+ focus = row.focus ? (typeof row.focus === 'string' ? JSON.parse(row.focus) : row.focus) : null;
765
+ selection = row.selection ? (typeof row.selection === 'string' ? JSON.parse(row.selection) : row.selection) : null;
766
+ editing = row.editing ? (typeof row.editing === 'string' ? JSON.parse(row.editing) : row.editing) : null;
767
+ } catch (e) {
768
+ console.warn('[Manifest Presence] Failed to parse presence data from row:', e);
769
+ }
770
+
771
+ const userColor = row.color || getUserColor(row.userId);
772
+ cursors.set(row.userId, {
773
+ x: row.x || 0,
774
+ y: row.y || 0,
775
+ vx: (includeVelocity && row.vx !== undefined) ? row.vx : 0, // Velocity X for interpolation (optional)
776
+ vy: (includeVelocity && row.vy !== undefined) ? row.vy : 0, // Velocity Y for interpolation (optional)
777
+ name: row.name || 'Anonymous',
778
+ color: userColor,
779
+ lastSeen: row.lastSeen || Date.now(),
780
+ lastUpdateTime: Date.now(), // Track when we loaded this for interpolation
781
+ focus: focus,
782
+ selection: selection,
783
+ editing: editing
784
+ });
785
+
786
+ // Apply visual indicators for initial load
787
+ console.log(`[Presence Debug] Applying visual indicators for initial load:`, {
788
+ userId: row.userId,
789
+ focus,
790
+ selection,
791
+ editing
792
+ });
793
+ applyVisualIndicators(row.userId, focus, selection, editing, userColor, containerElement);
794
+ }
795
+ });
796
+
797
+ console.log(`[Presence Debug] Initial cursors processed, total cursors: ${cursors.size}`);
798
+
799
+ // Trigger callback after loading initial cursors
800
+ if (onCursorUpdate) {
801
+ const cursorArray = Array.from(cursors.values());
802
+ console.log(`[Presence Debug] Triggering onCursorUpdate with ${cursorArray.length} cursors`);
803
+ onCursorUpdate(cursorArray);
804
+ }
805
+ }
806
+ } catch (error) {
807
+ // Suppress 401 errors (permission issues) - user needs to set table permissions
808
+ if (error?.code !== 401) {
809
+ console.warn('[Manifest Presence] Failed to load initial cursors:', error);
810
+ }
811
+ }
812
+ }
813
+
814
+
815
+ /* Manifest Data Sources - Presence Realtime Subscription */
816
+
817
+ // Create realtime subscription callback
818
+ function createPresenceRealtimeCallback(
819
+ channelId,
820
+ userInfo,
821
+ cursors,
822
+ containerElement,
823
+ isLocalUserEditing,
824
+ currentEditing,
825
+ onUserJoin,
826
+ onUserLeave,
827
+ onCursorUpdate
828
+ ) {
829
+ return (response) => {
830
+ // Log immediately to verify callback is being called
831
+ console.log(`[Presence Debug] ===== CALLBACK INVOKED =====`, {
832
+ timestamp: new Date().toISOString(),
833
+ hasResponse: !!response,
834
+ responseType: typeof response
835
+ });
836
+ console.log(`[Presence Debug] ===== PRESENCE EVENT RECEIVED =====`);
837
+ console.log(`[Presence Debug] Callback executed! This means realtime is working.`);
838
+ console.log(`[Presence Debug] Presence event received:`, {
839
+ hasResponse: !!response,
840
+ responseType: typeof response,
841
+ responseKeys: response ? Object.keys(response) : [],
842
+ hasEvents: !!response?.events,
843
+ events: response?.events,
844
+ eventsType: typeof response?.events,
845
+ eventsIsArray: Array.isArray(response?.events),
846
+ payload: response?.payload,
847
+ payloadKeys: response?.payload ? Object.keys(response?.payload) : [],
848
+ channelId,
849
+ fullResponse: response
850
+ });
851
+
852
+ // Handle both array and single event formats (like in manifest.data.realtime.js)
853
+ if (!response) {
854
+ console.warn(`[Presence Debug] No response received`);
855
+ return;
856
+ }
857
+
858
+ // Check if events exist, handle both array and single event
859
+ const events = response.events ?
860
+ (Array.isArray(response.events) ? response.events : [response.events]) :
861
+ [];
862
+
863
+ if (events.length === 0) {
864
+ console.warn(`[Presence Debug] No events in response:`, response);
865
+ return;
866
+ }
867
+
868
+ events.forEach(event => {
869
+ if (typeof event !== 'string') {
870
+ console.warn(`[Presence Debug] Non-string event:`, event);
871
+ return;
872
+ }
873
+
874
+ const payload = response.payload || response;
875
+ const userId = payload.userId || payload.$id;
876
+
877
+ console.log(`[Presence Debug] Processing presence event:`, {
878
+ event,
879
+ userId,
880
+ localUserId: userInfo.id,
881
+ payloadKeys: Object.keys(payload || {})
882
+ });
883
+
884
+ // Ignore our own updates
885
+ if (!userId || userId === userInfo.id) {
886
+ console.log(`[Presence Debug] Ignoring own update (userId: ${userId})`);
887
+ return;
888
+ }
889
+
890
+ if (event.includes('create') || event.includes('rows.create')) {
891
+ handleUserJoin(event, payload, channelId, userId, cursors, containerElement, onUserJoin, onCursorUpdate);
892
+ } else if (event.includes('update') || event.includes('rows.update')) {
893
+ handleUserUpdate(event, payload, channelId, userId, cursors, containerElement, isLocalUserEditing, currentEditing, onCursorUpdate);
894
+ } else if (event.includes('delete') || event.includes('rows.delete')) {
895
+ handleUserLeave(event, userId, onUserLeave, onCursorUpdate, cursors);
896
+ }
897
+ });
898
+ };
899
+ }
900
+
901
+ // Handle user join event
902
+ function handleUserJoin(event, payload, channelId, userId, cursors, containerElement, onUserJoin, onCursorUpdate) {
903
+ console.log(`[Presence Debug] User joined:`, { userId, name: payload.name, channelId: payload.channelId });
904
+ // User joined
905
+ if (payload.channelId === channelId) {
906
+ // Parse JSON strings for focus/selection/editing
907
+ let focus = null, selection = null, editing = null;
908
+ try {
909
+ focus = payload.focus ? (typeof payload.focus === 'string' ? JSON.parse(payload.focus) : payload.focus) : null;
910
+ selection = payload.selection ? (typeof payload.selection === 'string' ? JSON.parse(payload.selection) : payload.selection) : null;
911
+ editing = payload.editing ? (typeof payload.editing === 'string' ? JSON.parse(payload.editing) : payload.editing) : null;
912
+ } catch (e) {
913
+ console.warn('[Manifest Presence] Failed to parse presence data:', e);
914
+ }
915
+
916
+ const userColor = payload.color || getUserColor(userId);
917
+ cursors.set(userId, {
918
+ x: payload.x || 0,
919
+ y: payload.y || 0,
920
+ vx: payload.vx || 0, // Velocity X for interpolation
921
+ vy: payload.vy || 0, // Velocity Y for interpolation
922
+ name: payload.name || 'Anonymous',
923
+ color: userColor,
924
+ lastSeen: payload.lastSeen || Date.now(),
925
+ lastUpdateTime: Date.now(), // Track when we received this update for interpolation
926
+ focus: focus,
927
+ selection: selection,
928
+ editing: editing
929
+ });
930
+
931
+ // Apply visual indicators when user joins
932
+ console.log(`[Presence Debug] User joined, applying visual indicators:`, {
933
+ userId,
934
+ focus,
935
+ selection,
936
+ editing,
937
+ userColor
938
+ });
939
+ applyVisualIndicators(userId, focus, selection, editing, userColor, containerElement);
940
+
941
+ if (onUserJoin) {
942
+ onUserJoin({ userId, name: payload.name, color: userColor });
943
+ }
944
+
945
+ if (onCursorUpdate) {
946
+ onCursorUpdate(Array.from(cursors.values()));
947
+ }
948
+ } else {
949
+ console.log(`[Presence Debug] User joined but channelId mismatch:`, {
950
+ payloadChannelId: payload.channelId,
951
+ expectedChannelId: channelId
952
+ });
953
+ }
954
+ }
955
+
956
+ // Handle user update event
957
+ function handleUserUpdate(event, payload, channelId, userId, cursors, containerElement, isLocalUserEditing, currentEditing, onCursorUpdate) {
958
+ // Presence updated (cursor, focus, selection, editing)
959
+ console.log(`[Presence Debug] Presence update event:`, {
960
+ userId,
961
+ payloadChannelId: payload.channelId,
962
+ expectedChannelId: channelId,
963
+ matches: payload.channelId === channelId
964
+ });
965
+
966
+ if (payload.channelId === channelId) {
967
+ const existing = cursors.get(userId) || {};
968
+
969
+ // Parse JSON strings for focus/selection/editing
970
+ let focus = existing.focus, selection = existing.selection, editing = existing.editing;
971
+ if (payload.focus !== undefined) {
972
+ console.log(`[Presence Debug] Processing focus update for ${userId}:`, {
973
+ focusPayload: payload.focus,
974
+ existingFocus: existing.focus
975
+ });
976
+ try {
977
+ focus = payload.focus ? (typeof payload.focus === 'string' ? JSON.parse(payload.focus) : payload.focus) : null;
978
+
979
+ // Add CSS class to element for customizable styling
980
+ if (focus && focus.elementId) {
981
+ const targetElement = findElementById(focus.elementId, containerElement);
982
+ if (targetElement) {
983
+ targetElement.classList.add('presence-focused');
984
+ targetElement.setAttribute('data-presence-focus-user', userId);
985
+ targetElement.setAttribute('data-presence-focus-color', existing.color || getUserColor(userId));
986
+ }
987
+ }
988
+ // Remove focus class from elements that are no longer focused by this user
989
+ if (!focus || !focus.elementId) {
990
+ document.querySelectorAll(`[data-presence-focus-user="${userId}"]`).forEach(el => {
991
+ el.classList.remove('presence-focused');
992
+ el.removeAttribute('data-presence-focus-user');
993
+ el.removeAttribute('data-presence-focus-color');
994
+ });
995
+ }
996
+ } catch (e) {
997
+ console.warn('[Manifest Presence] Failed to parse focus:', e);
998
+ }
999
+ }
1000
+ if (payload.selection !== undefined) {
1001
+ try {
1002
+ selection = payload.selection ? (typeof payload.selection === 'string' ? JSON.parse(payload.selection) : payload.selection) : null;
1003
+
1004
+ // Update selection indicator
1005
+ if (selection && selection.elementId) {
1006
+ const targetElement = findElementById(selection.elementId, containerElement);
1007
+ if (targetElement) {
1008
+ // Store selection data for rendering
1009
+ const start = selection.start !== undefined ? selection.start : (selection.startOffset || 0);
1010
+ const end = selection.end !== undefined ? selection.end : (selection.endOffset || start);
1011
+ targetElement.setAttribute('data-presence-selection-user', userId);
1012
+ targetElement.setAttribute('data-presence-selection-start', start.toString());
1013
+ targetElement.setAttribute('data-presence-selection-end', end.toString());
1014
+ targetElement.setAttribute('data-presence-selection-color', existing.color || getUserColor(userId));
1015
+ // Trigger custom event for selection rendering
1016
+ targetElement.dispatchEvent(new CustomEvent('presence:selection', {
1017
+ detail: { userId, selection: { start, end }, color: existing.color || getUserColor(userId) }
1018
+ }));
1019
+ }
1020
+ } else {
1021
+ // Remove selection indicators
1022
+ document.querySelectorAll(`[data-presence-selection-user="${userId}"]`).forEach(el => {
1023
+ el.removeAttribute('data-presence-selection-user');
1024
+ el.removeAttribute('data-presence-selection-start');
1025
+ el.removeAttribute('data-presence-selection-end');
1026
+ el.removeAttribute('data-presence-selection-color');
1027
+ // Remove visual indicator
1028
+ const indicator = el.querySelector('.presence-selection');
1029
+ if (indicator) indicator.remove();
1030
+ });
1031
+ }
1032
+ } catch (e) {
1033
+ console.warn('[Manifest Presence] Failed to parse selection:', e);
1034
+ }
1035
+ }
1036
+ if (payload.editing !== undefined) {
1037
+ try {
1038
+ editing = payload.editing ? (typeof payload.editing === 'string' ? JSON.parse(payload.editing) : payload.editing) : null;
1039
+
1040
+ // Real-time text syncing: Update element if local user is not editing it
1041
+ if (editing && editing.elementId && editing.value !== undefined) {
1042
+ // Check if local user is currently editing this element
1043
+ const isLocalEditingThis = isLocalUserEditing.value &&
1044
+ currentEditing.value &&
1045
+ currentEditing.value.elementId === editing.elementId;
1046
+
1047
+ if (!isLocalEditingThis) {
1048
+ // Local user is not editing this element - safe to sync
1049
+ const targetElement = findElementById(editing.elementId, containerElement);
1050
+ if (targetElement) {
1051
+ // Check if element is currently being synced (to prevent conflicts)
1052
+ if (!targetElement.hasAttribute('data-presence-syncing')) {
1053
+ updateElementValue(targetElement, editing.value, editing.caretPosition);
1054
+ }
1055
+
1056
+ // Add caret position indicator
1057
+ if (editing.caretPosition !== null && editing.caretPosition !== undefined) {
1058
+ targetElement.setAttribute('data-presence-caret-user', userId);
1059
+ targetElement.setAttribute('data-presence-caret-position', editing.caretPosition);
1060
+ targetElement.setAttribute('data-presence-caret-color', existing.color || getUserColor(userId));
1061
+ // Trigger custom event for caret rendering
1062
+ targetElement.dispatchEvent(new CustomEvent('presence:caret', {
1063
+ detail: { userId, caretPosition: editing.caretPosition, color: existing.color || getUserColor(userId) }
1064
+ }));
1065
+ }
1066
+ } else {
1067
+ console.warn('[Manifest Presence] Element not found for syncing:', editing.elementId);
1068
+ }
1069
+ }
1070
+ } else if (!editing || !editing.elementId) {
1071
+ // Remove caret indicators when editing stops
1072
+ document.querySelectorAll(`[data-presence-caret-user="${userId}"]`).forEach(el => {
1073
+ el.removeAttribute('data-presence-caret-user');
1074
+ el.removeAttribute('data-presence-caret-position');
1075
+ el.removeAttribute('data-presence-caret-color');
1076
+ // Remove visual indicator
1077
+ const indicator = el.querySelector('.presence-caret');
1078
+ if (indicator) indicator.remove();
1079
+ });
1080
+ }
1081
+ } catch (e) {
1082
+ console.warn('[Manifest Presence] Failed to parse editing:', e);
1083
+ }
1084
+ }
1085
+
1086
+ cursors.set(userId, {
1087
+ ...existing,
1088
+ x: payload.x !== undefined ? payload.x : existing.x || 0,
1089
+ y: payload.y !== undefined ? payload.y : existing.y || 0,
1090
+ vx: payload.vx !== undefined ? payload.vx : (existing.vx || 0), // Velocity X
1091
+ vy: payload.vy !== undefined ? payload.vy : (existing.vy || 0), // Velocity Y
1092
+ name: payload.name || existing.name || 'Anonymous',
1093
+ color: payload.color || existing.color || getUserColor(userId),
1094
+ lastSeen: payload.lastSeen || Date.now(),
1095
+ lastUpdateTime: Date.now(), // Track when we received this update for interpolation
1096
+ focus: focus,
1097
+ selection: selection,
1098
+ editing: editing
1099
+ });
1100
+
1101
+ console.log(`[Presence Debug] Updated cursor for ${userId} after update event:`, {
1102
+ userId,
1103
+ focus,
1104
+ selection,
1105
+ editing,
1106
+ cursor: cursors.get(userId)
1107
+ });
1108
+
1109
+ // Apply visual indicators for update events
1110
+ applyVisualIndicators(userId, focus, selection, editing, existing.color || getUserColor(userId), containerElement);
1111
+
1112
+ if (onCursorUpdate) {
1113
+ onCursorUpdate(Array.from(cursors.values()));
1114
+ }
1115
+ } else {
1116
+ console.log(`[Presence Debug] Update event but channelId mismatch:`, {
1117
+ payloadChannelId: payload.channelId,
1118
+ expectedChannelId: channelId
1119
+ });
1120
+ }
1121
+ }
1122
+
1123
+ // Handle user leave event
1124
+ function handleUserLeave(event, userId, onUserLeave, onCursorUpdate, cursors) {
1125
+ // User left - clean up all indicators
1126
+ document.querySelectorAll(`[data-presence-focus-user="${userId}"]`).forEach(el => {
1127
+ el.classList.remove('presence-focused');
1128
+ el.removeAttribute('data-presence-focus-user');
1129
+ el.removeAttribute('data-presence-focus-color');
1130
+ });
1131
+ document.querySelectorAll(`[data-presence-caret-user="${userId}"]`).forEach(el => {
1132
+ el.removeAttribute('data-presence-caret-user');
1133
+ el.removeAttribute('data-presence-caret-position');
1134
+ el.removeAttribute('data-presence-caret-color');
1135
+ });
1136
+ document.querySelectorAll(`[data-presence-selection-user="${userId}"]`).forEach(el => {
1137
+ el.removeAttribute('data-presence-selection-user');
1138
+ el.removeAttribute('data-presence-selection-start');
1139
+ el.removeAttribute('data-presence-selection-end');
1140
+ el.removeAttribute('data-presence-selection-color');
1141
+ });
1142
+
1143
+ if (cursors.has(userId)) {
1144
+ cursors.delete(userId);
1145
+
1146
+ if (onUserLeave) {
1147
+ onUserLeave({ userId });
1148
+ }
1149
+
1150
+ if (onCursorUpdate) {
1151
+ onCursorUpdate(Array.from(cursors.values()));
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ // Setup realtime subscription
1157
+ async function setupPresenceRealtimeSubscription(
1158
+ services,
1159
+ presenceChannel,
1160
+ channelId,
1161
+ userInfo,
1162
+ cursors,
1163
+ containerElement,
1164
+ isLocalUserEditing,
1165
+ currentEditing,
1166
+ onUserJoin,
1167
+ onUserLeave,
1168
+ onCursorUpdate
1169
+ ) {
1170
+ // Initialize unsubscribe as a no-op function in case subscription fails
1171
+ let unsubscribe = () => {
1172
+ console.warn('[Manifest Presence] Unsubscribe called but subscription was not successful');
1173
+ };
1174
+
1175
+ console.log(`[Presence Debug] Subscribing to presence channel: ${presenceChannel} for channelId: ${channelId}`);
1176
+
1177
+ try {
1178
+ console.log(`[Presence Debug] About to subscribe to realtime channel: ${presenceChannel}`);
1179
+
1180
+ // Verify we're using the same realtime service instance
1181
+ console.log(`[Presence Debug] Realtime service:`, {
1182
+ type: typeof services.realtime,
1183
+ isRealtime: services.realtime instanceof window.Appwrite?.Realtime,
1184
+ subscribeType: typeof services.realtime?.subscribe,
1185
+ serviceObject: services.realtime
1186
+ });
1187
+
1188
+ // Call subscribe with inline callback
1189
+ // Note: subscribe() returns a Promise that resolves when subscription is active
1190
+ console.log(`[Presence Debug] About to call services.realtime.subscribe with channel: ${presenceChannel}`);
1191
+ const subscribeResult = services.realtime.subscribe(presenceChannel, (response) => {
1192
+ // Log immediately to verify callback is being called
1193
+ console.log(`[Presence Debug] ===== CALLBACK INVOKED =====`, {
1194
+ timestamp: new Date().toISOString(),
1195
+ hasResponse: !!response,
1196
+ responseType: typeof response,
1197
+ channel: presenceChannel
1198
+ });
1199
+ console.log(`[Presence Debug] ===== PRESENCE EVENT RECEIVED =====`);
1200
+ console.log(`[Presence Debug] Callback executed! This means realtime is working.`);
1201
+ console.log(`[Presence Debug] Presence event received:`, {
1202
+ hasResponse: !!response,
1203
+ responseType: typeof response,
1204
+ responseKeys: response ? Object.keys(response) : [],
1205
+ hasEvents: !!response?.events,
1206
+ events: response?.events,
1207
+ eventsType: typeof response?.events,
1208
+ eventsIsArray: Array.isArray(response?.events),
1209
+ payload: response?.payload,
1210
+ payloadKeys: response?.payload ? Object.keys(response?.payload) : [],
1211
+ channelId,
1212
+ fullResponse: response
1213
+ });
1214
+
1215
+ // Handle both array and single event formats (like in manifest.data.realtime.js)
1216
+ if (!response) {
1217
+ console.warn(`[Presence Debug] No response received`);
1218
+ return;
1219
+ }
1220
+
1221
+ // Check if events exist, handle both array and single event
1222
+ const events = response.events ?
1223
+ (Array.isArray(response.events) ? response.events : [response.events]) :
1224
+ [];
1225
+
1226
+ if (events.length === 0) {
1227
+ console.warn(`[Presence Debug] No events in response:`, response);
1228
+ return;
1229
+ }
1230
+
1231
+ events.forEach(event => {
1232
+ if (typeof event !== 'string') {
1233
+ console.warn(`[Presence Debug] Non-string event:`, event);
1234
+ return;
1235
+ }
1236
+
1237
+ const payload = response.payload || response;
1238
+ const userId = payload.userId || payload.$id;
1239
+
1240
+ console.log(`[Presence Debug] Processing presence event:`, {
1241
+ event,
1242
+ userId,
1243
+ localUserId: userInfo.id,
1244
+ payloadKeys: Object.keys(payload || {})
1245
+ });
1246
+
1247
+ // Ignore our own updates
1248
+ if (!userId || userId === userInfo.id) {
1249
+ console.log(`[Presence Debug] Ignoring own update (userId: ${userId})`);
1250
+ return;
1251
+ }
1252
+
1253
+ if (event.includes('create') || event.includes('rows.create')) {
1254
+ handleUserJoin(event, payload, channelId, userId, cursors, containerElement, onUserJoin, onCursorUpdate);
1255
+ } else if (event.includes('update') || event.includes('rows.update')) {
1256
+ handleUserUpdate(event, payload, channelId, userId, cursors, containerElement, isLocalUserEditing, currentEditing, onCursorUpdate);
1257
+ } else if (event.includes('delete') || event.includes('rows.delete')) {
1258
+ handleUserLeave(event, userId, onUserLeave, onCursorUpdate, cursors);
1259
+ }
1260
+ });
1261
+ });
1262
+
1263
+ console.log(`[Presence Debug] Subscribe call completed, result type:`, typeof subscribeResult);
1264
+ console.log(`[Presence Debug] Subscribe result:`, subscribeResult);
1265
+
1266
+ // Handle Promise resolution asynchronously (don't await - callback is already registered)
1267
+ // Match the pattern from working subscribeToTable which doesn't await
1268
+ if (subscribeResult && typeof subscribeResult.then === 'function') {
1269
+ // Callback is already registered synchronously, Promise is just for unsubscribe function
1270
+ subscribeResult.then((resolvedUnsubscribe) => {
1271
+ console.log(`[Presence Debug] Subscription Promise resolved, unsubscribe type:`, typeof resolvedUnsubscribe);
1272
+ console.log(`[Presence Debug] Resolved unsubscribe object:`, {
1273
+ type: typeof resolvedUnsubscribe,
1274
+ isFunction: typeof resolvedUnsubscribe === 'function',
1275
+ hasClose: resolvedUnsubscribe && typeof resolvedUnsubscribe.close === 'function',
1276
+ keys: resolvedUnsubscribe && typeof resolvedUnsubscribe === 'object' ? Object.keys(resolvedUnsubscribe) : [],
1277
+ fullObject: resolvedUnsubscribe
1278
+ });
1279
+ if (typeof resolvedUnsubscribe === 'function') {
1280
+ unsubscribe = resolvedUnsubscribe;
1281
+ } else if (resolvedUnsubscribe && typeof resolvedUnsubscribe.close === 'function') {
1282
+ unsubscribe = () => resolvedUnsubscribe.close();
1283
+ }
1284
+ }).catch((error) => {
1285
+ console.error(`[Presence Debug] Subscription Promise rejected:`, error);
1286
+ });
1287
+ // Set temporary unsubscribe that will be replaced when Promise resolves
1288
+ unsubscribe = () => {
1289
+ if (subscribeResult && typeof subscribeResult.then === 'function') {
1290
+ subscribeResult.then((resolved) => {
1291
+ if (resolved && typeof resolved.close === 'function') {
1292
+ resolved.close();
1293
+ } else if (typeof resolved === 'function') {
1294
+ resolved();
1295
+ }
1296
+ });
1297
+ }
1298
+ };
1299
+ } else if (typeof subscribeResult === 'function') {
1300
+ unsubscribe = subscribeResult;
1301
+ } else if (subscribeResult && typeof subscribeResult.close === 'function') {
1302
+ unsubscribe = () => subscribeResult.close();
1303
+ }
1304
+
1305
+ console.log(`[Presence Debug] Successfully subscribed to presence channel: ${presenceChannel}`);
1306
+ console.log(`[Presence Debug] Callback function registered. If events are not received, check:`);
1307
+ console.log(`[Presence Debug] 1. Appwrite table permissions (read permission required for realtime)`);
1308
+ console.log(`[Presence Debug] 2. Channel format: ${presenceChannel}`);
1309
+ console.log(`[Presence Debug] 3. Whether other users are actually updating presence data`);
1310
+ console.log(`[Presence Debug] 4. Network tab → WS filter → look for WebSocket frames with presence channel`);
1311
+ console.log(`[Presence Debug] 5. Appwrite Console → Database → presence table → ensure realtime is enabled`);
1312
+
1313
+ // Test: Log a message after a delay to verify subscription is still active
1314
+ setTimeout(() => {
1315
+ console.log(`[Presence Debug] Subscription test: 5 seconds have passed. If you see "===== PRESENCE EVENT RECEIVED =====" above, realtime is working.`);
1316
+ }, 5000);
1317
+
1318
+ return unsubscribe;
1319
+ } catch (error) {
1320
+ console.error('[Manifest Presence] Failed to subscribe to presence:', error);
1321
+ return unsubscribe; // Return no-op function
1322
+ }
1323
+ }
1324
+
1325
+
1326
+ /* Manifest Data Sources - Presence Visual Rendering */
1327
+
1328
+ // Render caret position indicator (optional visual rendering)
1329
+ function renderCaret(element, caretPosition, color) {
1330
+ if (!element) return;
1331
+
1332
+ // Remove existing caret for this element
1333
+ const existing = element.querySelector('.presence-caret');
1334
+ if (existing) existing.remove();
1335
+
1336
+ if (caretPosition === null || caretPosition === undefined) return;
1337
+
1338
+ // Create caret indicator
1339
+ const caret = document.createElement('div');
1340
+ caret.className = 'presence-caret';
1341
+ if (color) {
1342
+ caret.style.setProperty('--presence-caret-color', color);
1343
+ }
1344
+
1345
+ // Calculate caret position
1346
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
1347
+ // For input/textarea, measure text width
1348
+ const text = element.value.substring(0, caretPosition);
1349
+ const measure = document.createElement('span');
1350
+ measure.style.position = 'absolute';
1351
+ measure.style.visibility = 'hidden';
1352
+ measure.style.whiteSpace = 'pre';
1353
+ measure.style.font = window.getComputedStyle(element).font;
1354
+ measure.textContent = text;
1355
+ document.body.appendChild(measure);
1356
+
1357
+ const textWidth = measure.offsetWidth;
1358
+ document.body.removeChild(measure);
1359
+
1360
+ const rect = element.getBoundingClientRect();
1361
+ const paddingLeft = parseInt(window.getComputedStyle(element).paddingLeft) || 0;
1362
+ const borderLeft = parseInt(window.getComputedStyle(element).borderLeftWidth) || 0;
1363
+
1364
+ caret.style.left = (textWidth + paddingLeft + borderLeft) + 'px';
1365
+ caret.style.top = (paddingLeft || 2) + 'px';
1366
+ } else if (element.isContentEditable) {
1367
+ // For contenteditable, use Range API
1368
+ try {
1369
+ const range = document.createRange();
1370
+ const textNode = element.firstChild;
1371
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1372
+ const pos = Math.min(caretPosition, textNode.textContent.length);
1373
+ range.setStart(textNode, pos);
1374
+ range.setEnd(textNode, pos);
1375
+ const rect = range.getBoundingClientRect();
1376
+ const parentRect = element.getBoundingClientRect();
1377
+ caret.style.left = (rect.left - parentRect.left) + 'px';
1378
+ caret.style.top = (rect.top - parentRect.top) + 'px';
1379
+ }
1380
+ } catch (e) {
1381
+ console.warn('[Manifest Presence] Failed to position caret:', e);
1382
+ return;
1383
+ }
1384
+ }
1385
+
1386
+ element.style.position = 'relative';
1387
+ element.appendChild(caret);
1388
+ }
1389
+
1390
+ // Render text selection highlight (optional visual rendering)
1391
+ function renderSelection(element, start, end, color) {
1392
+ if (!element) return;
1393
+
1394
+ // Remove existing selection for this element
1395
+ const existing = element.querySelector('.presence-selection');
1396
+ if (existing) existing.remove();
1397
+
1398
+ if (start === null || end === null || start === end) return;
1399
+
1400
+ const selection = document.createElement('div');
1401
+ selection.className = 'presence-selection';
1402
+ if (color) {
1403
+ selection.style.setProperty('--presence-selection-color', color);
1404
+ }
1405
+
1406
+ // Calculate selection bounds
1407
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
1408
+ const text = element.value;
1409
+ const startText = text.substring(0, start);
1410
+ const selectedText = text.substring(start, end);
1411
+
1412
+ // Measure start position
1413
+ const measureStart = document.createElement('span');
1414
+ measureStart.style.position = 'absolute';
1415
+ measureStart.style.visibility = 'hidden';
1416
+ measureStart.style.whiteSpace = 'pre';
1417
+ measureStart.style.font = window.getComputedStyle(element).font;
1418
+ measureStart.textContent = startText;
1419
+ document.body.appendChild(measureStart);
1420
+
1421
+ // Measure selection width
1422
+ const measureSelected = document.createElement('span');
1423
+ measureSelected.style.position = 'absolute';
1424
+ measureSelected.style.visibility = 'hidden';
1425
+ measureSelected.style.whiteSpace = 'pre';
1426
+ measureSelected.style.font = window.getComputedStyle(element).font;
1427
+ measureSelected.textContent = selectedText;
1428
+ document.body.appendChild(measureSelected);
1429
+
1430
+ const startWidth = measureStart.offsetWidth;
1431
+ const selectedWidth = measureSelected.offsetWidth;
1432
+ document.body.removeChild(measureStart);
1433
+ document.body.removeChild(measureSelected);
1434
+
1435
+ const rect = element.getBoundingClientRect();
1436
+ const paddingLeft = parseInt(window.getComputedStyle(element).paddingLeft) || 0;
1437
+ const borderLeft = parseInt(window.getComputedStyle(element).borderLeftWidth) || 0;
1438
+ const lineHeight = parseInt(window.getComputedStyle(element).lineHeight) || 20;
1439
+
1440
+ selection.style.left = (startWidth + paddingLeft + borderLeft) + 'px';
1441
+ selection.style.top = (paddingLeft || 2) + 'px';
1442
+ selection.style.width = selectedWidth + 'px';
1443
+ selection.style.height = lineHeight + 'px';
1444
+ } else if (element.isContentEditable) {
1445
+ // For contenteditable, use Range API
1446
+ try {
1447
+ const textNode = element.firstChild;
1448
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
1449
+ const range = document.createRange();
1450
+ const textLength = textNode.textContent.length;
1451
+ const safeStart = Math.min(start, textLength);
1452
+ const safeEnd = Math.min(end, textLength);
1453
+ range.setStart(textNode, safeStart);
1454
+ range.setEnd(textNode, safeEnd);
1455
+ const rect = range.getBoundingClientRect();
1456
+ const parentRect = element.getBoundingClientRect();
1457
+ selection.style.left = (rect.left - parentRect.left) + 'px';
1458
+ selection.style.top = (rect.top - parentRect.top) + 'px';
1459
+ selection.style.width = rect.width + 'px';
1460
+ selection.style.height = rect.height + 'px';
1461
+ }
1462
+ } catch (e) {
1463
+ console.warn('[Manifest Presence] Failed to position selection:', e);
1464
+ return;
1465
+ }
1466
+ }
1467
+
1468
+ element.style.position = 'relative';
1469
+ element.appendChild(selection);
1470
+ }
1471
+
1472
+ // Initialize optional visual rendering (caret/selection indicators)
1473
+ function initializeVisualRendering() {
1474
+ // Listen for presence events
1475
+ document.addEventListener('presence:caret', (e) => {
1476
+ const { userId, caretPosition, color } = e.detail;
1477
+ const element = e.target;
1478
+ if (element && caretPosition !== null && caretPosition !== undefined) {
1479
+ renderCaret(element, caretPosition, color);
1480
+ }
1481
+ });
1482
+
1483
+ document.addEventListener('presence:selection', (e) => {
1484
+ const { userId, selection, color } = e.detail;
1485
+ const element = e.target;
1486
+ if (element && selection && selection.start !== undefined && selection.end !== undefined) {
1487
+ renderSelection(element, selection.start, selection.end, color);
1488
+ }
1489
+ });
1490
+
1491
+ // Update caret/selection when attributes change
1492
+ const observer = new MutationObserver((mutations) => {
1493
+ mutations.forEach((mutation) => {
1494
+ if (mutation.type === 'attributes') {
1495
+ const element = mutation.target;
1496
+ const caretUser = element.getAttribute('data-presence-caret-user');
1497
+ const caretPos = element.getAttribute('data-presence-caret-position');
1498
+ const caretColor = element.getAttribute('data-presence-caret-color');
1499
+
1500
+ if (caretUser && caretPos !== null) {
1501
+ renderCaret(element, parseInt(caretPos), caretColor || null);
1502
+ }
1503
+
1504
+ const selUser = element.getAttribute('data-presence-selection-user');
1505
+ const selStart = element.getAttribute('data-presence-selection-start');
1506
+ const selEnd = element.getAttribute('data-presence-selection-end');
1507
+ const selColor = element.getAttribute('data-presence-selection-color');
1508
+
1509
+ if (selUser && selStart !== null && selEnd !== null) {
1510
+ renderSelection(element, parseInt(selStart), parseInt(selEnd), selColor || null);
1511
+ }
1512
+ }
1513
+ });
1514
+ });
1515
+
1516
+ // Observe all editable elements
1517
+ function initObserver() {
1518
+ const editableElements = document.querySelectorAll('input, textarea, [contenteditable="true"]');
1519
+ editableElements.forEach(el => {
1520
+ observer.observe(el, {
1521
+ attributes: true,
1522
+ attributeFilter: [
1523
+ 'data-presence-caret-user', 'data-presence-caret-position', 'data-presence-caret-color',
1524
+ 'data-presence-selection-user', 'data-presence-selection-start', 'data-presence-selection-end', 'data-presence-selection-color'
1525
+ ]
1526
+ });
1527
+ });
1528
+ }
1529
+
1530
+ if (document.readyState === 'loading') {
1531
+ document.addEventListener('DOMContentLoaded', initObserver);
1532
+ } else {
1533
+ initObserver();
1534
+ }
1535
+ }
1536
+
1537
+
1538
+ /* Manifest Data Sources - Presence Main Subscription */
1539
+
1540
+ // Subscribe to presence channel for cursor tracking
1541
+ // Uses a database table to store cursor positions (Appwrite Realtime is read-only)
1542
+ async function subscribeToPresence(channelId, options = {}) {
1543
+ // Get configuration from manifest (if available)
1544
+ const manifestConfig = await getPresenceConfig();
1545
+
1546
+ // Read CSS variables for timing/threshold values (with fallbacks)
1547
+ const cssThrottle = getCSSVariableValue('--presence-throttle', 300);
1548
+ const cssCleanupInterval = getCSSVariableValue('--presence-cleanup-interval', 30000);
1549
+ const cssMinChangeThreshold = getCSSVariableValue('--presence-min-change-threshold', 5);
1550
+ const cssIdleThreshold = getCSSVariableValue('--presence-idle-threshold', 5000);
1551
+
1552
+ // Merge defaults: options > manifest > CSS variables > hardcoded defaults
1553
+ const finalOptions = {
1554
+ element: options.element ?? document.body,
1555
+ databaseId: options.databaseId ?? manifestConfig.appwriteDatabaseId ?? null,
1556
+ tableId: options.tableId ?? manifestConfig.appwriteTableId ?? 'presence',
1557
+ onCursorUpdate: options.onCursorUpdate ?? null,
1558
+ onUserJoin: options.onUserJoin ?? null,
1559
+ onUserLeave: options.onUserLeave ?? null,
1560
+ throttle: options.throttle ?? manifestConfig.throttle ?? cssThrottle,
1561
+ cleanupInterval: options.cleanupInterval ?? manifestConfig.cleanupInterval ?? cssCleanupInterval,
1562
+ minChangeThreshold: options.minChangeThreshold ?? manifestConfig.minChangeThreshold ?? cssMinChangeThreshold,
1563
+ idleThreshold: options.idleThreshold ?? manifestConfig.idleThreshold ?? cssIdleThreshold,
1564
+ enableVisualRendering: options.enableVisualRendering ?? manifestConfig.enableVisualRendering ?? true,
1565
+ includeVelocity: options.includeVelocity ?? manifestConfig.includeVelocity ?? false
1566
+ };
1567
+
1568
+ const {
1569
+ element,
1570
+ databaseId,
1571
+ tableId,
1572
+ onCursorUpdate,
1573
+ onUserJoin,
1574
+ onUserLeave,
1575
+ throttle,
1576
+ cleanupInterval,
1577
+ minChangeThreshold,
1578
+ idleThreshold,
1579
+ enableVisualRendering,
1580
+ includeVelocity
1581
+ } = finalOptions;
1582
+
1583
+ // Unsubscribe from existing subscription if any
1584
+ if (presenceSubscriptions.has(channelId)) {
1585
+ const existing = presenceSubscriptions.get(channelId);
1586
+ if (existing.unsubscribe) {
1587
+ existing.unsubscribe();
1588
+ }
1589
+ if (existing.updateInterval) {
1590
+ clearInterval(existing.updateInterval);
1591
+ }
1592
+ if (existing.cleanupInterval) {
1593
+ clearInterval(existing.cleanupInterval);
1594
+ }
1595
+ if (existing.cursorTracker && existing.cursorTracker.cleanup) {
1596
+ existing.cursorTracker.cleanup();
1597
+ }
1598
+ presenceSubscriptions.delete(channelId);
1599
+ }
1600
+
1601
+ const services = await getAppwriteServices();
1602
+ if (!services?.tablesDB || !services?.realtime) {
1603
+ console.warn('[Manifest Presence] Appwrite services not available');
1604
+ return null;
1605
+ }
1606
+
1607
+ if (!databaseId) {
1608
+ console.error('[Manifest Presence] databaseId is required');
1609
+ return null;
1610
+ }
1611
+
1612
+ const userInfo = getUserInfo();
1613
+ if (!userInfo) {
1614
+ console.warn('[Manifest Presence] User not authenticated');
1615
+ return null;
1616
+ }
1617
+
1618
+ // Track cursors for other users
1619
+ const cursors = new Map(); // Map<userId, { x, y, name, color, lastSeen }>
1620
+
1621
+ // Current presence state for this user (wrapped in objects for reference passing)
1622
+ const currentCursor = { x: 0, y: 0 };
1623
+ const currentFocus = { value: null }; // { elementId, elementType, tagName }
1624
+ const currentSelection = { value: null }; // { start, end, text }
1625
+ const currentEditing = { value: null }; // { elementId, value, caretPosition }
1626
+ const isLocalUserEditing = { value: false }; // Track if local user is actively editing
1627
+ const lastBroadcastTime = { value: 0 };
1628
+
1629
+ // Optimization: Track last sent position and state for change detection
1630
+ const lastSentCursor = { value: { x: null, y: null } };
1631
+ const lastSentFocus = { value: null };
1632
+ const lastSentSelection = { value: null };
1633
+ const lastSentEditing = { value: null };
1634
+ const lastActivityTime = { value: Date.now() }; // Track user activity for idle detection
1635
+ const lastVelocity = { vx: 0, vy: 0 }; // Velocity for smooth interpolation (future use)
1636
+ const lastPosition = { x: 0, y: 0, time: Date.now() }; // For velocity calculation
1637
+
1638
+ // Create updateCursorPosition wrapper that uses the state objects
1639
+ const updateCursorPositionWrapper = (forceImmediate = false) => {
1640
+ return updateCursorPosition(
1641
+ services,
1642
+ databaseId,
1643
+ tableId,
1644
+ channelId,
1645
+ userInfo,
1646
+ currentCursor,
1647
+ currentFocus,
1648
+ currentSelection,
1649
+ currentEditing,
1650
+ lastVelocity,
1651
+ includeVelocity,
1652
+ lastBroadcastTime,
1653
+ lastActivityTime,
1654
+ throttle,
1655
+ idleThreshold,
1656
+ minChangeThreshold,
1657
+ lastSentCursor,
1658
+ lastSentFocus,
1659
+ lastSentSelection,
1660
+ lastSentEditing,
1661
+ forceImmediate
1662
+ );
1663
+ };
1664
+
1665
+ // Create event handlers
1666
+ const eventHandlersState = {
1667
+ currentCursor,
1668
+ currentFocus,
1669
+ currentSelection,
1670
+ currentEditing,
1671
+ isLocalUserEditing,
1672
+ lastPosition,
1673
+ lastVelocity,
1674
+ lastActivityTime
1675
+ };
1676
+
1677
+ const eventHandlersCallbacks = {
1678
+ getElementId: (el) => getElementId(el, element),
1679
+ updateCursorPosition: updateCursorPositionWrapper
1680
+ };
1681
+
1682
+ const cleanupEventHandlers = createPresenceEventHandlers(element, eventHandlersState, eventHandlersCallbacks);
1683
+
1684
+ // Update cursor position periodically
1685
+ const updateInterval = setInterval(updateCursorPositionWrapper, throttle);
1686
+
1687
+ // Subscribe to real-time updates from the presence table
1688
+ const presenceChannel = `databases.${databaseId}.tables.${tableId}.rows`;
1689
+
1690
+ // Setup realtime subscription
1691
+ const unsubscribe = await setupPresenceRealtimeSubscription(
1692
+ services,
1693
+ presenceChannel,
1694
+ channelId,
1695
+ userInfo,
1696
+ cursors,
1697
+ element,
1698
+ isLocalUserEditing,
1699
+ currentEditing,
1700
+ onUserJoin,
1701
+ onUserLeave,
1702
+ onCursorUpdate
1703
+ );
1704
+
1705
+ // Load initial cursor positions from database
1706
+ await loadInitialCursors(
1707
+ services,
1708
+ databaseId,
1709
+ tableId,
1710
+ channelId,
1711
+ userInfo,
1712
+ cursors,
1713
+ includeVelocity,
1714
+ applyVisualIndicators,
1715
+ element,
1716
+ onCursorUpdate
1717
+ );
1718
+
1719
+ // Ensure initial cursors trigger callback even if load failed
1720
+ if (onCursorUpdate && cursors.size > 0) {
1721
+ console.log(`[Presence Debug] Triggering onCursorUpdate after load (fallback) with ${cursors.size} cursors`);
1722
+ onCursorUpdate(Array.from(cursors.values()));
1723
+ }
1724
+
1725
+ // Clean up stale cursors (users who haven't updated in a while)
1726
+ const cleanupIntervalId = setInterval(() => {
1727
+ const now = Date.now();
1728
+ let hasChanges = false;
1729
+
1730
+ cursors.forEach((cursor, userId) => {
1731
+ if (now - cursor.lastSeen > cleanupInterval) {
1732
+ cursors.delete(userId);
1733
+ hasChanges = true;
1734
+
1735
+ if (onUserLeave) {
1736
+ onUserLeave({ userId });
1737
+ }
1738
+ }
1739
+ });
1740
+
1741
+ if (hasChanges && onCursorUpdate) {
1742
+ onCursorUpdate(Array.from(cursors.values()));
1743
+ }
1744
+ }, cleanupInterval);
1745
+
1746
+ // Cleanup function
1747
+ const cleanup = () => {
1748
+ cleanupEventHandlers();
1749
+ clearInterval(updateInterval);
1750
+ clearInterval(cleanupIntervalId);
1751
+
1752
+ // Remove our presence from database on cleanup
1753
+ try {
1754
+ services.tablesDB.deleteRow({
1755
+ databaseId,
1756
+ tableId,
1757
+ rowId: userInfo.id
1758
+ });
1759
+ } catch (error) {
1760
+ console.warn('[Manifest Presence] Failed to remove presence on cleanup:', error);
1761
+ }
1762
+ };
1763
+
1764
+ // Store subscription info
1765
+ presenceSubscriptions.set(channelId, {
1766
+ unsubscribe,
1767
+ cursorTracker: { cleanup },
1768
+ cursors,
1769
+ element,
1770
+ userInfo,
1771
+ updateInterval,
1772
+ cleanupInterval: cleanupIntervalId
1773
+ });
1774
+
1775
+ return {
1776
+ unsubscribe: () => {
1777
+ cleanup();
1778
+ // Only call unsubscribe if it's actually a function
1779
+ if (typeof unsubscribe === 'function') {
1780
+ try {
1781
+ unsubscribe();
1782
+ } catch (error) {
1783
+ console.warn('[Manifest Presence] Error during unsubscribe:', error);
1784
+ }
1785
+ }
1786
+ presenceSubscriptions.delete(channelId);
1787
+ },
1788
+ cursors, // Expose cursors map for rendering
1789
+ userInfo
1790
+ };
1791
+ }
1792
+
1793
+ // Unsubscribe from presence channel
1794
+ function unsubscribeFromPresence(channelId) {
1795
+ if (presenceSubscriptions.has(channelId)) {
1796
+ const subscription = presenceSubscriptions.get(channelId);
1797
+ if (subscription.unsubscribe) {
1798
+ subscription.unsubscribe();
1799
+ }
1800
+ if (subscription.cursorTracker && subscription.cursorTracker.cleanup) {
1801
+ subscription.cursorTracker.cleanup();
1802
+ }
1803
+ presenceSubscriptions.delete(channelId);
1804
+ }
1805
+ }
1806
+
1807
+ // Unsubscribe from all presence channels
1808
+ function unsubscribeAllPresence() {
1809
+ presenceSubscriptions.forEach((subscription, channelId) => {
1810
+ if (subscription.unsubscribe) {
1811
+ subscription.unsubscribe();
1812
+ }
1813
+ if (subscription.cursorTracker && subscription.cursorTracker.cleanup) {
1814
+ subscription.cursorTracker.cleanup();
1815
+ }
1816
+ });
1817
+ presenceSubscriptions.clear();
1818
+ }
1819
+
1820
+ // Initialize visual rendering on plugin load (if DOM is ready)
1821
+ if (typeof document !== 'undefined') {
1822
+ if (document.readyState === 'loading') {
1823
+ document.addEventListener('DOMContentLoaded', () => {
1824
+ initializeVisualRendering();
1825
+ });
1826
+ } else {
1827
+ initializeVisualRendering();
1828
+ }
1829
+ }
1830
+
1831
+ // Export functions
1832
+ window.ManifestDataPresence = {
1833
+ subscribeToPresence,
1834
+ unsubscribeFromPresence,
1835
+ unsubscribeAllPresence,
1836
+ getUserColor,
1837
+ getUserInfo,
1838
+ interpolateCursorPosition, // Export for UI rendering
1839
+ lerp, // Export for UI rendering
1840
+ smoothInterpolate, // Export for UI rendering
1841
+ renderCaret, // Export for manual caret rendering
1842
+ renderSelection, // Export for manual selection rendering
1843
+ initializeVisualRendering, // Export for manual initialization
1844
+ presenceSubscriptions // Expose for debugging
1845
+ };