mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-27 07:17:30 +08:00
Implement user cursor and selection tracking (#260)
* Implement user cursor and selection tracking * Separate jump-to-user and follow
This commit is contained in:
parent
c7887a57de
commit
cd80bd7804
12 changed files with 743 additions and 82 deletions
|
@ -10,3 +10,4 @@
|
|||
@import "./ansi.css";
|
||||
@import "./js_interop.css";
|
||||
@import "./tooltips.css";
|
||||
@import "./editor.css";
|
||||
|
|
54
assets/css/editor.css
Normal file
54
assets/css/editor.css
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
168
assets/js/cell/live_editor/remote_user.js
Normal file
168
assets/js/cell/live_editor/remote_user.js
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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, §ion_to_view(&1, data))
|
||||
}
|
||||
end
|
||||
|
|
Loading…
Add table
Reference in a new issue