From 964271f9c771318f4b1b7e65926044379218d95c Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 5 Aug 2024 16:18:05 +0100 Subject: [PATCH] Multiplayer: add real-time cursor tracking --- .../editor-client/src/js/multiplayer.js | 195 ++++++++++++------ .../runtime/lib/multiplayer/index.js | 3 +- 2 files changed, 135 insertions(+), 63 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js b/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js index ea836eaf4..b37d90fcb 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js +++ b/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js @@ -100,16 +100,36 @@ RED.multiplayer = (function () { break } } + if (isInWorkspace) { + const chart = $('#red-ui-workspace-chart') + const chartOffset = chart.offset() + const scaleFactor = RED.view.scale() + location.cursor = { + x: (lastPosition[0] - chartOffset.left + chart.scrollLeft()) / scaleFactor, + y: (lastPosition[1] - chartOffset.top + chart.scrollTop()) / scaleFactor + } + } return location } + + let publishLocationTimeout + let lastPosition = [0,0] + let isInWorkspace = false + function publishLocation () { - const location = getLocation() - if (location.workspace !== 0) { - log('send', 'multiplayer/location', location) - RED.comms.send('multiplayer/location', location) + if (!publishLocationTimeout) { + publishLocationTimeout = setTimeout(() => { + const location = getLocation() + if (location.workspace !== 0) { + log('send', 'multiplayer/location', location) + RED.comms.send('multiplayer/location', location) + } + publishLocationTimeout = null + }, 100) } } + function revealUser(location, skipWorkspace) { if (location.node) { // Need to check if this is a known node, so we can fall back to revealing @@ -271,7 +291,16 @@ RED.multiplayer = (function () { function removeUserLocation (sessionId) { updateUserLocation(sessionId, {}) + removeUserCursor(sessionId) } + function removeUserCursor (sessionId) { + // return + if (sessions[sessionId]?.cursor) { + sessions[sessionId].cursor.parentNode.removeChild(sessions[sessionId].cursor) + delete sessions[sessionId].cursor + } + } + function updateUserLocation (sessionId, location) { let viewTouched = false const oldLocation = sessions[sessionId].location @@ -291,6 +320,28 @@ RED.multiplayer = (function () { // console.log(`updateUserLocation sessionId:${sessionId} oldWS:${oldLocation?.workspace} newWS:${location.workspace}`) if (location.workspace) { getWorkspaceTray(location.workspace).addUser(sessionId) + if (location.cursor && location.workspace === RED.workspaces.active()) { + if (!sessions[sessionId].cursor) { + const user = sessions[sessionId].user + const cursorIcon = document.createElementNS("http://www.w3.org/2000/svg","g"); + cursorIcon.setAttribute("class", "red-ui-multiplayer-annotation") + cursorIcon.appendChild(createAnnotationUser(user, true)) + $(cursorIcon).css({ + transform: `translate( ${location.cursor.x}px, ${location.cursor.y}px)`, + transition: 'transform 0.1s linear' + }) + $("#red-ui-workspace-chart svg").append(cursorIcon) + sessions[sessionId].cursor = cursorIcon + } else { + const cursorIcon = sessions[sessionId].cursor + $(cursorIcon).css({ + transform: `translate( ${location.cursor.x}px, ${location.cursor.y}px)` + }) + + } + } else if (sessions[sessionId].cursor) { + removeUserCursor(sessionId) + } } if (location.node) { addUserToNode(sessionId, location.node) @@ -309,67 +360,69 @@ RED.multiplayer = (function () { // } // } + + function createAnnotationUser(user, pointer = false) { + const radius = 20 + const halfRadius = radius/2 + const group = document.createElementNS("http://www.w3.org/2000/svg","g"); + const badge = document.createElementNS("http://www.w3.org/2000/svg","path"); + let shapePath + if (!pointer) { + shapePath = `M 0 ${halfRadius} a ${halfRadius} ${halfRadius} 0 1 1 ${radius} 0 a ${halfRadius} ${halfRadius} 0 1 1 -${radius} 0 z` + } else { + shapePath = `M 0 0 h ${halfRadius} a ${halfRadius} ${halfRadius} 0 1 1 -${halfRadius} ${halfRadius} z` + } + badge.setAttribute('d', shapePath) + badge.setAttribute("class", "red-ui-multiplayer-annotation-background") + group.appendChild(badge) + if (user && user.profileColor !== undefined) { + badge.setAttribute("class", "red-ui-multiplayer-annotation-background red-ui-user-profile-color-" + user.profileColor) + } + if (user && user.image) { + const image = document.createElementNS("http://www.w3.org/2000/svg","image"); + image.setAttribute("width", radius) + image.setAttribute("height", radius) + image.setAttribute("href", user.image) + image.setAttribute("clip-path", "circle("+Math.floor(radius/2)+")") + group.appendChild(image) + } else if (user && user.anonymous) { + const anonIconHead = document.createElementNS("http://www.w3.org/2000/svg","circle"); + anonIconHead.setAttribute("cx", radius/2) + anonIconHead.setAttribute("cy", radius/2 - 2) + anonIconHead.setAttribute("r", 2.4) + anonIconHead.setAttribute("class","red-ui-multiplayer-annotation-anon-label"); + group.appendChild(anonIconHead) + const anonIconBody = document.createElementNS("http://www.w3.org/2000/svg","path"); + anonIconBody.setAttribute("class","red-ui-multiplayer-annotation-anon-label"); + // anonIconBody.setAttribute("d",`M ${radius/2 - 4} ${radius/2 + 1} h 8 v4 h -8 z`); + anonIconBody.setAttribute("d",`M ${radius/2} ${radius/2 + 5} h -2.5 c -2 1 -2 -5 0.5 -4.5 c 2 1 2 1 4 0 c 2.5 -0.5 2.5 5.5 0 4.5 z`); + group.appendChild(anonIconBody) + } else { + const labelText = user.username ? user.username.substring(0,2) : user + const label = document.createElementNS("http://www.w3.org/2000/svg","text"); + if (user.username) { + label.setAttribute("class","red-ui-multiplayer-annotation-label"); + label.textContent = user.username.substring(0,2) + } else { + label.setAttribute("class","red-ui-multiplayer-annotation-label red-ui-multiplayer-user-count") + label.textContent = user + } + label.setAttribute("text-anchor", "middle") + label.setAttribute("x",radius/2); + label.setAttribute("y",radius/2 + 3); + group.appendChild(label) + } + const border = document.createElementNS("http://www.w3.org/2000/svg","path"); + border.setAttribute('d', shapePath) + border.setAttribute("class", "red-ui-multiplayer-annotation-border") + group.appendChild(border) + return group + } + return { init: function () { - function createAnnotationUser(user) { - - const group = document.createElementNS("http://www.w3.org/2000/svg","g"); - const badge = document.createElementNS("http://www.w3.org/2000/svg","circle"); - const radius = 20 - badge.setAttribute("cx",radius/2); - badge.setAttribute("cy",radius/2); - badge.setAttribute("r",radius/2); - badge.setAttribute("class", "red-ui-multiplayer-annotation-background") - group.appendChild(badge) - if (user && user.profileColor !== undefined) { - badge.setAttribute("class", "red-ui-multiplayer-annotation-background red-ui-user-profile-color-" + user.profileColor) - } - if (user && user.image) { - const image = document.createElementNS("http://www.w3.org/2000/svg","image"); - image.setAttribute("width", radius) - image.setAttribute("height", radius) - image.setAttribute("href", user.image) - image.setAttribute("clip-path", "circle("+Math.floor(radius/2)+")") - group.appendChild(image) - } else if (user && user.anonymous) { - const anonIconHead = document.createElementNS("http://www.w3.org/2000/svg","circle"); - anonIconHead.setAttribute("cx", radius/2) - anonIconHead.setAttribute("cy", radius/2 - 2) - anonIconHead.setAttribute("r", 2.4) - anonIconHead.setAttribute("class","red-ui-multiplayer-annotation-anon-label"); - group.appendChild(anonIconHead) - const anonIconBody = document.createElementNS("http://www.w3.org/2000/svg","path"); - anonIconBody.setAttribute("class","red-ui-multiplayer-annotation-anon-label"); - // anonIconBody.setAttribute("d",`M ${radius/2 - 4} ${radius/2 + 1} h 8 v4 h -8 z`); - anonIconBody.setAttribute("d",`M ${radius/2} ${radius/2 + 5} h -2.5 c -2 1 -2 -5 0.5 -4.5 c 2 1 2 1 4 0 c 2.5 -0.5 2.5 5.5 0 4.5 z`); - group.appendChild(anonIconBody) - } else { - const labelText = user.username ? user.username.substring(0,2) : user - const label = document.createElementNS("http://www.w3.org/2000/svg","text"); - if (user.username) { - label.setAttribute("class","red-ui-multiplayer-annotation-label"); - label.textContent = user.username.substring(0,2) - } else { - label.setAttribute("class","red-ui-multiplayer-annotation-label red-ui-multiplayer-user-count") - label.textContent = user - } - label.setAttribute("text-anchor", "middle") - label.setAttribute("x",radius/2); - label.setAttribute("y",radius/2 + 3); - group.appendChild(label) - } - const border = document.createElementNS("http://www.w3.org/2000/svg","circle"); - border.setAttribute("cx",radius/2); - border.setAttribute("cy",radius/2); - border.setAttribute("r",radius/2); - border.setAttribute("class", "red-ui-multiplayer-annotation-border") - group.appendChild(border) - - - - return group - } + RED.view.annotations.register("red-ui-multiplayer",{ type: 'badge', @@ -479,6 +532,24 @@ RED.multiplayer = (function () { RED.comms.send('multiplayer/disconnect', disconnectInfo) RED.settings.removeLocal('multiplayer:sessionId') }) + + const chart = $('#red-ui-workspace-chart') + chart.on('mousemove', function (evt) { + lastPosition[0] = evt.clientX + lastPosition[1] = evt.clientY + publishLocation() + }) + chart.on('scroll', function (evt) { + publishLocation() + }) + chart.on('mouseenter', function () { + isInWorkspace = true + publishLocation() + }) + chart.on('mouseleave', function () { + isInWorkspace = false + publishLocation() + }) } } diff --git a/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js b/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js index 08cb0d5a1..85a90e977 100644 --- a/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js +++ b/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js @@ -110,7 +110,8 @@ module.exports = { const payload = { session: sessionId, workspace: opts.data.workspace, - node: opts.data.node + node: opts.data.node, + cursor: opts.data.cursor } runtime.events.emit('comms', { topic: 'multiplayer/location',