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.
- package/LICENSE +11 -0
- package/README.md +58 -0
- package/dist/manifest.accordion.css +81 -0
- package/dist/manifest.appwrite.auth.js +6247 -0
- package/dist/manifest.appwrite.data.js +1586 -0
- package/dist/manifest.appwrite.presence.js +1845 -0
- package/dist/manifest.avatar.css +113 -0
- package/dist/manifest.button.css +79 -0
- package/dist/manifest.checkbox.css +58 -0
- package/dist/manifest.code.css +453 -0
- package/dist/manifest.code.js +958 -0
- package/dist/manifest.code.min.css +1 -0
- package/dist/manifest.components.js +737 -0
- package/dist/manifest.css +3124 -0
- package/dist/manifest.data.js +11413 -0
- package/dist/manifest.dialog.css +130 -0
- package/dist/manifest.divider.css +77 -0
- package/dist/manifest.dropdown.css +278 -0
- package/dist/manifest.dropdowns.js +378 -0
- package/dist/manifest.form.css +169 -0
- package/dist/manifest.icons.js +161 -0
- package/dist/manifest.input.css +129 -0
- package/dist/manifest.js +302 -0
- package/dist/manifest.localization.js +571 -0
- package/dist/manifest.markdown.js +738 -0
- package/dist/manifest.min.css +1 -0
- package/dist/manifest.radio.css +38 -0
- package/dist/manifest.resize.css +233 -0
- package/dist/manifest.resize.js +442 -0
- package/dist/manifest.router.js +1207 -0
- package/dist/manifest.sidebar.css +102 -0
- package/dist/manifest.slides.css +80 -0
- package/dist/manifest.slides.js +173 -0
- package/dist/manifest.switch.css +44 -0
- package/dist/manifest.table.css +74 -0
- package/dist/manifest.tabs.js +273 -0
- package/dist/manifest.tailwind.js +578 -0
- package/dist/manifest.theme.css +119 -0
- package/dist/manifest.themes.js +109 -0
- package/dist/manifest.toast.css +92 -0
- package/dist/manifest.toasts.js +285 -0
- package/dist/manifest.tooltip.css +156 -0
- package/dist/manifest.tooltips.js +331 -0
- package/dist/manifest.typography.css +341 -0
- package/dist/manifest.utilities.css +399 -0
- package/dist/manifest.utilities.js +3197 -0
- 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
|
+
};
|