clay-server 2.11.0 → 2.12.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.
- package/bin/cli.js +16 -4
- package/lib/daemon.js +167 -0
- package/lib/project.js +83 -1
- package/lib/public/app.js +567 -20
- package/lib/public/css/icon-strip.css +308 -5
- package/lib/public/css/menus.css +1 -16
- package/lib/public/css/messages.css +7 -0
- package/lib/public/css/session-search.css +150 -0
- package/lib/public/css/sidebar.css +30 -0
- package/lib/public/css/tooltip.css +20 -0
- package/lib/public/index.html +2 -1
- package/lib/public/modules/notifications.js +1 -58
- package/lib/public/modules/session-search.js +440 -0
- package/lib/public/modules/sidebar.js +576 -148
- package/lib/public/modules/tooltip.js +123 -0
- package/lib/public/style.css +2 -0
- package/lib/server.js +46 -3
- package/lib/sessions.js +37 -0
- package/lib/worktree.js +134 -0
- package/package.json +1 -1
package/lib/public/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { showToast, copyToClipboard, escapeHtml } from './modules/utils.js';
|
|
2
2
|
import { refreshIcons, iconHtml, randomThinkingVerb } from './modules/icons.js';
|
|
3
3
|
import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, closeMermaidModal, parseEmojis } from './modules/markdown.js';
|
|
4
|
-
import { initSidebar, renderSessionList, handleSearchResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories, renderUserStrip, setCurrentDmUser, updateDmBadge, updateSessionBadge, updateProjectBadge, closeDmUserPicker, spawnDustParticles } from './modules/sidebar.js';
|
|
4
|
+
import { initSidebar, renderSessionList, handleSearchResults, handleSearchContentResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, onHistoryPrepended, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories, renderUserStrip, setCurrentDmUser, updateDmBadge, updateSessionBadge, updateProjectBadge, closeDmUserPicker, spawnDustParticles } from './modules/sidebar.js';
|
|
5
5
|
import { initRewind, setRewindMode, showRewindModal, clearPendingRewindUuid, addRewindButton } from './modules/rewind.js';
|
|
6
6
|
import { initNotifications, showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './modules/notifications.js';
|
|
7
7
|
import { initInput, clearPendingImages, handleInputSync, autoResize, builtinCommands, sendMessage } from './modules/input.js';
|
|
@@ -20,6 +20,8 @@ import { initPlaybook, openPlaybook, getPlaybooks, getPlaybookForTip, isComplete
|
|
|
20
20
|
import { initSTT } from './modules/stt.js';
|
|
21
21
|
import { initProfile } from './modules/profile.js';
|
|
22
22
|
import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
23
|
+
import { initSessionSearch, toggleSearch, closeSearch, isSearchOpen, handleFindInSessionResults, onHistoryPrepended as onSessionSearchHistoryPrepended } from './modules/session-search.js';
|
|
24
|
+
import { initTooltips, registerTooltip } from './modules/tooltip.js';
|
|
23
25
|
|
|
24
26
|
// --- Base path for multi-project routing ---
|
|
25
27
|
var slugMatch = location.pathname.match(/^\/p\/([a-z0-9_-]+)/);
|
|
@@ -32,7 +34,8 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
32
34
|
var inputEl = $("input");
|
|
33
35
|
var sendBtn = $("send-btn");
|
|
34
36
|
function getStatusDot() {
|
|
35
|
-
return document.querySelector("#icon-strip-projects .icon-strip-item.active .icon-strip-status")
|
|
37
|
+
return document.querySelector("#icon-strip-projects .icon-strip-item.active .icon-strip-status") ||
|
|
38
|
+
document.querySelector("#icon-strip-projects .icon-strip-wt-item.active .icon-strip-status");
|
|
36
39
|
}
|
|
37
40
|
var headerTitleEl = $("header-title");
|
|
38
41
|
var headerRenameBtn = $("header-rename-btn");
|
|
@@ -902,7 +905,18 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
902
905
|
function renderProjectList() {
|
|
903
906
|
// Render icon strip projects
|
|
904
907
|
var iconStripProjects = cachedProjects.map(function (p) {
|
|
905
|
-
return {
|
|
908
|
+
return {
|
|
909
|
+
slug: p.slug,
|
|
910
|
+
name: p.title || p.project,
|
|
911
|
+
icon: p.icon || null,
|
|
912
|
+
isProcessing: p.isProcessing,
|
|
913
|
+
onlineUsers: p.onlineUsers || [],
|
|
914
|
+
unread: p.unread || 0,
|
|
915
|
+
isWorktree: p.isWorktree || false,
|
|
916
|
+
parentSlug: p.parentSlug || null,
|
|
917
|
+
branch: p.branch || null,
|
|
918
|
+
worktreeAccessible: p.worktreeAccessible !== undefined ? p.worktreeAccessible : true,
|
|
919
|
+
};
|
|
906
920
|
});
|
|
907
921
|
renderIconStrip(iconStripProjects, currentSlug);
|
|
908
922
|
// Update title bar project name and icon if it changed
|
|
@@ -1214,6 +1228,9 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
1214
1228
|
// --- Theme (module) ---
|
|
1215
1229
|
initTheme();
|
|
1216
1230
|
|
|
1231
|
+
// --- Tooltips ---
|
|
1232
|
+
initTooltips();
|
|
1233
|
+
|
|
1217
1234
|
// --- Sidebar (module) ---
|
|
1218
1235
|
var sidebarCtx = {
|
|
1219
1236
|
$: $,
|
|
@@ -1244,6 +1261,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
1244
1261
|
openAddProjectModal: function () { openAddProjectModal(); },
|
|
1245
1262
|
sendWs: function (msg) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg)); },
|
|
1246
1263
|
onDmRemoveUser: function (userId) { dmRemovedUsers[userId] = true; },
|
|
1264
|
+
getHistoryFrom: function () { return historyFrom; },
|
|
1247
1265
|
};
|
|
1248
1266
|
initSidebar(sidebarCtx);
|
|
1249
1267
|
initIconStrip(sidebarCtx);
|
|
@@ -1362,12 +1380,21 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
1362
1380
|
// Also blink the active session's processing dot in sidebar
|
|
1363
1381
|
var sessionDot = document.querySelector(".session-item.active .session-processing");
|
|
1364
1382
|
if (sessionDot) sessionDot.classList.add("io");
|
|
1383
|
+
// If active project is a worktree, also blink the parent project dot
|
|
1384
|
+
var activeWt = document.querySelector("#icon-strip-projects .icon-strip-wt-item.active");
|
|
1385
|
+
var parentDot = null;
|
|
1386
|
+
if (activeWt) {
|
|
1387
|
+
var group = activeWt.closest(".icon-strip-group");
|
|
1388
|
+
if (group) parentDot = group.querySelector(".folder-header .icon-strip-status");
|
|
1389
|
+
if (parentDot) parentDot.classList.add("io");
|
|
1390
|
+
}
|
|
1365
1391
|
clearTimeout(ioTimer);
|
|
1366
1392
|
ioTimer = setTimeout(function () {
|
|
1367
1393
|
var d = getStatusDot();
|
|
1368
1394
|
if (d) d.classList.remove("io");
|
|
1369
1395
|
var sd = document.querySelector(".session-item.active .session-processing.io");
|
|
1370
1396
|
if (sd) sd.classList.remove("io");
|
|
1397
|
+
if (parentDot) parentDot.classList.remove("io");
|
|
1371
1398
|
}, 80);
|
|
1372
1399
|
}
|
|
1373
1400
|
|
|
@@ -1389,7 +1416,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
1389
1416
|
function updateCrossProjectBlink() {
|
|
1390
1417
|
if (crossProjectBlinkTimer) { clearTimeout(crossProjectBlinkTimer); crossProjectBlinkTimer = null; }
|
|
1391
1418
|
function doBlink() {
|
|
1392
|
-
var dots = document.querySelectorAll("#icon-strip-projects .icon-strip-item:not(.active) .icon-strip-status.processing");
|
|
1419
|
+
var dots = document.querySelectorAll("#icon-strip-projects .icon-strip-item:not(.active) .icon-strip-status.processing, #icon-strip-projects .icon-strip-wt-item:not(.active) .icon-strip-status.processing");
|
|
1393
1420
|
if (dots.length === 0) { crossProjectBlinkTimer = null; return; }
|
|
1394
1421
|
for (var i = 0; i < dots.length; i++) { dots[i].classList.add("io"); }
|
|
1395
1422
|
setTimeout(function () {
|
|
@@ -2522,6 +2549,16 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
2522
2549
|
sessionHint.textContent = "After logging in, start a new session to continue.";
|
|
2523
2550
|
div.appendChild(sessionHint);
|
|
2524
2551
|
|
|
2552
|
+
var loginBtn = document.createElement("button");
|
|
2553
|
+
loginBtn.className = "auth-required-btn";
|
|
2554
|
+
loginBtn.textContent = "Open terminal & log in";
|
|
2555
|
+
loginBtn.addEventListener("click", function () {
|
|
2556
|
+
pendingTermCommand = "claude\n";
|
|
2557
|
+
ws.send(JSON.stringify({ type: "term_create", cols: 80, rows: 24 }));
|
|
2558
|
+
openTerminal();
|
|
2559
|
+
});
|
|
2560
|
+
div.appendChild(loginBtn);
|
|
2561
|
+
|
|
2525
2562
|
addToMessages(div);
|
|
2526
2563
|
scrollToBottom();
|
|
2527
2564
|
|
|
@@ -2734,6 +2771,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
2734
2771
|
}
|
|
2735
2772
|
|
|
2736
2773
|
function resetClientState() {
|
|
2774
|
+
if (isSearchOpen()) closeSearch();
|
|
2737
2775
|
messagesEl.innerHTML = "";
|
|
2738
2776
|
currentMsgEl = null;
|
|
2739
2777
|
currentFullText = "";
|
|
@@ -3164,6 +3202,18 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
3164
3202
|
updateSessionPresence(msg.presence || {});
|
|
3165
3203
|
break;
|
|
3166
3204
|
|
|
3205
|
+
case "cursor_move":
|
|
3206
|
+
handleRemoteCursorMove(msg);
|
|
3207
|
+
break;
|
|
3208
|
+
|
|
3209
|
+
case "cursor_leave":
|
|
3210
|
+
handleRemoteCursorLeave(msg);
|
|
3211
|
+
break;
|
|
3212
|
+
|
|
3213
|
+
case "text_select":
|
|
3214
|
+
handleRemoteSelection(msg);
|
|
3215
|
+
break;
|
|
3216
|
+
|
|
3167
3217
|
case "session_io":
|
|
3168
3218
|
blinkSessionDot(msg.id);
|
|
3169
3219
|
break;
|
|
@@ -3176,6 +3226,14 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
3176
3226
|
handleSearchResults(msg);
|
|
3177
3227
|
break;
|
|
3178
3228
|
|
|
3229
|
+
case "search_content_results":
|
|
3230
|
+
if (msg.source === "find_in_session") {
|
|
3231
|
+
handleFindInSessionResults(msg);
|
|
3232
|
+
} else {
|
|
3233
|
+
handleSearchContentResults(msg);
|
|
3234
|
+
}
|
|
3235
|
+
break;
|
|
3236
|
+
|
|
3179
3237
|
case "cli_session_list":
|
|
3180
3238
|
populateCliSessionList(msg.sessions || []);
|
|
3181
3239
|
break;
|
|
@@ -3190,9 +3248,13 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
3190
3248
|
}
|
|
3191
3249
|
activeSessionId = msg.id;
|
|
3192
3250
|
cliSessionId = msg.cliSessionId || null;
|
|
3251
|
+
clearRemoteCursors();
|
|
3193
3252
|
resetClientState();
|
|
3194
3253
|
updateRalphBars();
|
|
3195
3254
|
updateLoopInputVisibility(msg.loop);
|
|
3255
|
+
// Restore input area visibility (may have been hidden by auth_required)
|
|
3256
|
+
var inputAreaSw = document.getElementById("input-area");
|
|
3257
|
+
if (inputAreaSw) inputAreaSw.classList.remove("hidden");
|
|
3196
3258
|
// Restore draft for incoming session
|
|
3197
3259
|
var draft = sessionDrafts[activeSessionId] || "";
|
|
3198
3260
|
inputEl.value = draft;
|
|
@@ -3965,6 +4027,10 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
3965
4027
|
} else {
|
|
3966
4028
|
updateHistorySentinel();
|
|
3967
4029
|
}
|
|
4030
|
+
|
|
4031
|
+
// Notify sidebar search that history was prepended (for pending scroll targets)
|
|
4032
|
+
onHistoryPrepended();
|
|
4033
|
+
onSessionSearchHistoryPrepended();
|
|
3968
4034
|
}
|
|
3969
4035
|
|
|
3970
4036
|
function scheduleReconnect() {
|
|
@@ -4133,6 +4199,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
4133
4199
|
if (d.multiUser) isMultiUserMode = true;
|
|
4134
4200
|
if (d.user && d.user.id) myUserId = d.user.id;
|
|
4135
4201
|
if (d.mustChangePin) showForceChangePinOverlay();
|
|
4202
|
+
initCursorToggle();
|
|
4136
4203
|
}).catch(function () {});
|
|
4137
4204
|
// Hide server settings and update controls for non-admin users in multi-user mode
|
|
4138
4205
|
checkAdminAccess().then(function (isAdmin) {
|
|
@@ -4213,6 +4280,19 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
4213
4280
|
// --- Playbook Engine ---
|
|
4214
4281
|
initPlaybook();
|
|
4215
4282
|
|
|
4283
|
+
// --- In-session search (Cmd+F / Ctrl+F) ---
|
|
4284
|
+
initSessionSearch({
|
|
4285
|
+
messagesEl: messagesEl,
|
|
4286
|
+
get ws() { return ws; },
|
|
4287
|
+
getHistoryFrom: function () { return historyFrom; },
|
|
4288
|
+
});
|
|
4289
|
+
var findInSessionBtn = $("find-in-session-btn");
|
|
4290
|
+
if (findInSessionBtn) {
|
|
4291
|
+
findInSessionBtn.addEventListener("click", function () {
|
|
4292
|
+
toggleSearch();
|
|
4293
|
+
});
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4216
4296
|
// --- Sticky Notes ---
|
|
4217
4297
|
initStickyNotes({
|
|
4218
4298
|
get ws() { return ws; },
|
|
@@ -5090,7 +5170,11 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
5090
5170
|
showRemoveProjectTaskDialog(slug, name, msg.count);
|
|
5091
5171
|
} else {
|
|
5092
5172
|
// No tasks — confirm then particle burst + remove
|
|
5093
|
-
|
|
5173
|
+
var isWt = slug.indexOf("--") !== -1;
|
|
5174
|
+
var confirmMsg = isWt
|
|
5175
|
+
? 'Delete worktree "' + name + '"? The branch and working directory will be removed from disk.'
|
|
5176
|
+
: 'Remove "' + name + '"? You can re-add it later.';
|
|
5177
|
+
showConfirm(confirmMsg, function () {
|
|
5094
5178
|
// Find the icon strip item to anchor the particle burst
|
|
5095
5179
|
var iconEl = document.querySelector('.icon-strip-item[data-slug="' + slug + '"]');
|
|
5096
5180
|
if (iconEl) {
|
|
@@ -5166,26 +5250,33 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
5166
5250
|
|
|
5167
5251
|
function handleRemoveProjectResult(msg) {
|
|
5168
5252
|
if (msg.ok) {
|
|
5169
|
-
|
|
5170
|
-
// If we removed the current project, go to home hub without full reload
|
|
5253
|
+
// If we removed the current project, navigate away
|
|
5171
5254
|
if (msg.slug === currentSlug) {
|
|
5255
|
+
// Check if this is a worktree: navigate to parent project instead of home hub
|
|
5256
|
+
var isWorktree = msg.slug.indexOf("--") !== -1;
|
|
5257
|
+
var parentSlug = isWorktree ? msg.slug.split("--")[0] : null;
|
|
5258
|
+
|
|
5259
|
+
showToast(isWorktree ? "Worktree removed" : "Project removed", "success");
|
|
5260
|
+
|
|
5172
5261
|
// Suppress disconnect overlay and reconnect by detaching the WS
|
|
5173
5262
|
if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); ws = null; }
|
|
5174
5263
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
5175
5264
|
connected = false;
|
|
5176
5265
|
connectOverlay.classList.add("hidden");
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5266
|
+
if (!isWorktree) {
|
|
5267
|
+
// Add to cached removed projects for re-add UI
|
|
5268
|
+
var removedProj = null;
|
|
5269
|
+
for (var ri = 0; ri < cachedProjects.length; ri++) {
|
|
5270
|
+
if (cachedProjects[ri].slug === msg.slug) { removedProj = cachedProjects[ri]; break; }
|
|
5271
|
+
}
|
|
5272
|
+
if (removedProj) {
|
|
5273
|
+
cachedRemovedProjects.push({
|
|
5274
|
+
path: removedProj.path || "",
|
|
5275
|
+
title: removedProj.title || null,
|
|
5276
|
+
icon: removedProj.icon || null,
|
|
5277
|
+
removedAt: Date.now(),
|
|
5278
|
+
});
|
|
5279
|
+
}
|
|
5189
5280
|
}
|
|
5190
5281
|
// Remove from cached projects and re-render icon strip
|
|
5191
5282
|
cachedProjects = cachedProjects.filter(function (p) { return p.slug !== msg.slug; });
|
|
@@ -5193,7 +5284,14 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
5193
5284
|
currentSlug = null;
|
|
5194
5285
|
renderProjectList();
|
|
5195
5286
|
resetClientState();
|
|
5196
|
-
|
|
5287
|
+
|
|
5288
|
+
if (parentSlug && switchProject) {
|
|
5289
|
+
switchProject(parentSlug);
|
|
5290
|
+
} else {
|
|
5291
|
+
showHomeHub();
|
|
5292
|
+
}
|
|
5293
|
+
} else {
|
|
5294
|
+
showToast(msg.slug.indexOf("--") !== -1 ? "Worktree removed" : "Project removed", "success");
|
|
5197
5295
|
}
|
|
5198
5296
|
} else {
|
|
5199
5297
|
showToast(msg.error || "Failed to remove project", "error");
|
|
@@ -5596,6 +5694,455 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
5596
5694
|
});
|
|
5597
5695
|
})();
|
|
5598
5696
|
|
|
5697
|
+
// --- Remote Cursor Presence ---
|
|
5698
|
+
var cursorSharingEnabled = localStorage.getItem("cursorSharing") !== "off";
|
|
5699
|
+
var remoteCursors = {}; // userId -> { el, timer }
|
|
5700
|
+
var cursorThrottleTimer = null;
|
|
5701
|
+
var CURSOR_THROTTLE_MS = 30;
|
|
5702
|
+
var CURSOR_HIDE_TIMEOUT = 5000;
|
|
5703
|
+
|
|
5704
|
+
// Cursor sharing toggle button in user island (multi-user only)
|
|
5705
|
+
function initCursorToggle() {
|
|
5706
|
+
if (!isMultiUserMode) return;
|
|
5707
|
+
var actionsEl = document.querySelector(".user-island-actions");
|
|
5708
|
+
if (!actionsEl) return;
|
|
5709
|
+
if (document.getElementById("cursor-share-toggle")) return;
|
|
5710
|
+
|
|
5711
|
+
var btn = document.createElement("button");
|
|
5712
|
+
btn.id = "cursor-share-toggle";
|
|
5713
|
+
btn.className = "cursor-share-btn";
|
|
5714
|
+
btn.innerHTML = '<i data-lucide="mouse-pointer-2"></i>';
|
|
5715
|
+
actionsEl.appendChild(btn);
|
|
5716
|
+
|
|
5717
|
+
function updateToggleStyle() {
|
|
5718
|
+
if (cursorSharingEnabled) {
|
|
5719
|
+
btn.classList.remove("off");
|
|
5720
|
+
btn.classList.add("on");
|
|
5721
|
+
registerTooltip(btn, "Cursor sharing on");
|
|
5722
|
+
} else {
|
|
5723
|
+
btn.classList.remove("on");
|
|
5724
|
+
btn.classList.add("off");
|
|
5725
|
+
registerTooltip(btn, "Cursor sharing off");
|
|
5726
|
+
}
|
|
5727
|
+
}
|
|
5728
|
+
|
|
5729
|
+
updateToggleStyle();
|
|
5730
|
+
lucide.createIcons({ nodes: [btn] });
|
|
5731
|
+
|
|
5732
|
+
btn.addEventListener("click", function () {
|
|
5733
|
+
cursorSharingEnabled = !cursorSharingEnabled;
|
|
5734
|
+
localStorage.setItem("cursorSharing", cursorSharingEnabled ? "on" : "off");
|
|
5735
|
+
updateToggleStyle();
|
|
5736
|
+
if (!cursorSharingEnabled && ws && ws.readyState === 1) {
|
|
5737
|
+
ws.send(JSON.stringify({ type: "cursor_leave" }));
|
|
5738
|
+
ws.send(JSON.stringify({ type: "text_select", ranges: [] }));
|
|
5739
|
+
}
|
|
5740
|
+
});
|
|
5741
|
+
}
|
|
5742
|
+
|
|
5743
|
+
// Unique colors for remote cursors (Figma-style)
|
|
5744
|
+
var cursorColors = [
|
|
5745
|
+
"#F24822", "#FF7262", "#A259FF", "#1ABCFE",
|
|
5746
|
+
"#0ACF83", "#FF6D00", "#E84393", "#6C5CE7",
|
|
5747
|
+
"#00B894", "#FDCB6E", "#E17055", "#74B9FF",
|
|
5748
|
+
];
|
|
5749
|
+
var userColorMap = {};
|
|
5750
|
+
var nextColorIdx = 0;
|
|
5751
|
+
|
|
5752
|
+
function getCursorColor(userId) {
|
|
5753
|
+
if (!userColorMap[userId]) {
|
|
5754
|
+
userColorMap[userId] = cursorColors[nextColorIdx % cursorColors.length];
|
|
5755
|
+
nextColorIdx++;
|
|
5756
|
+
}
|
|
5757
|
+
return userColorMap[userId];
|
|
5758
|
+
}
|
|
5759
|
+
|
|
5760
|
+
function createCursorElement(userId, displayName, color, avatarStyle, avatarSeed) {
|
|
5761
|
+
var wrapper = document.createElement("div");
|
|
5762
|
+
wrapper.className = "remote-cursor";
|
|
5763
|
+
wrapper.dataset.userId = userId;
|
|
5764
|
+
wrapper.style.position = "absolute";
|
|
5765
|
+
wrapper.style.zIndex = "9999";
|
|
5766
|
+
wrapper.style.pointerEvents = "none";
|
|
5767
|
+
wrapper.style.display = "none";
|
|
5768
|
+
wrapper.style.transition = "left 30ms linear, top 30ms linear";
|
|
5769
|
+
wrapper.style.willChange = "left, top";
|
|
5770
|
+
|
|
5771
|
+
// SVG cursor arrow
|
|
5772
|
+
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
5773
|
+
svg.setAttribute("width", "16");
|
|
5774
|
+
svg.setAttribute("height", "20");
|
|
5775
|
+
svg.setAttribute("viewBox", "0 0 16 20");
|
|
5776
|
+
svg.style.display = "block";
|
|
5777
|
+
svg.style.filter = "drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
|
|
5778
|
+
var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
5779
|
+
path.setAttribute("d", "M0 0 L0 16 L4.5 12 L8 19 L10.5 18 L7 11 L13 11 Z");
|
|
5780
|
+
path.setAttribute("fill", color);
|
|
5781
|
+
path.setAttribute("stroke", "#fff");
|
|
5782
|
+
path.setAttribute("stroke-width", "1");
|
|
5783
|
+
svg.appendChild(path);
|
|
5784
|
+
wrapper.appendChild(svg);
|
|
5785
|
+
|
|
5786
|
+
// Tag: avatar + name label together
|
|
5787
|
+
var tag = document.createElement("div");
|
|
5788
|
+
tag.className = "remote-cursor-tag";
|
|
5789
|
+
tag.style.cssText = "position:absolute;left:14px;top:14px;display:flex;align-items:center;" +
|
|
5790
|
+
"gap:3px;background:" + color + ";padding:1px 6px 1px 2px;border-radius:10px;" +
|
|
5791
|
+
"pointer-events:none;white-space:nowrap;";
|
|
5792
|
+
|
|
5793
|
+
// Avatar
|
|
5794
|
+
var avatarImg = document.createElement("img");
|
|
5795
|
+
avatarImg.className = "remote-cursor-avatar";
|
|
5796
|
+
var style = avatarStyle || "thumbs";
|
|
5797
|
+
var seed = avatarSeed || userId;
|
|
5798
|
+
avatarImg.src = "https://api.dicebear.com/9.x/" + style + "/svg?seed=" + encodeURIComponent(seed) + "&size=16";
|
|
5799
|
+
avatarImg.style.cssText = "width:14px;height:14px;border-radius:50%;background:#fff;flex-shrink:0;";
|
|
5800
|
+
tag.appendChild(avatarImg);
|
|
5801
|
+
|
|
5802
|
+
// Name label
|
|
5803
|
+
var label = document.createElement("span");
|
|
5804
|
+
label.className = "remote-cursor-label";
|
|
5805
|
+
label.textContent = displayName;
|
|
5806
|
+
label.style.cssText = "color:#fff;font-size:11px;font-weight:500;line-height:16px;font-family:inherit;";
|
|
5807
|
+
tag.appendChild(label);
|
|
5808
|
+
|
|
5809
|
+
wrapper.appendChild(tag);
|
|
5810
|
+
|
|
5811
|
+
return wrapper;
|
|
5812
|
+
}
|
|
5813
|
+
|
|
5814
|
+
|
|
5815
|
+
// Compute cumulative character offset within a container element
|
|
5816
|
+
function getCharOffset(container, targetNode, targetOffset) {
|
|
5817
|
+
var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
|
|
5818
|
+
var offset = 0;
|
|
5819
|
+
var node;
|
|
5820
|
+
while ((node = walker.nextNode())) {
|
|
5821
|
+
if (node === targetNode) {
|
|
5822
|
+
return offset + targetOffset;
|
|
5823
|
+
}
|
|
5824
|
+
offset += node.textContent.length;
|
|
5825
|
+
}
|
|
5826
|
+
return offset;
|
|
5827
|
+
}
|
|
5828
|
+
|
|
5829
|
+
// Find text node + local offset for a given cumulative character offset
|
|
5830
|
+
function getNodeAtCharOffset(container, charOffset) {
|
|
5831
|
+
var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
|
|
5832
|
+
var consumed = 0;
|
|
5833
|
+
var node;
|
|
5834
|
+
var lastNode = null;
|
|
5835
|
+
while ((node = walker.nextNode())) {
|
|
5836
|
+
lastNode = node;
|
|
5837
|
+
var len = node.textContent.length;
|
|
5838
|
+
if (consumed + len >= charOffset) {
|
|
5839
|
+
return { node: node, offset: Math.min(charOffset - consumed, len) };
|
|
5840
|
+
}
|
|
5841
|
+
consumed += len;
|
|
5842
|
+
}
|
|
5843
|
+
if (lastNode) {
|
|
5844
|
+
return { node: lastNode, offset: lastNode.textContent.length };
|
|
5845
|
+
}
|
|
5846
|
+
return null;
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5849
|
+
// Find parent [data-turn] element from a DOM node
|
|
5850
|
+
function findParentTurn(node) {
|
|
5851
|
+
var el = node.nodeType === 3 ? node.parentElement : node;
|
|
5852
|
+
while (el && el !== messagesEl) {
|
|
5853
|
+
if (el.dataset && el.dataset.turn != null) return el;
|
|
5854
|
+
el = el.parentElement;
|
|
5855
|
+
}
|
|
5856
|
+
return null;
|
|
5857
|
+
}
|
|
5858
|
+
|
|
5859
|
+
// --- Remote Selection Highlight ---
|
|
5860
|
+
var remoteSelections = {}; // userId -> { els: [], timer }
|
|
5861
|
+
|
|
5862
|
+
function clearRemoteSelection(userId) {
|
|
5863
|
+
var sel = remoteSelections[userId];
|
|
5864
|
+
if (!sel) return;
|
|
5865
|
+
for (var i = 0; i < sel.els.length; i++) {
|
|
5866
|
+
if (sel.els[i].parentNode) sel.els[i].parentNode.removeChild(sel.els[i]);
|
|
5867
|
+
}
|
|
5868
|
+
sel.els = [];
|
|
5869
|
+
}
|
|
5870
|
+
|
|
5871
|
+
function handleRemoteSelection(msg) {
|
|
5872
|
+
var userId = msg.userId;
|
|
5873
|
+
var color = getCursorColor(userId);
|
|
5874
|
+
|
|
5875
|
+
if (!remoteSelections[userId]) {
|
|
5876
|
+
remoteSelections[userId] = { els: [], timer: null };
|
|
5877
|
+
}
|
|
5878
|
+
|
|
5879
|
+
// Clear previous highlight
|
|
5880
|
+
clearRemoteSelection(userId);
|
|
5881
|
+
|
|
5882
|
+
// If selection cleared, just remove
|
|
5883
|
+
if (!msg.ranges || msg.ranges.length === 0) return;
|
|
5884
|
+
|
|
5885
|
+
var containerRect = messagesEl.getBoundingClientRect();
|
|
5886
|
+
|
|
5887
|
+
for (var r = 0; r < msg.ranges.length; r++) {
|
|
5888
|
+
var sel = msg.ranges[r];
|
|
5889
|
+
var startTurnEl = messagesEl.querySelector('[data-turn="' + sel.startTurn + '"]');
|
|
5890
|
+
var endTurnEl = messagesEl.querySelector('[data-turn="' + sel.endTurn + '"]');
|
|
5891
|
+
if (!startTurnEl || !endTurnEl) continue;
|
|
5892
|
+
|
|
5893
|
+
var startResult = getNodeAtCharOffset(startTurnEl, sel.startCh);
|
|
5894
|
+
var endResult = getNodeAtCharOffset(endTurnEl, sel.endCh);
|
|
5895
|
+
if (!startResult || !endResult) continue;
|
|
5896
|
+
|
|
5897
|
+
try {
|
|
5898
|
+
var range = document.createRange();
|
|
5899
|
+
range.setStart(startResult.node, startResult.offset);
|
|
5900
|
+
range.setEnd(endResult.node, endResult.offset);
|
|
5901
|
+
var rects = range.getClientRects();
|
|
5902
|
+
|
|
5903
|
+
for (var i = 0; i < rects.length; i++) {
|
|
5904
|
+
var rect = rects[i];
|
|
5905
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
5906
|
+
var highlight = document.createElement("div");
|
|
5907
|
+
highlight.className = "remote-selection";
|
|
5908
|
+
highlight.dataset.userId = userId;
|
|
5909
|
+
highlight.style.cssText =
|
|
5910
|
+
"position:absolute;pointer-events:none;z-index:9998;" +
|
|
5911
|
+
"background:" + color + ";" +
|
|
5912
|
+
"opacity:0.2;" +
|
|
5913
|
+
"border-radius:2px;" +
|
|
5914
|
+
"left:" + (rect.left - containerRect.left + messagesEl.scrollLeft) + "px;" +
|
|
5915
|
+
"top:" + (rect.top - containerRect.top + messagesEl.scrollTop) + "px;" +
|
|
5916
|
+
"width:" + rect.width + "px;" +
|
|
5917
|
+
"height:" + rect.height + "px;";
|
|
5918
|
+
messagesEl.appendChild(highlight);
|
|
5919
|
+
remoteSelections[userId].els.push(highlight);
|
|
5920
|
+
}
|
|
5921
|
+
} catch (e) {}
|
|
5922
|
+
}
|
|
5923
|
+
|
|
5924
|
+
// Auto-hide after timeout
|
|
5925
|
+
if (remoteSelections[userId].timer) clearTimeout(remoteSelections[userId].timer);
|
|
5926
|
+
remoteSelections[userId].timer = setTimeout(function () {
|
|
5927
|
+
clearRemoteSelection(userId);
|
|
5928
|
+
}, 10000);
|
|
5929
|
+
}
|
|
5930
|
+
|
|
5931
|
+
function createOffscreenIndicator(userId, displayName, color) {
|
|
5932
|
+
var btn = document.createElement("button");
|
|
5933
|
+
btn.className = "remote-cursor-offscreen";
|
|
5934
|
+
btn.dataset.userId = userId;
|
|
5935
|
+
btn.style.cssText =
|
|
5936
|
+
"position:absolute;left:50%;transform:translateX(-50%);" +
|
|
5937
|
+
"z-index:10000;display:none;cursor:pointer;border:none;outline:none;" +
|
|
5938
|
+
"background:" + color + ";color:#fff;font-size:11px;font-weight:500;" +
|
|
5939
|
+
"padding:3px 10px 3px 8px;border-radius:12px;white-space:nowrap;" +
|
|
5940
|
+
"font-family:inherit;line-height:16px;opacity:0.9;" +
|
|
5941
|
+
"box-shadow:0 2px 8px rgba(0,0,0,0.2);pointer-events:auto;" +
|
|
5942
|
+
"transition:opacity 0.15s;";
|
|
5943
|
+
btn.addEventListener("mouseenter", function () { btn.style.opacity = "1"; });
|
|
5944
|
+
btn.addEventListener("mouseleave", function () { btn.style.opacity = "0.9"; });
|
|
5945
|
+
return btn;
|
|
5946
|
+
}
|
|
5947
|
+
|
|
5948
|
+
function updateCursorVisibility(entry) {
|
|
5949
|
+
var visibleTop = messagesEl.scrollTop;
|
|
5950
|
+
var visibleBottom = visibleTop + messagesEl.clientHeight;
|
|
5951
|
+
var y = entry.lastY || 0;
|
|
5952
|
+
|
|
5953
|
+
if (y < visibleTop) {
|
|
5954
|
+
entry.indicator.style.top = (visibleTop + 6) + "px";
|
|
5955
|
+
entry.indicator.style.display = "";
|
|
5956
|
+
} else if (y > visibleBottom) {
|
|
5957
|
+
entry.indicator.style.top = (visibleBottom - 28) + "px";
|
|
5958
|
+
entry.indicator.style.display = "";
|
|
5959
|
+
} else {
|
|
5960
|
+
entry.indicator.style.display = "none";
|
|
5961
|
+
}
|
|
5962
|
+
}
|
|
5963
|
+
|
|
5964
|
+
function handleRemoteCursorMove(msg) {
|
|
5965
|
+
var userId = msg.userId;
|
|
5966
|
+
|
|
5967
|
+
var entry = remoteCursors[userId];
|
|
5968
|
+
if (!entry) {
|
|
5969
|
+
var color = getCursorColor(userId);
|
|
5970
|
+
var el = createCursorElement(userId, msg.displayName, color, msg.avatarStyle, msg.avatarSeed);
|
|
5971
|
+
messagesEl.appendChild(el);
|
|
5972
|
+
var indicator = createOffscreenIndicator(userId, msg.displayName, color);
|
|
5973
|
+
messagesEl.appendChild(indicator);
|
|
5974
|
+
entry = { el: el, indicator: indicator, timer: null, lastY: 0, active: false };
|
|
5975
|
+
remoteCursors[userId] = entry;
|
|
5976
|
+
|
|
5977
|
+
indicator.addEventListener("click", function () {
|
|
5978
|
+
messagesEl.scrollTo({ top: entry.lastY - messagesEl.clientHeight / 2, behavior: "smooth" });
|
|
5979
|
+
});
|
|
5980
|
+
}
|
|
5981
|
+
|
|
5982
|
+
// Find the same turn element on this screen
|
|
5983
|
+
var anchorEl = null;
|
|
5984
|
+
if (msg.turn != null) {
|
|
5985
|
+
anchorEl = messagesEl.querySelector('[data-turn="' + msg.turn + '"]');
|
|
5986
|
+
}
|
|
5987
|
+
|
|
5988
|
+
if (anchorEl && msg.rx != null && msg.ry != null) {
|
|
5989
|
+
var x = anchorEl.offsetLeft + msg.rx * anchorEl.offsetWidth;
|
|
5990
|
+
var y = anchorEl.offsetTop + msg.ry * anchorEl.offsetHeight;
|
|
5991
|
+
entry.lastY = y;
|
|
5992
|
+
entry.active = true;
|
|
5993
|
+
|
|
5994
|
+
// Update indicator label (direction set by updateCursorVisibility)
|
|
5995
|
+
entry.indicator.textContent = (y < messagesEl.scrollTop ? "▲ " : "▼ ") + (msg.displayName || userId);
|
|
5996
|
+
|
|
5997
|
+
entry.el.style.left = x + "px";
|
|
5998
|
+
entry.el.style.top = y + "px";
|
|
5999
|
+
entry.el.style.display = "";
|
|
6000
|
+
|
|
6001
|
+
updateCursorVisibility(entry);
|
|
6002
|
+
}
|
|
6003
|
+
|
|
6004
|
+
// Reset hide timer
|
|
6005
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
6006
|
+
entry.timer = setTimeout(function () {
|
|
6007
|
+
entry.el.style.display = "none";
|
|
6008
|
+
entry.indicator.style.display = "none";
|
|
6009
|
+
entry.active = false;
|
|
6010
|
+
}, CURSOR_HIDE_TIMEOUT);
|
|
6011
|
+
}
|
|
6012
|
+
|
|
6013
|
+
function handleRemoteCursorLeave(msg) {
|
|
6014
|
+
var entry = remoteCursors[msg.userId];
|
|
6015
|
+
if (entry) {
|
|
6016
|
+
entry.el.style.display = "none";
|
|
6017
|
+
entry.indicator.style.display = "none";
|
|
6018
|
+
entry.active = false;
|
|
6019
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
6022
|
+
|
|
6023
|
+
// Find the closest [data-turn] element to a given clientY
|
|
6024
|
+
function findClosestTurn(clientY) {
|
|
6025
|
+
var turns = messagesEl.querySelectorAll("[data-turn]");
|
|
6026
|
+
if (!turns.length) return null;
|
|
6027
|
+
// First: exact hit
|
|
6028
|
+
for (var i = 0; i < turns.length; i++) {
|
|
6029
|
+
var r = turns[i].getBoundingClientRect();
|
|
6030
|
+
if (clientY >= r.top && clientY <= r.bottom) return turns[i];
|
|
6031
|
+
}
|
|
6032
|
+
// Second: closest by distance
|
|
6033
|
+
var closest = null;
|
|
6034
|
+
var closestDist = Infinity;
|
|
6035
|
+
for (var j = 0; j < turns.length; j++) {
|
|
6036
|
+
var rect = turns[j].getBoundingClientRect();
|
|
6037
|
+
var mid = (rect.top + rect.bottom) / 2;
|
|
6038
|
+
var dist = Math.abs(clientY - mid);
|
|
6039
|
+
if (dist < closestDist) { closestDist = dist; closest = turns[j]; }
|
|
6040
|
+
}
|
|
6041
|
+
return closest;
|
|
6042
|
+
}
|
|
6043
|
+
|
|
6044
|
+
|
|
6045
|
+
// Track local cursor and send to server
|
|
6046
|
+
messagesEl.addEventListener("mousemove", function (e) {
|
|
6047
|
+
if (!cursorSharingEnabled) return;
|
|
6048
|
+
if (!ws || ws.readyState !== 1) return;
|
|
6049
|
+
if (cursorThrottleTimer) return;
|
|
6050
|
+
cursorThrottleTimer = setTimeout(function () { cursorThrottleTimer = null; }, CURSOR_THROTTLE_MS);
|
|
6051
|
+
|
|
6052
|
+
// Find which turn element the cursor is over
|
|
6053
|
+
var turnEl = findClosestTurn(e.clientY);
|
|
6054
|
+
if (!turnEl) return;
|
|
6055
|
+
|
|
6056
|
+
// Calculate ratio within the turn element
|
|
6057
|
+
var turnRect = turnEl.getBoundingClientRect();
|
|
6058
|
+
var rx = turnRect.width > 0 ? (e.clientX - turnRect.left) / turnRect.width : 0;
|
|
6059
|
+
var ry = turnRect.height > 0 ? (e.clientY - turnRect.top) / turnRect.height : 0;
|
|
6060
|
+
|
|
6061
|
+
ws.send(JSON.stringify({
|
|
6062
|
+
type: "cursor_move",
|
|
6063
|
+
turn: parseInt(turnEl.dataset.turn, 10),
|
|
6064
|
+
rx: Math.max(0, Math.min(1, rx)),
|
|
6065
|
+
ry: Math.max(0, Math.min(1, ry))
|
|
6066
|
+
}));
|
|
6067
|
+
});
|
|
6068
|
+
|
|
6069
|
+
messagesEl.addEventListener("mouseleave", function () {
|
|
6070
|
+
if (!cursorSharingEnabled) return;
|
|
6071
|
+
if (!ws || ws.readyState !== 1) return;
|
|
6072
|
+
ws.send(JSON.stringify({ type: "cursor_leave" }));
|
|
6073
|
+
});
|
|
6074
|
+
|
|
6075
|
+
// Update offscreen indicators on scroll
|
|
6076
|
+
messagesEl.addEventListener("scroll", function () {
|
|
6077
|
+
for (var uid in remoteCursors) {
|
|
6078
|
+
var entry = remoteCursors[uid];
|
|
6079
|
+
if (!entry.active) continue;
|
|
6080
|
+
updateCursorVisibility(entry);
|
|
6081
|
+
}
|
|
6082
|
+
});
|
|
6083
|
+
|
|
6084
|
+
// Track local text selection and send to server
|
|
6085
|
+
var selectionThrottleTimer = null;
|
|
6086
|
+
var lastSelectionKey = "";
|
|
6087
|
+
document.addEventListener("selectionchange", function () {
|
|
6088
|
+
if (!cursorSharingEnabled) return;
|
|
6089
|
+
if (!ws || ws.readyState !== 1) return;
|
|
6090
|
+
if (selectionThrottleTimer) return;
|
|
6091
|
+
selectionThrottleTimer = setTimeout(function () { selectionThrottleTimer = null; }, 100);
|
|
6092
|
+
|
|
6093
|
+
var sel = window.getSelection();
|
|
6094
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
|
6095
|
+
// Selection cleared
|
|
6096
|
+
if (lastSelectionKey !== "") {
|
|
6097
|
+
lastSelectionKey = "";
|
|
6098
|
+
ws.send(JSON.stringify({ type: "text_select", ranges: [] }));
|
|
6099
|
+
}
|
|
6100
|
+
return;
|
|
6101
|
+
}
|
|
6102
|
+
|
|
6103
|
+
var ranges = [];
|
|
6104
|
+
for (var i = 0; i < sel.rangeCount; i++) {
|
|
6105
|
+
var range = sel.getRangeAt(i);
|
|
6106
|
+
var startTurn = findParentTurn(range.startContainer);
|
|
6107
|
+
var endTurn = findParentTurn(range.endContainer);
|
|
6108
|
+
if (!startTurn || !endTurn) continue;
|
|
6109
|
+
// Both must be inside messagesEl
|
|
6110
|
+
if (!messagesEl.contains(startTurn)) continue;
|
|
6111
|
+
|
|
6112
|
+
var startCh = getCharOffset(startTurn, range.startContainer, range.startOffset);
|
|
6113
|
+
var endCh = getCharOffset(endTurn, range.endContainer, range.endOffset);
|
|
6114
|
+
|
|
6115
|
+
ranges.push({
|
|
6116
|
+
startTurn: parseInt(startTurn.dataset.turn, 10),
|
|
6117
|
+
startCh: startCh,
|
|
6118
|
+
endTurn: parseInt(endTurn.dataset.turn, 10),
|
|
6119
|
+
endCh: endCh
|
|
6120
|
+
});
|
|
6121
|
+
}
|
|
6122
|
+
|
|
6123
|
+
var key = JSON.stringify(ranges);
|
|
6124
|
+
if (key === lastSelectionKey) return;
|
|
6125
|
+
lastSelectionKey = key;
|
|
6126
|
+
|
|
6127
|
+
ws.send(JSON.stringify({ type: "text_select", ranges: ranges }));
|
|
6128
|
+
});
|
|
6129
|
+
|
|
6130
|
+
// Clean up remote cursors and selections when switching sessions
|
|
6131
|
+
function clearRemoteCursors() {
|
|
6132
|
+
for (var uid in remoteCursors) {
|
|
6133
|
+
var entry = remoteCursors[uid];
|
|
6134
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
6135
|
+
if (entry.el.parentNode) entry.el.parentNode.removeChild(entry.el);
|
|
6136
|
+
if (entry.indicator && entry.indicator.parentNode) entry.indicator.parentNode.removeChild(entry.indicator);
|
|
6137
|
+
}
|
|
6138
|
+
remoteCursors = {};
|
|
6139
|
+
for (var uid2 in remoteSelections) {
|
|
6140
|
+
clearRemoteSelection(uid2);
|
|
6141
|
+
if (remoteSelections[uid2].timer) clearTimeout(remoteSelections[uid2].timer);
|
|
6142
|
+
}
|
|
6143
|
+
remoteSelections = {};
|
|
6144
|
+
}
|
|
6145
|
+
|
|
5599
6146
|
// --- Init ---
|
|
5600
6147
|
lucide.createIcons();
|
|
5601
6148
|
connect();
|