Implement user cursor and selection tracking (#260)

* Implement user cursor and selection tracking

* Separate jump-to-user and follow
This commit is contained in:
Jonatan Kłosko 2021-05-07 16:41:37 +02:00 committed by GitHub
parent c7887a57de
commit cd80bd7804
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 743 additions and 82 deletions

View file

@ -10,3 +10,4 @@
@import "./ansi.css";
@import "./js_interop.css";
@import "./tooltips.css";
@import "./editor.css";

54
assets/css/editor.css Normal file
View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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,

View file

@ -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();
}
}

View file

@ -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);
};
}
/**

View file

@ -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);
}

View file

@ -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) {

View file

@ -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();

View file

@ -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
</button>
</span>
<span class="tooltip right distant" aria-label="Connected users (su)">
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center" data-element="users-list-toggle">
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center" data-element="clients-list-toggle">
<%= remix_icon("group-fill") %>
</button>
</span>
@ -110,10 +111,10 @@ defmodule LivebookWeb.SessionLive do
<h3 class="font-semibold text-gray-800 text-lg">
Sections
</h3>
<div class="mt-4 flex flex-col space-y-4" data-element="section-list">
<div class="mt-4 flex flex-col space-y-4">
<%= for section_item <- @data_view.sections_items do %>
<button class="text-left hover:text-gray-900 text-gray-500"
data-element="section-list-item"
data-element="sections-list-item"
data-section-id="<%= section_item.id %>">
<%= section_item.name %>
</button>
@ -126,21 +127,42 @@ defmodule LivebookWeb.SessionLive do
</button>
</div>
</div>
<div data-element="users-list">
<div data-element="clients-list">
<div class="flex-grow flex flex-col">
<h3 class="font-semibold text-gray-800 text-lg">
Users
</h3>
<h4 class="font text-gray-500 text-sm my-1">
<%= length(@data_view.users) %> connected
<%= length(@data_view.clients) %> connected
</h4>
<div class="mt-4 flex flex-col space-y-4" data-element="section-list">
<%= for user <- @data_view.users do %>
<div class="flex space-x-2 items-center">
<%= render_user_avatar(user, class: "h-7 w-7 flex-shrink-0", text_class: "text-xs") %>
<span class="text-gray-500">
<%= user.name || "Anonymous" %>
</span>
<div class="mt-4 flex flex-col space-y-4">
<%= for {client_pid, user} <- @data_view.clients do %>
<div class="flex items-center justify-between space-x-2"
id="clients-list-item-<%= inspect(client_pid) %>"
data-element="clients-list-item"
data-client-pid="<%= inspect(client_pid) %>">
<button class="flex space-x-2 items-center text-gray-500 hover:text-gray-900 disabled:pointer-events-none"
<%= if client_pid == @self, do: "disabled" %>
data-element="client-link">
<%= render_user_avatar(user, class: "h-7 w-7 flex-shrink-0", text_class: "text-xs") %>
<span><%= user.name || "Anonymous" %></span>
</button>
<%= if client_pid != @self do %>
<span class="tooltip left" aria-label="Follow this user"
data-element="client-follow-toggle"
data-meta="follow">
<button class="icon-button">
<%= remix_icon("pushpin-line", class: "text-lg") %>
</button>
</span>
<span class="tooltip left" aria-label="Unfollow this user"
data-element="client-follow-toggle"
data-meta="unfollow">
<button class="icon-button">
<%= remix_icon("pushpin-fill", class: "text-lg") %>
</button>
</span>
<% end %>
</div>
<% end %>
</div>
@ -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, &section_to_view(&1, data))
}
end