diff --git a/assets/css/app.css b/assets/css/app.css index 8382209a4..124815247 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -10,3 +10,4 @@ @import "./ansi.css"; @import "./js_interop.css"; @import "./tooltips.css"; +@import "./editor.css"; diff --git a/assets/css/editor.css b/assets/css/editor.css new file mode 100644 index 000000000..9a18bd1fc --- /dev/null +++ b/assets/css/editor.css @@ -0,0 +1,54 @@ +/* Monaco overrides */ + +/* Add some spacing to code snippets in completion suggestions */ +div.suggest-details-container div.monaco-tokenized-source { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +/* Monaco cursor widget */ + +.monaco-cursor-widget-container { + pointer-events: none; + z-index: 100; +} + +.monaco-cursor-widget-container .monaco-cursor-widget-cursor { + pointer-events: initial; + width: 2px; +} + +.monaco-cursor-widget-container .monaco-cursor-widget-label { + pointer-events: initial; + transform: translateY(-200%); + white-space: nowrap; + padding: 1px 8px; + font-size: 12px; + color: #f8fafc; + + visibility: hidden; + transition-property: visibility; + transition-duration: 0s; + transition-delay: 1.5s; +} + +.monaco-cursor-widget-container .monaco-cursor-widget-label:hover { + visibility: visible; +} + +.monaco-cursor-widget-container + .monaco-cursor-widget-cursor:hover + + .monaco-cursor-widget-label { + visibility: visible; + transition-delay: 0s; +} + +/* When in the first line, we want to display cursor and label in the same line */ +.monaco-cursor-widget-container.inline { + display: flex; +} + +.monaco-cursor-widget-container.inline .monaco-cursor-widget-label { + margin-left: 2px; + transform: none; +} diff --git a/assets/css/global.css b/assets/css/global.css index 117a4e14d..b8d5a8aa5 100644 --- a/assets/css/global.css +++ b/assets/css/global.css @@ -8,26 +8,3 @@ body { font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; } - -/* nprogress overrides (https://github.com/rstacruz/nprogress#customization) */ - -#nprogress .bar { - background: #3e64ff !important; -} - -#nprogress .peg { - box-shadow: 0 0 10px #3e64ff, 0 0 5px #3e64ff; -} - -#nprogress .spinner-icon { - border-top-color: #3e64ff; - border-left-color: #3e64ff; -} - -/* Monaco overrides */ - -/* Add some spacing to code snippets in completion suggestions */ -div.suggest-details-container div.monaco-tokenized-source { - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index f94e64c91..86ff665f5 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -60,7 +60,7 @@ solely client-side operations. @apply opacity-100 pointer-events-auto; } -[data-element="section-list-item"][data-js-is-viewed] { +[data-element="sections-list-item"][data-js-is-viewed] { @apply text-gray-900; } @@ -83,8 +83,8 @@ solely client-side operations. @apply hidden; } -[data-element="session"]:not([data-js-side-panel-content="users-list"]) - [data-element="users-list"] { +[data-element="session"]:not([data-js-side-panel-content="clients-list"]) + [data-element="clients-list"] { @apply hidden; } @@ -93,8 +93,8 @@ solely client-side operations. @apply text-gray-50 bg-gray-700; } -[data-element="session"][data-js-side-panel-content="users-list"] - [data-element="users-list-toggle"] { +[data-element="session"][data-js-side-panel-content="clients-list"] + [data-element="clients-list-toggle"] { @apply text-gray-50 bg-gray-700; } @@ -103,3 +103,12 @@ solely client-side operations. + [data-element="section-actions"] { @apply hidden; } + +[data-element="clients-list-item"]:not([data-js-followed]) + [data-meta="unfollow"] { + @apply hidden; +} + +[data-element="clients-list-item"][data-js-followed] [data-meta="follow"] { + @apply hidden; +} diff --git a/assets/js/cell/index.js b/assets/js/cell/index.js index e887dc7dd..ff2e9baac 100644 --- a/assets/js/cell/index.js +++ b/assets/js/cell/index.js @@ -4,6 +4,7 @@ import Markdown from "./markdown"; import { globalPubSub } from "../lib/pub_sub"; import { smoothlyScrollToElement } from "../lib/utils"; import scrollIntoView from "scroll-into-view-if-needed"; +import monaco from "./live_editor/monaco"; /** * A hook managing a single cell. @@ -65,6 +66,8 @@ const Cell = { // If the element is being scrolled to, focus interrupts it, // so ensure the scrolling continues. smoothlyScrollToElement(this.el); + + broadcastSelection(this); } this.state.liveEditor.onBlur(() => { @@ -75,17 +78,25 @@ const Cell = { this.state.liveEditor.focus(); } }); + + this.state.liveEditor.onCursorSelectionChange((selection) => { + broadcastSelection(this, selection); + }); }); - this.handleSessionEvent = (event) => handleSessionEvent(this, event); - globalPubSub.subscribe("session", this.handleSessionEvent); + this._unsubscribeFromCellsEvents = globalPubSub.subscribe( + "cells", + (event) => { + handleCellsEvent(this, event); + } + ); }, destroyed() { - globalPubSub.unsubscribe("session", this.handleSessionEvent); + this._unsubscribeFromCellsEvents(); if (this.state.liveEditor) { - this.state.liveEditor.destroy(); + this.state.liveEditor.dispose(); } }, @@ -103,9 +114,9 @@ function getProps(hook) { } /** - * Handles client-side session event. + * Handles client-side cells event. */ -function handleSessionEvent(hook, event) { +function handleCellsEvent(hook, event) { if (event.type === "cell_focused") { handleCellFocused(hook, event.cellId); } else if (event.type === "insert_mode_changed") { @@ -114,6 +125,8 @@ function handleSessionEvent(hook, event) { handleCellMoved(hook, event.cellId); } else if (event.type === "cell_upload") { handleCellUpload(hook, event.cellId, event.url); + } else if (event.type === "location_report") { + handleLocationReport(hook, event.client, event.report); } } @@ -147,6 +160,8 @@ function handleInsertModeChanged(hook, insertMode) { block: "center", }); }, 0); + + broadcastSelection(hook); } else { hook.state.liveEditor.blur(); } @@ -167,4 +182,25 @@ function handleCellUpload(hook, cellId, url) { } } +function handleLocationReport(hook, client, report) { + if (hook.props.cellId === report.cellId && report.selection) { + hook.state.liveEditor.updateUserSelection(client, report.selection); + } else { + hook.state.liveEditor.removeUserSelection(client); + } +} + +function broadcastSelection(hook, selection = null) { + selection = selection || hook.state.liveEditor.editor.getSelection(); + + // Report new selection only if this cell is in insert mode + if (hook.state.isFocused && hook.state.insertMode) { + globalPubSub.broadcast("session", { + type: "cursor_selection_changed", + cellId: hook.props.cellId, + selection, + }); + } +} + export default Cell; diff --git a/assets/js/cell/live_editor.js b/assets/js/cell/live_editor.js index 70901d3d6..734939174 100644 --- a/assets/js/cell/live_editor.js +++ b/assets/js/cell/live_editor.js @@ -2,6 +2,7 @@ import monaco from "./live_editor/monaco"; import EditorClient from "./live_editor/editor_client"; import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter"; import HookServerAdapter from "./live_editor/hook_server_adapter"; +import RemoteUser from "./live_editor/remote_user"; /** * Mounts cell source editor with real-time collaboration mechanism. @@ -15,6 +16,8 @@ class LiveEditor { this.source = source; this._onChange = null; this._onBlur = null; + this._onCursorSelectionChange = null; + this._remoteUserByClientPid = {}; this.__mountEditor(); @@ -38,6 +41,11 @@ class LiveEditor { this.editor.onDidBlurEditorWidget(() => { this._onBlur && this._onBlur(); }); + + this.editor.onDidChangeCursorSelection((event) => { + this._onCursorSelectionChange && + this._onCursorSelectionChange(event.selection); + }); } /** @@ -47,6 +55,13 @@ class LiveEditor { this._onChange = callback; } + /** + * Registers a callback called with a new cursor selection whenever it changes. + */ + onCursorSelectionChange(callback) { + this._onCursorSelectionChange = callback; + } + /** * Registers a callback called whenever the editor loses focus. */ @@ -74,7 +89,7 @@ class LiveEditor { /** * Performs necessary cleanup actions. */ - destroy() { + dispose() { // Explicitly destroy the editor instance and its text model. this.editor.dispose(); @@ -85,6 +100,32 @@ class LiveEditor { } } + /** + * Either adds or moves remote user cursor to the new position. + */ + updateUserSelection(client, selection) { + if (this._remoteUserByClientPid[client.pid]) { + this._remoteUserByClientPid[client.pid].update(selection); + } else { + this._remoteUserByClientPid[client.pid] = new RemoteUser( + this.editor, + selection, + client.hex_color, + client.name + ); + } + } + + /** + * Removes remote user cursor. + */ + removeUserSelection(client) { + if (this._remoteUserByClientPid[client.pid]) { + this._remoteUserByClientPid[client.pid].dispose(); + delete this._remoteUserByClientPid[client.pid]; + } + } + __mountEditor() { this.editor = monaco.editor.create(this.container, { language: this.type, diff --git a/assets/js/cell/live_editor/remote_user.js b/assets/js/cell/live_editor/remote_user.js new file mode 100644 index 000000000..1de671502 --- /dev/null +++ b/assets/js/cell/live_editor/remote_user.js @@ -0,0 +1,168 @@ +import monaco from "./monaco"; +import { randomId } from "../../lib/utils"; + +/** + * Remote user visual indicators within the editor. + * + * Consists of a cursor widget and a selection highlight. + * Both elements have the user's hex color of choice. + */ +export default class RemoteUser { + constructor(editor, selection, hexColor, label) { + this._cursorWidget = new CursorWidget( + editor, + selection.getPosition(), + hexColor, + label + ); + + this._selectionDecoration = new SelectionDecoration( + editor, + selection, + hexColor + ); + } + + /** + * Updates indicators to match the given selection. + */ + update(selection) { + this._cursorWidget.update(selection.getPosition()); + this._selectionDecoration.update(selection); + } + + /** + * Performs necessary cleanup actions. + */ + dispose() { + this._cursorWidget.dispose(); + this._selectionDecoration.dispose(); + } +} + +class CursorWidget { + constructor(editor, position, hexColor, label) { + this._id = randomId(); + this._editor = editor; + this._position = position; + this._isPositionValid = this.__checkPositionValidity(position); + + this.__buildDomNode(hexColor, label); + + this._editor.addContentWidget(this); + + this._onDidChangeModelContentDisposable = this._editor.onDidChangeModelContent( + (event) => { + // We may receive new cursor position before content update, + // and the position may be invalid (e.g. column 10, even though the line has currently length 9). + // If that's the case then we want to update the cursor once the content is updated. + if (!this._isPositionValid) { + this.update(this._position); + } + } + ); + } + + getId() { + return this._id; + } + + getPosition() { + return { + position: this._position, + preference: [monaco.editor.ContentWidgetPositionPreference.EXACT], + }; + } + + update(position) { + this._position = position; + this._isPositionValid = this.__checkPositionValidity(position); + this.__updateDomNode(); + this._editor.layoutContentWidget(this); + } + + getDomNode() { + return this._domNode; + } + + dispose() { + this._editor.removeContentWidget(this); + this._onDidChangeModelContentDisposable.dispose(); + } + + __checkPositionValidity(position) { + const validPosition = this._editor.getModel().validatePosition(position); + return position.equals(validPosition); + } + + __buildDomNode(hexColor, label) { + const lineHeight = this._editor.getOption( + monaco.editor.EditorOption.lineHeight + ); + + const node = document.createElement("div"); + node.classList.add("monaco-cursor-widget-container"); + + const cursorNode = document.createElement("div"); + cursorNode.classList.add("monaco-cursor-widget-cursor"); + cursorNode.style.background = hexColor; + cursorNode.style.height = `${lineHeight}px`; + + const labelNode = document.createElement("div"); + labelNode.classList.add("monaco-cursor-widget-label"); + labelNode.style.height = `${lineHeight}px`; + labelNode.innerText = label; + labelNode.style.background = hexColor; + + node.appendChild(cursorNode); + node.appendChild(labelNode); + + this._domNode = node; + this.__updateDomNode(); + } + + __updateDomNode() { + const isFirstLine = this._position.lineNumber === 1; + this._domNode.classList.toggle("inline", isFirstLine); + } +} + +class SelectionDecoration { + constructor(editor, selection, hexColor) { + this._editor = editor; + this._decorations = []; + + // Dynamically create CSS class for the given hex color + + this._className = `user-selection-${hexColor.replace("#", "")}`; + + this._styleElement = document.createElement("style"); + this._styleElement.innerHTML = ` + .${this._className} { + background-color: ${hexColor}30; + } + `; + document.body.appendChild(this._styleElement); + + this.update(selection); + } + + update(selection) { + const newDecorations = [ + { + range: selection, + options: { className: this._className }, + }, + ]; + + this._decorations = this._editor.deltaDecorations( + this._decorations, + newDecorations + ); + } + + dispose() { + this._editor.deltaDecorations(this._decorations, []); + this._styleElement.remove(); + } +} diff --git a/assets/js/lib/pub_sub.js b/assets/js/lib/pub_sub.js index 8ced346a0..4a0f8f178 100644 --- a/assets/js/lib/pub_sub.js +++ b/assets/js/lib/pub_sub.js @@ -11,6 +11,9 @@ export default class PubSub { * * Subsequent calls to `broadcast` with this topic * will result in this function being called. + * + * Returns a function that unsubscribes + * as a shorthand for `unsubscribe`. */ subscribe(topic, callback) { if (!Array.isArray(this.subscribersByTopic[topic])) { @@ -18,6 +21,10 @@ export default class PubSub { } this.subscribersByTopic[topic].push(callback); + + return () => { + this.unsubscribe(topic, callback); + }; } /** diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js index 3e1ea2fb6..a1190d334 100644 --- a/assets/js/lib/utils.js +++ b/assets/js/lib/utils.js @@ -63,3 +63,13 @@ export function encodeBase64(string) { export function decodeBase64(binary) { return decodeURIComponent(escape(atob(binary))); } + +/** + * Generates a random string. + */ +export function randomId() { + const array = new Uint8Array(24); + crypto.getRandomValues(array); + const byteString = String.fromCharCode(...array); + return btoa(byteString); +} diff --git a/assets/js/session/index.js b/assets/js/session/index.js index 6c869eb1e..f9e2e27f3 100644 --- a/assets/js/session/index.js +++ b/assets/js/session/index.js @@ -7,11 +7,47 @@ import { } from "../lib/utils"; import KeyBuffer from "./key_buffer"; import { globalPubSub } from "../lib/pub_sub"; +import monaco from "../cell/live_editor/monaco"; /** * A hook managing the whole session. * - * Handles keybindings, focus changes and insert mode changes. + * Serves as a coordinator handling all the global session events. + * Note that each cell has its own hook, so that LV keeps track + * of cells being added/removed from the DOM. We do however need + * to communicate between this global hook and cells and for + * that we use a simple local pubsub that the hooks subscribe to. + * + * ## Shortcuts + * + * This hook registers session shortcut handlers, + * see `LivebookWeb.SessionLive.ShortcutsComponent` + * for the complete list of available shortcuts. + * + * ## Navigation + * + * This hook handles focusing cells and moving the focus around, + * this is done purely on the client side because it is event-intensive + * and specific to this client only. The UI changes are handled by + * setting `data-js-*` attributes and using CSS accordingly (see assets/css/js_interop.css). + * Navigation changes are also broadcasted to all cell hooks via PubSub. + * + * ## Location tracking and following + * + * Location describes where the given client is within + * the notebook (in which cell, and where specifically in that cell). + * When multiple clients are connected, they report own location to + * each other whenever it changes. We then each the location to show + * cursor and selection indicators. + * + * Additionally the current user may follow another client from the clients list. + * In such case, whenever a new location comes from that client we move there automatically + * (i.e. we focus the same cells to effectively mimic how the followed client moves around). + * + * Initially we load basic information about connected clients using `"session_init"` + * and then update this information whenever clients join/leave/update. + * This way location reports include only client pid, as we already have + * the necessary hex_color/name locally. */ const Session = { mounted() { @@ -21,8 +57,19 @@ const Session = { focusedCellType: null, insertMode: false, keyBuffer: new KeyBuffer(), + clientsMap: {}, + lastLocationReportByClientPid: {}, + followedClientPid: null, }; + // Load initial data + + this.pushEvent("session_init", {}, ({ clients }) => { + clients.forEach((client) => { + this.state.clientsMap[client.pid] = client; + }); + }); + // DOM events this.handleDocumentKeyDown = (event) => { @@ -43,16 +90,20 @@ const Session = { document.addEventListener("dblclick", this.handleDocumentDoubleClick); - getSectionList().addEventListener("click", (event) => { - handleSectionListClick(this, event); + getSectionsList().addEventListener("click", (event) => { + handleSectionsListClick(this, event); + }); + + getClientsList().addEventListener("click", (event) => { + handleClientsListClick(this, event); }); getSectionsListToggle().addEventListener("click", (event) => { toggleSectionsList(this); }); - getUsersListToggle().addEventListener("click", (event) => { - toggleUsersList(this); + getClientsListToggle().addEventListener("click", (event) => { + toggleClientsList(this); }); getNotebook().addEventListener("scroll", (event) => { @@ -89,34 +140,88 @@ const Session = { } ); - this.handleEvent("cell_moved", ({ cell_id: cellId }) => { - handleCellMoved(this, cellId); + this.handleEvent("cell_moved", ({ cell_id }) => { + handleCellMoved(this, cell_id); }); - this.handleEvent("section_inserted", ({ section_id: sectionId }) => { - handleSectionInserted(this, sectionId); + this.handleEvent("section_inserted", ({ section_id }) => { + handleSectionInserted(this, section_id); }); - this.handleEvent("section_deleted", ({ section_id: sectionId }) => { - handleSectionDeleted(this, sectionId); + this.handleEvent("section_deleted", ({ section_id }) => { + handleSectionDeleted(this, section_id); }); - this.handleEvent("section_moved", ({ section_id: sectionId }) => { - handleSectionMoved(this, sectionId); + this.handleEvent("section_moved", ({ section_id }) => { + handleSectionMoved(this, section_id); }); - this.handleEvent("cell_upload", ({ cell_id: cellId, url }) => { - handleCellUpload(this, cellId, url); + this.handleEvent("cell_upload", ({ cell_id, url }) => { + handleCellUpload(this, cell_id, url); }); + + this.handleEvent("client_joined", ({ client }) => { + handleClientJoined(this, client); + }); + + this.handleEvent("client_left", ({ client_pid }) => { + handleClientLeft(this, client_pid); + }); + + this.handleEvent("clients_updated", ({ clients }) => { + handleClientsUpdated(this, clients); + }); + + this.handleEvent( + "location_report", + ({ client_pid, cell_id, selection }) => { + const report = { + cellId: cell_id, + selection: decodeSelection(selection), + }; + + handleLocationReport(this, client_pid, report); + } + ); + + this._unsubscribeFromSessionEvents = globalPubSub.subscribe( + "session", + (event) => { + handleSessionEvent(this, event); + } + ); }, destroyed() { + this._unsubscribeFromSessionEvents(); + document.removeEventListener("keydown", this.handleDocumentKeyDown); document.removeEventListener("mousedown", this.handleDocumentMouseDown); document.removeEventListener("dblclick", this.handleDocumentDoubleClick); }, }; +/** + * Data of a specific LV client. + * + * @typedef Client + * @type {Object} + * @property {String} pid + * @property {String} hex_color + * @property {String} name + */ + +/** + * A report of the current location sent by one of the other clients. + * + * @typedef LocationReport + * @type {Object} + * @property {String|null} cellId + * @property {monaco.Selection|null} selection + */ + +// DOM event handlers + /** * Handles session keybindings. * @@ -185,7 +290,7 @@ function handleDocumentKeyDown(hook, event) { } else if (keyBuffer.tryMatch(["s", "s"])) { toggleSectionsList(hook); } else if (keyBuffer.tryMatch(["s", "u"])) { - toggleUsersList(hook); + toggleClientsList(hook); } else if (keyBuffer.tryMatch(["s", "r"])) { showNotebookRuntimeSettings(hook); } else if (keyBuffer.tryMatch(["e", "x"])) { @@ -274,9 +379,9 @@ function handleDocumentDoubleClick(hook, event) { /** * Handles section link clicks in the section list. */ -function handleSectionListClick(hook, event) { +function handleSectionsListClick(hook, event) { const sectionButton = event.target.closest( - `[data-element="section-list-item"]` + `[data-element="sections-list-item"]` ); if (sectionButton) { const sectionId = sectionButton.getAttribute("data-section-id"); @@ -285,6 +390,61 @@ function handleSectionListClick(hook, event) { } } +/** + * Handles client link clicks in the clients list. + */ +function handleClientsListClick(hook, event) { + const clientListItem = event.target.closest( + `[data-element="clients-list-item"]` + ); + + if (clientListItem) { + const clientPid = clientListItem.getAttribute("data-client-pid"); + + const clientLink = event.target.closest(`[data-element="client-link"]`); + if (clientLink) { + handleClientLinkClick(hook, clientPid); + } + + const clientFollowToggle = event.target.closest( + `[data-element="client-follow-toggle"]` + ); + if (clientFollowToggle) { + handleClientFollowToggleClick(hook, clientPid, clientListItem); + } + } +} + +function handleClientLinkClick(hook, clientPid) { + mirrorClientFocus(hook, clientPid); +} + +function handleClientFollowToggleClick(hook, clientPid, clientListItem) { + const followedClientListItem = document.querySelector( + `[data-element="clients-list-item"][data-js-followed]` + ); + + if (followedClientListItem) { + followedClientListItem.removeAttribute("data-js-followed"); + } + + if (clientPid === hook.state.followedClientPid) { + hook.state.followedClientPid = null; + } else { + clientListItem.setAttribute("data-js-followed", "true"); + hook.state.followedClientPid = clientPid; + mirrorClientFocus(hook, clientPid); + } +} + +function mirrorClientFocus(hook, clientPid) { + const locationReport = hook.state.lastLocationReportByClientPid[clientPid]; + + if (locationReport && locationReport.cellId) { + setFocusedCell(hook, locationReport.cellId); + } +} + /** * Handles button clicks within cell indicators section. */ @@ -315,7 +475,7 @@ function focusCellFromUrl(hook) { */ function updateSectionListHighlight() { const currentListItem = document.querySelector( - `[data-element="section-list-item"][data-js-is-viewed]` + `[data-element="sections-list-item"][data-js-is-viewed]` ); if (currentListItem) { @@ -334,7 +494,7 @@ function updateSectionListHighlight() { if (viewedSection) { const sectionId = viewedSection.getAttribute("data-section-id"); const listItem = document.querySelector( - `[data-element="section-list-item"][data-section-id="${sectionId}"]` + `[data-element="sections-list-item"][data-section-id="${sectionId}"]` ); listItem.setAttribute("data-js-is-viewed", "true"); } @@ -350,11 +510,11 @@ function toggleSectionsList(hook) { } } -function toggleUsersList(hook) { - if (hook.el.getAttribute("data-js-side-panel-content") === "users-list") { +function toggleClientsList(hook) { + if (hook.el.getAttribute("data-js-side-panel-content") === "clients-list") { hook.el.removeAttribute("data-js-side-panel-content"); } else { - hook.el.setAttribute("data-js-side-panel-content", "users-list"); + hook.el.setAttribute("data-js-side-panel-content", "clients-list"); } } @@ -499,7 +659,7 @@ function setFocusedCell(hook, cellId) { hook.state.focusedSectionId = null; } - globalPubSub.broadcast("session", { type: "cell_focused", cellId }); + globalPubSub.broadcast("cells", { type: "cell_focused", cellId }); setInsertMode(hook, false); } @@ -511,9 +671,14 @@ function setInsertMode(hook, insertModeEnabled) { hook.el.setAttribute("data-js-insert-mode", "true"); } else { hook.el.removeAttribute("data-js-insert-mode"); + + sendLocationReport(hook, { + cellId: hook.state.focusedCellId, + selection: null, + }); } - globalPubSub.broadcast("session", { + globalPubSub.broadcast("cells", { type: "insert_mode_changed", enabled: insertModeEnabled, }); @@ -534,7 +699,7 @@ function handleCellDeleted(hook, cellId, siblingCellId) { function handleCellMoved(hook, cellId) { if (hook.state.focusedCellId === cellId) { - globalPubSub.broadcast("session", { type: "cell_moved", cellId }); + globalPubSub.broadcast("cells", { type: "cell_moved", cellId }); // The cell may have moved to another section, so update this information. hook.state.focusedSectionId = getSectionIdByCellId( @@ -575,7 +740,48 @@ function handleCellUpload(hook, cellId, url) { setInsertMode(hook, true); } - globalPubSub.broadcast("session", { type: "cell_upload", cellId, url }); + globalPubSub.broadcast("cells", { type: "cell_upload", cellId, url }); +} + +function handleClientJoined(hook, client) { + hook.state.clientsMap[client.pid] = client; +} + +function handleClientLeft(hook, clientPid) { + const client = hook.state.clientsMap[clientPid]; + + if (client) { + delete hook.state.clientsMap[clientPid]; + + broadcastLocationReport(client, { cellId: null, selection: null }); + + if (client.pid === hook.state.followedClientPid) { + hook.state.followedClientPid = null; + } + } +} + +function handleClientsUpdated(hook, updatedClients) { + updatedClients.forEach((client) => { + hook.state.clientsMap[client.pid] = client; + }); +} + +function handleLocationReport(hook, clientPid, report) { + const client = hook.state.clientsMap[clientPid]; + + hook.state.lastLocationReportByClientPid[clientPid] = report; + + if (client) { + broadcastLocationReport(client, report); + + if ( + client.pid === hook.state.followedClientPid && + report.cellId !== hook.state.focusedCellId + ) { + setFocusedCell(hook, report.cellId); + } + } } function focusNotebookNameIfNew() { @@ -588,6 +794,72 @@ function focusNotebookNameIfNew() { } } +// Session event handlers + +function handleSessionEvent(hook, event) { + if (event.type === "cursor_selection_changed") { + sendLocationReport(hook, { + cellId: event.cellId, + selection: event.selection, + }); + } +} + +/** + * Broadcast new location report coming from the server to all the cells. + */ +function broadcastLocationReport(client, report) { + globalPubSub.broadcast("cells", { + type: "location_report", + client, + report, + }); +} + +/** + * Sends local location report to the server. + */ +function sendLocationReport(hook, report) { + const numberOfClients = Object.keys(hook.state.clientsMap).length; + + // Only send reports if there are other people to send to + if (numberOfClients > 1) { + hook.pushEvent("location_report", { + cell_id: report.cellId, + selection: encodeSelection(report.selection), + }); + } +} + +function encodeSelection(selection) { + if (selection === null) return null; + + return [ + selection.selectionStartLineNumber, + selection.selectionStartColumn, + selection.positionLineNumber, + selection.positionColumn, + ]; +} + +function decodeSelection(encoded) { + if (encoded === null) return null; + + const [ + selectionStartLineNumber, + selectionStartColumn, + positionLineNumber, + positionColumn, + ] = encoded; + + return new monaco.Selection( + selectionStartLineNumber, + selectionStartColumn, + positionLineNumber, + positionColumn + ); +} + // Helpers function nearbyCellId(cellId, offset) { @@ -645,8 +917,12 @@ function getSectionById(sectionId) { ); } -function getSectionList() { - return document.querySelector(`[data-element="section-list"]`); +function getSectionsList() { + return document.querySelector(`[data-element="sections-list"]`); +} + +function getClientsList() { + return document.querySelector(`[data-element="clients-list"]`); } function getCellIndicators() { @@ -661,8 +937,8 @@ function getSectionsListToggle() { return document.querySelector(`[data-element="sections-list-toggle"]`); } -function getUsersListToggle() { - return document.querySelector(`[data-element="users-list-toggle"]`); +function getClientsListToggle() { + return document.querySelector(`[data-element="clients-list-toggle"]`); } function cancelEvent(event) { diff --git a/assets/test/lib/pub_sub.test.js b/assets/test/lib/pub_sub.test.js index b4e8cd30f..6188b3a00 100644 --- a/assets/test/lib/pub_sub.test.js +++ b/assets/test/lib/pub_sub.test.js @@ -14,6 +14,17 @@ describe("PubSub", () => { expect(callback2).not.toHaveBeenCalled(); }); + test("subscribe returns a function that unsubscribes", () => { + const pubsub = new PubSub(); + const callback1 = jest.fn(); + + const unsubscribe = pubsub.subscribe("topic1", callback1); + unsubscribe(); + pubsub.broadcast("topic1", {}); + + expect(callback1).not.toHaveBeenCalled(); + }); + test("unsubscribed callback is not called on the specified topic", () => { const pubsub = new PubSub(); const callback1 = jest.fn(); diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 942d5a24b..eb5e8b433 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -32,6 +32,7 @@ defmodule LivebookWeb.SessionLive do session_id: session_id, session_pid: session_pid, current_user: current_user, + self: self(), data_view: data_to_view(data) ) |> assign_private(data: data) @@ -79,7 +80,7 @@ defmodule LivebookWeb.SessionLive do - @@ -110,10 +111,10 @@ defmodule LivebookWeb.SessionLive do

Sections

-
+
<%= for section_item <- @data_view.sections_items do %> @@ -126,21 +127,42 @@ defmodule LivebookWeb.SessionLive do
-
+

Users

- <%= length(@data_view.users) %> connected + <%= length(@data_view.clients) %> connected

-
- <%= for user <- @data_view.users do %> -
- <%= render_user_avatar(user, class: "h-7 w-7 flex-shrink-0", text_class: "text-xs") %> - - <%= user.name || "Anonymous" %> - +
+ <%= for {client_pid, user} <- @data_view.clients do %> +
+ + <%= if client_pid != @self do %> + + + + + + + <% end %>
<% end %>
@@ -276,6 +298,19 @@ defmodule LivebookWeb.SessionLive do end @impl true + def handle_event("session_init", _params, socket) do + data = socket.private.data + + payload = %{ + clients: + Enum.map(data.clients_map, fn {client_pid, user_id} -> + client_info(client_pid, data.users_map[user_id]) + end) + } + + {:reply, payload, socket} + end + def handle_event("cell_init", %{"cell_id" => cell_id}, socket) do data = socket.private.data @@ -490,6 +525,17 @@ defmodule LivebookWeb.SessionLive do create_session(socket, notebook: notebook, copy_images_from: images_dir) end + def handle_event("location_report", report, socket) do + Phoenix.PubSub.broadcast_from( + Livebook.PubSub, + self(), + "sessions:#{socket.assigns.session_id}", + {:location_report, self(), report} + ) + + {:noreply, socket} + end + defp create_session(socket, opts) do case SessionSupervisor.create_session(opts) do {:ok, id} -> @@ -549,8 +595,30 @@ defmodule LivebookWeb.SessionLive do {:noreply, assign(socket, :current_user, user)} end + def handle_info({:location_report, client_pid, report}, socket) do + report = Map.put(report, :client_pid, inspect(client_pid)) + {:noreply, push_event(socket, "location_report", report)} + end + def handle_info(_message, socket), do: {:noreply, socket} + defp after_operation(socket, _prev_socket, {:client_join, client_pid, user}) do + push_event(socket, "client_joined", %{client: client_info(client_pid, user)}) + end + + defp after_operation(socket, _prev_socket, {:client_leave, client_pid}) do + push_event(socket, "client_left", %{client_pid: inspect(client_pid)}) + end + + defp after_operation(socket, _prev_socket, {:update_user, _client_pid, user}) do + updated_clients = + socket.private.data.clients_map + |> Enum.filter(fn {_client_pid, user_id} -> user_id == user.id end) + |> Enum.map(fn {client_pid, _user_id} -> client_info(client_pid, user) end) + + push_event(socket, "clients_updated", %{clients: updated_clients}) + end + defp after_operation(socket, _prev_socket, {:insert_section, client_pid, _index, section_id}) do if client_pid == self() do push_event(socket, "section_inserted", %{section_id: section_id}) @@ -620,6 +688,10 @@ defmodule LivebookWeb.SessionLive do defp handle_action(socket, _action), do: socket + defp client_info(pid, user) do + %{pid: inspect(pid), hex_color: user.hex_color, name: user.name || "Anonymous"} + end + defp normalize_name(name) do name |> String.trim() @@ -660,11 +732,10 @@ defmodule LivebookWeb.SessionLive do for section <- data.notebook.sections do %{id: section.id, name: section.name} end, - users: + clients: data.clients_map - |> Map.values() - |> Enum.map(&data.users_map[&1]) - |> Enum.sort_by(& &1.name), + |> Enum.map(fn {client_pid, user_id} -> {client_pid, data.users_map[user_id]} end) + |> Enum.sort_by(fn {_client_pid, user} -> user.name end), section_views: Enum.map(data.notebook.sections, §ion_to_view(&1, data)) } end