clay-server 2.27.0-beta.9 → 2.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -0
  2. package/lib/daemon-projects.js +164 -0
  3. package/lib/daemon.js +13 -126
  4. package/lib/mates-identity.js +132 -0
  5. package/lib/mates-knowledge.js +113 -0
  6. package/lib/mates-prompts.js +398 -0
  7. package/lib/mates.js +40 -599
  8. package/lib/project-connection.js +2 -0
  9. package/lib/project-http.js +4 -2
  10. package/lib/project-loop.js +110 -48
  11. package/lib/project-mate-interaction.js +4 -0
  12. package/lib/project-notifications.js +210 -0
  13. package/lib/project-sessions.js +5 -2
  14. package/lib/project-user-message.js +2 -1
  15. package/lib/project.js +26 -2
  16. package/lib/public/app.js +1193 -8517
  17. package/lib/public/css/command-palette.css +14 -0
  18. package/lib/public/css/loop.css +301 -0
  19. package/lib/public/css/notifications-center.css +190 -0
  20. package/lib/public/css/rewind.css +6 -0
  21. package/lib/public/index.html +89 -35
  22. package/lib/public/modules/app-connection.js +160 -0
  23. package/lib/public/modules/app-cursors.js +473 -0
  24. package/lib/public/modules/app-debate-ui.js +389 -0
  25. package/lib/public/modules/app-dm.js +627 -0
  26. package/lib/public/modules/app-favicon.js +212 -0
  27. package/lib/public/modules/app-header.js +229 -0
  28. package/lib/public/modules/app-home-hub.js +600 -0
  29. package/lib/public/modules/app-loop-ui.js +589 -0
  30. package/lib/public/modules/app-loop-wizard.js +439 -0
  31. package/lib/public/modules/app-messages.js +1560 -0
  32. package/lib/public/modules/app-misc.js +299 -0
  33. package/lib/public/modules/app-notifications.js +372 -0
  34. package/lib/public/modules/app-panels.js +888 -0
  35. package/lib/public/modules/app-projects.js +798 -0
  36. package/lib/public/modules/app-rate-limit.js +451 -0
  37. package/lib/public/modules/app-rendering.js +597 -0
  38. package/lib/public/modules/app-skills-install.js +234 -0
  39. package/lib/public/modules/command-palette.js +27 -4
  40. package/lib/public/modules/input.js +31 -20
  41. package/lib/public/modules/scheduler-config.js +1532 -0
  42. package/lib/public/modules/scheduler-history.js +79 -0
  43. package/lib/public/modules/scheduler.js +33 -1554
  44. package/lib/public/modules/session-search.js +13 -1
  45. package/lib/public/modules/sidebar-mates.js +812 -0
  46. package/lib/public/modules/sidebar-mobile.js +1269 -0
  47. package/lib/public/modules/sidebar-projects.js +1449 -0
  48. package/lib/public/modules/sidebar-sessions.js +986 -0
  49. package/lib/public/modules/sidebar.js +232 -4591
  50. package/lib/public/modules/store.js +27 -0
  51. package/lib/public/modules/ws-ref.js +7 -0
  52. package/lib/public/style.css +1 -0
  53. package/lib/sdk-bridge.js +96 -717
  54. package/lib/sdk-message-processor.js +587 -0
  55. package/lib/sdk-message-queue.js +42 -0
  56. package/lib/sdk-skill-discovery.js +131 -0
  57. package/lib/server-admin.js +712 -0
  58. package/lib/server-auth.js +737 -0
  59. package/lib/server-dm.js +221 -0
  60. package/lib/server-mates.js +281 -0
  61. package/lib/server-palette.js +110 -0
  62. package/lib/server-settings.js +479 -0
  63. package/lib/server-skills.js +280 -0
  64. package/lib/server.js +246 -2755
  65. package/lib/sessions.js +11 -4
  66. package/lib/users-auth.js +146 -0
  67. package/lib/users-permissions.js +118 -0
  68. package/lib/users-preferences.js +210 -0
  69. package/lib/users.js +48 -398
  70. package/lib/ws-schema.js +498 -0
  71. package/package.json +1 -1
@@ -0,0 +1,473 @@
1
+ // app-cursors.js - Remote cursor presence, text selection sharing
2
+ // Extracted from app.js (PR-27)
3
+
4
+ import { avatarUrl } from './avatar.js';
5
+
6
+ var _ctx = null;
7
+
8
+ // --- Module-owned state ---
9
+ var cursorSharingEnabled = localStorage.getItem("cursorSharing") !== "off";
10
+ var remoteCursors = {}; // userId -> { el, indicator, timer, lastY, active }
11
+ var cursorThrottleTimer = null;
12
+ var CURSOR_THROTTLE_MS = 30;
13
+ var CURSOR_HIDE_TIMEOUT = 5000;
14
+
15
+ var cursorColors = [
16
+ "#F24822", "#FF7262", "#A259FF", "#1ABCFE",
17
+ "#0ACF83", "#FF6D00", "#E84393", "#6C5CE7",
18
+ "#00B894", "#FDCB6E", "#E17055", "#74B9FF",
19
+ ];
20
+ var userColorMap = {};
21
+ var nextColorIdx = 0;
22
+
23
+ var remoteSelections = {}; // userId -> { els: [], timer }
24
+ var selectionThrottleTimer = null;
25
+ var lastSelectionKey = "";
26
+
27
+ // --- Internal helpers ---
28
+
29
+ function getCursorColor(userId) {
30
+ if (!userColorMap[userId]) {
31
+ userColorMap[userId] = cursorColors[nextColorIdx % cursorColors.length];
32
+ nextColorIdx++;
33
+ }
34
+ return userColorMap[userId];
35
+ }
36
+
37
+ function createCursorElement(userId, displayName, color, avatarStyle, avatarSeed, avatarCustom) {
38
+ var wrapper = document.createElement("div");
39
+ wrapper.className = "remote-cursor";
40
+ wrapper.dataset.userId = userId;
41
+ wrapper.style.position = "absolute";
42
+ wrapper.style.zIndex = "9999";
43
+ wrapper.style.pointerEvents = "none";
44
+ wrapper.style.display = "none";
45
+ wrapper.style.transition = "left 30ms linear, top 30ms linear";
46
+ wrapper.style.willChange = "left, top";
47
+
48
+ // SVG cursor arrow
49
+ var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
50
+ svg.setAttribute("width", "16");
51
+ svg.setAttribute("height", "20");
52
+ svg.setAttribute("viewBox", "0 0 16 20");
53
+ svg.style.display = "block";
54
+ svg.style.filter = "drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
55
+ var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
56
+ path.setAttribute("d", "M0 0 L0 16 L4.5 12 L8 19 L10.5 18 L7 11 L13 11 Z");
57
+ path.setAttribute("fill", color);
58
+ path.setAttribute("stroke", "#fff");
59
+ path.setAttribute("stroke-width", "1");
60
+ svg.appendChild(path);
61
+ wrapper.appendChild(svg);
62
+
63
+ // Tag: avatar + name label together
64
+ var tag = document.createElement("div");
65
+ tag.className = "remote-cursor-tag";
66
+ tag.style.cssText = "position:absolute;left:14px;top:14px;display:flex;align-items:center;" +
67
+ "gap:3px;background:" + color + ";padding:1px 6px 1px 2px;border-radius:10px;" +
68
+ "pointer-events:none;white-space:nowrap;";
69
+
70
+ // Avatar
71
+ var avatarImg = document.createElement("img");
72
+ avatarImg.className = "remote-cursor-avatar";
73
+ avatarImg.src = avatarCustom ? avatarCustom : avatarUrl(avatarStyle || "thumbs", avatarSeed || userId, 16);
74
+ avatarImg.style.cssText = "width:14px;height:14px;border-radius:50%;background:#fff;flex-shrink:0;";
75
+ tag.appendChild(avatarImg);
76
+
77
+ // Name label
78
+ var label = document.createElement("span");
79
+ label.className = "remote-cursor-label";
80
+ label.textContent = displayName;
81
+ label.style.cssText = "color:#fff;font-size:11px;font-weight:500;line-height:16px;font-family:inherit;";
82
+ tag.appendChild(label);
83
+
84
+ wrapper.appendChild(tag);
85
+
86
+ return wrapper;
87
+ }
88
+
89
+ // Compute cumulative character offset within a container element
90
+ function getCharOffset(container, targetNode, targetOffset) {
91
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
92
+ var offset = 0;
93
+ var node;
94
+ while ((node = walker.nextNode())) {
95
+ if (node === targetNode) {
96
+ return offset + targetOffset;
97
+ }
98
+ offset += node.textContent.length;
99
+ }
100
+ return offset;
101
+ }
102
+
103
+ // Find text node + local offset for a given cumulative character offset
104
+ function getNodeAtCharOffset(container, charOffset) {
105
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
106
+ var consumed = 0;
107
+ var node;
108
+ var lastNode = null;
109
+ while ((node = walker.nextNode())) {
110
+ lastNode = node;
111
+ var len = node.textContent.length;
112
+ if (consumed + len >= charOffset) {
113
+ return { node: node, offset: Math.min(charOffset - consumed, len) };
114
+ }
115
+ consumed += len;
116
+ }
117
+ if (lastNode) {
118
+ return { node: lastNode, offset: lastNode.textContent.length };
119
+ }
120
+ return null;
121
+ }
122
+
123
+ // Find parent [data-turn] element from a DOM node
124
+ function findParentTurn(node) {
125
+ var messagesEl = _ctx.messagesEl;
126
+ var el = node.nodeType === 3 ? node.parentElement : node;
127
+ while (el && el !== messagesEl) {
128
+ if (el.dataset && el.dataset.turn != null) return el;
129
+ el = el.parentElement;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function clearRemoteSelection(userId) {
135
+ var sel = remoteSelections[userId];
136
+ if (!sel) return;
137
+ for (var i = 0; i < sel.els.length; i++) {
138
+ if (sel.els[i].parentNode) sel.els[i].parentNode.removeChild(sel.els[i]);
139
+ }
140
+ sel.els = [];
141
+ }
142
+
143
+ function createOffscreenIndicator(userId, displayName, color) {
144
+ var btn = document.createElement("button");
145
+ btn.className = "remote-cursor-offscreen";
146
+ btn.dataset.userId = userId;
147
+ btn.style.cssText =
148
+ "position:absolute;left:50%;transform:translateX(-50%);" +
149
+ "z-index:10000;display:none;cursor:pointer;border:none;outline:none;" +
150
+ "background:" + color + ";color:#fff;font-size:11px;font-weight:500;" +
151
+ "padding:3px 10px 3px 8px;border-radius:12px;white-space:nowrap;" +
152
+ "font-family:inherit;line-height:16px;opacity:0.9;" +
153
+ "box-shadow:0 2px 8px rgba(0,0,0,0.2);pointer-events:auto;" +
154
+ "transition:opacity 0.15s;";
155
+ btn.addEventListener("mouseenter", function () { btn.style.opacity = "1"; });
156
+ btn.addEventListener("mouseleave", function () { btn.style.opacity = "0.9"; });
157
+ return btn;
158
+ }
159
+
160
+ function updateCursorVisibility(entry) {
161
+ var messagesEl = _ctx.messagesEl;
162
+ var visibleTop = messagesEl.scrollTop;
163
+ var visibleBottom = visibleTop + messagesEl.clientHeight;
164
+ var y = entry.lastY || 0;
165
+
166
+ if (y < visibleTop) {
167
+ entry.indicator.style.top = (visibleTop + 6) + "px";
168
+ entry.indicator.style.display = "";
169
+ } else if (y > visibleBottom) {
170
+ entry.indicator.style.top = (visibleBottom - 28) + "px";
171
+ entry.indicator.style.display = "";
172
+ } else {
173
+ entry.indicator.style.display = "none";
174
+ }
175
+ }
176
+
177
+ // Find the closest [data-turn] element to a given clientY
178
+ function findClosestTurn(clientY) {
179
+ var messagesEl = _ctx.messagesEl;
180
+ var turns = messagesEl.querySelectorAll("[data-turn]");
181
+ if (!turns.length) return null;
182
+ // First: exact hit
183
+ for (var i = 0; i < turns.length; i++) {
184
+ var r = turns[i].getBoundingClientRect();
185
+ if (clientY >= r.top && clientY <= r.bottom) return turns[i];
186
+ }
187
+ // Second: closest by distance
188
+ var closest = null;
189
+ var closestDist = Infinity;
190
+ for (var j = 0; j < turns.length; j++) {
191
+ var rect = turns[j].getBoundingClientRect();
192
+ var mid = (rect.top + rect.bottom) / 2;
193
+ var dist = Math.abs(clientY - mid);
194
+ if (dist < closestDist) { closestDist = dist; closest = turns[j]; }
195
+ }
196
+ return closest;
197
+ }
198
+
199
+ // Cursor sharing toggle button in user island (multi-user only)
200
+ export function initCursorToggle() {
201
+ if (!_ctx.isMultiUserMode) return;
202
+ var actionsEl = document.querySelector(".user-island-actions");
203
+ if (!actionsEl) return;
204
+ if (document.getElementById("cursor-share-toggle")) return;
205
+
206
+ var btn = document.createElement("button");
207
+ btn.id = "cursor-share-toggle";
208
+ btn.className = "cursor-share-btn";
209
+ btn.innerHTML = '<i data-lucide="mouse-pointer-2"></i>';
210
+ var settingsBtn = document.getElementById("user-settings-btn");
211
+ if (settingsBtn) {
212
+ actionsEl.insertBefore(btn, settingsBtn);
213
+ } else {
214
+ actionsEl.appendChild(btn);
215
+ }
216
+
217
+ function updateToggleStyle() {
218
+ if (cursorSharingEnabled) {
219
+ btn.classList.remove("off");
220
+ btn.classList.add("on");
221
+ _ctx.registerTooltip(btn, "Cursor sharing on");
222
+ } else {
223
+ btn.classList.remove("on");
224
+ btn.classList.add("off");
225
+ _ctx.registerTooltip(btn, "Cursor sharing off");
226
+ }
227
+ }
228
+
229
+ updateToggleStyle();
230
+ lucide.createIcons({ nodes: [btn] });
231
+
232
+ btn.addEventListener("click", function () {
233
+ cursorSharingEnabled = !cursorSharingEnabled;
234
+ localStorage.setItem("cursorSharing", cursorSharingEnabled ? "on" : "off");
235
+ updateToggleStyle();
236
+ var ws = _ctx.ws;
237
+ if (!cursorSharingEnabled && ws && ws.readyState === 1) {
238
+ ws.send(JSON.stringify({ type: "cursor_leave" }));
239
+ ws.send(JSON.stringify({ type: "text_select", ranges: [] }));
240
+ }
241
+ });
242
+ }
243
+
244
+ // --- Exported functions ---
245
+
246
+ export function handleRemoteSelection(msg) {
247
+ var messagesEl = _ctx.messagesEl;
248
+ var userId = msg.userId;
249
+ var color = getCursorColor(userId);
250
+
251
+ if (!remoteSelections[userId]) {
252
+ remoteSelections[userId] = { els: [], timer: null };
253
+ }
254
+
255
+ // Clear previous highlight
256
+ clearRemoteSelection(userId);
257
+
258
+ // If selection cleared, just remove
259
+ if (!msg.ranges || msg.ranges.length === 0) return;
260
+
261
+ var containerRect = messagesEl.getBoundingClientRect();
262
+
263
+ for (var r = 0; r < msg.ranges.length; r++) {
264
+ var sel = msg.ranges[r];
265
+ var startTurnEl = messagesEl.querySelector('[data-turn="' + sel.startTurn + '"]');
266
+ var endTurnEl = messagesEl.querySelector('[data-turn="' + sel.endTurn + '"]');
267
+ if (!startTurnEl || !endTurnEl) continue;
268
+
269
+ var startResult = getNodeAtCharOffset(startTurnEl, sel.startCh);
270
+ var endResult = getNodeAtCharOffset(endTurnEl, sel.endCh);
271
+ if (!startResult || !endResult) continue;
272
+
273
+ try {
274
+ var range = document.createRange();
275
+ range.setStart(startResult.node, startResult.offset);
276
+ range.setEnd(endResult.node, endResult.offset);
277
+ var rects = range.getClientRects();
278
+
279
+ for (var i = 0; i < rects.length; i++) {
280
+ var rect = rects[i];
281
+ if (rect.width === 0 && rect.height === 0) continue;
282
+ var highlight = document.createElement("div");
283
+ highlight.className = "remote-selection";
284
+ highlight.dataset.userId = userId;
285
+ highlight.style.cssText =
286
+ "position:absolute;pointer-events:none;z-index:9998;" +
287
+ "background:" + color + ";" +
288
+ "opacity:0.2;" +
289
+ "border-radius:2px;" +
290
+ "left:" + (rect.left - containerRect.left + messagesEl.scrollLeft) + "px;" +
291
+ "top:" + (rect.top - containerRect.top + messagesEl.scrollTop) + "px;" +
292
+ "width:" + rect.width + "px;" +
293
+ "height:" + rect.height + "px;";
294
+ messagesEl.appendChild(highlight);
295
+ remoteSelections[userId].els.push(highlight);
296
+ }
297
+ } catch (e) {}
298
+ }
299
+
300
+ // Auto-hide after timeout
301
+ if (remoteSelections[userId].timer) clearTimeout(remoteSelections[userId].timer);
302
+ remoteSelections[userId].timer = setTimeout(function () {
303
+ clearRemoteSelection(userId);
304
+ }, 10000);
305
+ }
306
+
307
+ export function handleRemoteCursorMove(msg) {
308
+ var messagesEl = _ctx.messagesEl;
309
+ var userId = msg.userId;
310
+
311
+ var entry = remoteCursors[userId];
312
+ if (!entry) {
313
+ var color = getCursorColor(userId);
314
+ var el = createCursorElement(userId, msg.displayName, color, msg.avatarStyle, msg.avatarSeed, msg.avatarCustom);
315
+ messagesEl.appendChild(el);
316
+ var indicator = createOffscreenIndicator(userId, msg.displayName, color);
317
+ messagesEl.appendChild(indicator);
318
+ entry = { el: el, indicator: indicator, timer: null, lastY: 0, active: false };
319
+ remoteCursors[userId] = entry;
320
+
321
+ indicator.addEventListener("click", function () {
322
+ messagesEl.scrollTo({ top: entry.lastY - messagesEl.clientHeight / 2, behavior: "smooth" });
323
+ });
324
+ }
325
+
326
+ // Find the same turn element on this screen
327
+ var anchorEl = null;
328
+ if (msg.turn != null) {
329
+ anchorEl = messagesEl.querySelector('[data-turn="' + msg.turn + '"]');
330
+ }
331
+
332
+ if (anchorEl && msg.rx != null && msg.ry != null) {
333
+ var x = anchorEl.offsetLeft + msg.rx * anchorEl.offsetWidth;
334
+ var y = anchorEl.offsetTop + msg.ry * anchorEl.offsetHeight;
335
+ entry.lastY = y;
336
+ entry.active = true;
337
+
338
+ // Update indicator label (direction set by updateCursorVisibility)
339
+ entry.indicator.textContent = (y < messagesEl.scrollTop ? "▲ " : "▼ ") + (msg.displayName || userId);
340
+
341
+ entry.el.style.left = x + "px";
342
+ entry.el.style.top = y + "px";
343
+ entry.el.style.display = "";
344
+
345
+ updateCursorVisibility(entry);
346
+ }
347
+
348
+ // Reset hide timer
349
+ if (entry.timer) clearTimeout(entry.timer);
350
+ entry.timer = setTimeout(function () {
351
+ entry.el.style.display = "none";
352
+ entry.indicator.style.display = "none";
353
+ entry.active = false;
354
+ }, CURSOR_HIDE_TIMEOUT);
355
+ }
356
+
357
+ export function handleRemoteCursorLeave(msg) {
358
+ var entry = remoteCursors[msg.userId];
359
+ if (entry) {
360
+ entry.el.style.display = "none";
361
+ entry.indicator.style.display = "none";
362
+ entry.active = false;
363
+ if (entry.timer) clearTimeout(entry.timer);
364
+ }
365
+ }
366
+
367
+ export function clearRemoteCursors() {
368
+ for (var uid in remoteCursors) {
369
+ var entry = remoteCursors[uid];
370
+ if (entry.timer) clearTimeout(entry.timer);
371
+ if (entry.el.parentNode) entry.el.parentNode.removeChild(entry.el);
372
+ if (entry.indicator && entry.indicator.parentNode) entry.indicator.parentNode.removeChild(entry.indicator);
373
+ }
374
+ remoteCursors = {};
375
+ for (var uid2 in remoteSelections) {
376
+ clearRemoteSelection(uid2);
377
+ if (remoteSelections[uid2].timer) clearTimeout(remoteSelections[uid2].timer);
378
+ }
379
+ remoteSelections = {};
380
+ }
381
+
382
+ export function initCursors(ctx) {
383
+ _ctx = ctx;
384
+ var messagesEl = _ctx.messagesEl;
385
+
386
+ initCursorToggle();
387
+
388
+ // Track local cursor and send to server
389
+ messagesEl.addEventListener("mousemove", function (e) {
390
+ if (!cursorSharingEnabled) return;
391
+ var ws = _ctx.ws;
392
+ if (!ws || ws.readyState !== 1) return;
393
+ if (cursorThrottleTimer) return;
394
+ cursorThrottleTimer = setTimeout(function () { cursorThrottleTimer = null; }, CURSOR_THROTTLE_MS);
395
+
396
+ // Find which turn element the cursor is over
397
+ var turnEl = findClosestTurn(e.clientY);
398
+ if (!turnEl) return;
399
+
400
+ // Calculate ratio within the turn element
401
+ var turnRect = turnEl.getBoundingClientRect();
402
+ var rx = turnRect.width > 0 ? (e.clientX - turnRect.left) / turnRect.width : 0;
403
+ var ry = turnRect.height > 0 ? (e.clientY - turnRect.top) / turnRect.height : 0;
404
+
405
+ ws.send(JSON.stringify({
406
+ type: "cursor_move",
407
+ turn: parseInt(turnEl.dataset.turn, 10),
408
+ rx: Math.max(0, Math.min(1, rx)),
409
+ ry: Math.max(0, Math.min(1, ry))
410
+ }));
411
+ });
412
+
413
+ messagesEl.addEventListener("mouseleave", function () {
414
+ if (!cursorSharingEnabled) return;
415
+ var ws = _ctx.ws;
416
+ if (!ws || ws.readyState !== 1) return;
417
+ ws.send(JSON.stringify({ type: "cursor_leave" }));
418
+ });
419
+
420
+ // Update offscreen indicators on scroll
421
+ messagesEl.addEventListener("scroll", function () {
422
+ for (var uid in remoteCursors) {
423
+ var entry = remoteCursors[uid];
424
+ if (!entry.active) continue;
425
+ updateCursorVisibility(entry);
426
+ }
427
+ });
428
+
429
+ // Track local text selection and send to server
430
+ document.addEventListener("selectionchange", function () {
431
+ if (!cursorSharingEnabled) return;
432
+ var ws = _ctx.ws;
433
+ if (!ws || ws.readyState !== 1) return;
434
+ if (selectionThrottleTimer) return;
435
+ selectionThrottleTimer = setTimeout(function () { selectionThrottleTimer = null; }, 100);
436
+
437
+ var sel = window.getSelection();
438
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
439
+ // Selection cleared
440
+ if (lastSelectionKey !== "") {
441
+ lastSelectionKey = "";
442
+ ws.send(JSON.stringify({ type: "text_select", ranges: [] }));
443
+ }
444
+ return;
445
+ }
446
+
447
+ var ranges = [];
448
+ for (var i = 0; i < sel.rangeCount; i++) {
449
+ var range = sel.getRangeAt(i);
450
+ var startTurn = findParentTurn(range.startContainer);
451
+ var endTurn = findParentTurn(range.endContainer);
452
+ if (!startTurn || !endTurn) continue;
453
+ // Both must be inside messagesEl
454
+ if (!messagesEl.contains(startTurn)) continue;
455
+
456
+ var startCh = getCharOffset(startTurn, range.startContainer, range.startOffset);
457
+ var endCh = getCharOffset(endTurn, range.endContainer, range.endOffset);
458
+
459
+ ranges.push({
460
+ startTurn: parseInt(startTurn.dataset.turn, 10),
461
+ startCh: startCh,
462
+ endTurn: parseInt(endTurn.dataset.turn, 10),
463
+ endCh: endCh
464
+ });
465
+ }
466
+
467
+ var key = JSON.stringify(ranges);
468
+ if (key === lastSelectionKey) return;
469
+ lastSelectionKey = key;
470
+
471
+ ws.send(JSON.stringify({ type: "text_select", ranges: ranges }));
472
+ });
473
+ }