mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-09 13:07:37 +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 "./ansi.css";
|
||||||
@import "./js_interop.css";
|
@import "./js_interop.css";
|
||||||
@import "./tooltips.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,
|
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
|
||||||
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
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;
|
@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;
|
@apply text-gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,8 +83,8 @@ solely client-side operations.
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-element="session"]:not([data-js-side-panel-content="users-list"])
|
[data-element="session"]:not([data-js-side-panel-content="clients-list"])
|
||||||
[data-element="users-list"] {
|
[data-element="clients-list"] {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,8 +93,8 @@ solely client-side operations.
|
||||||
@apply text-gray-50 bg-gray-700;
|
@apply text-gray-50 bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-element="session"][data-js-side-panel-content="users-list"]
|
[data-element="session"][data-js-side-panel-content="clients-list"]
|
||||||
[data-element="users-list-toggle"] {
|
[data-element="clients-list-toggle"] {
|
||||||
@apply text-gray-50 bg-gray-700;
|
@apply text-gray-50 bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,3 +103,12 @@ solely client-side operations.
|
||||||
+ [data-element="section-actions"] {
|
+ [data-element="section-actions"] {
|
||||||
@apply hidden;
|
@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 { globalPubSub } from "../lib/pub_sub";
|
||||||
import { smoothlyScrollToElement } from "../lib/utils";
|
import { smoothlyScrollToElement } from "../lib/utils";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
|
import monaco from "./live_editor/monaco";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hook managing a single cell.
|
* A hook managing a single cell.
|
||||||
|
@ -65,6 +66,8 @@ const Cell = {
|
||||||
// If the element is being scrolled to, focus interrupts it,
|
// If the element is being scrolled to, focus interrupts it,
|
||||||
// so ensure the scrolling continues.
|
// so ensure the scrolling continues.
|
||||||
smoothlyScrollToElement(this.el);
|
smoothlyScrollToElement(this.el);
|
||||||
|
|
||||||
|
broadcastSelection(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state.liveEditor.onBlur(() => {
|
this.state.liveEditor.onBlur(() => {
|
||||||
|
@ -75,17 +78,25 @@ const Cell = {
|
||||||
this.state.liveEditor.focus();
|
this.state.liveEditor.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.state.liveEditor.onCursorSelectionChange((selection) => {
|
||||||
|
broadcastSelection(this, selection);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handleSessionEvent = (event) => handleSessionEvent(this, event);
|
this._unsubscribeFromCellsEvents = globalPubSub.subscribe(
|
||||||
globalPubSub.subscribe("session", this.handleSessionEvent);
|
"cells",
|
||||||
|
(event) => {
|
||||||
|
handleCellsEvent(this, event);
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
destroyed() {
|
destroyed() {
|
||||||
globalPubSub.unsubscribe("session", this.handleSessionEvent);
|
this._unsubscribeFromCellsEvents();
|
||||||
|
|
||||||
if (this.state.liveEditor) {
|
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") {
|
if (event.type === "cell_focused") {
|
||||||
handleCellFocused(hook, event.cellId);
|
handleCellFocused(hook, event.cellId);
|
||||||
} else if (event.type === "insert_mode_changed") {
|
} else if (event.type === "insert_mode_changed") {
|
||||||
|
@ -114,6 +125,8 @@ function handleSessionEvent(hook, event) {
|
||||||
handleCellMoved(hook, event.cellId);
|
handleCellMoved(hook, event.cellId);
|
||||||
} else if (event.type === "cell_upload") {
|
} else if (event.type === "cell_upload") {
|
||||||
handleCellUpload(hook, event.cellId, event.url);
|
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",
|
block: "center",
|
||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
broadcastSelection(hook);
|
||||||
} else {
|
} else {
|
||||||
hook.state.liveEditor.blur();
|
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;
|
export default Cell;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import monaco from "./live_editor/monaco";
|
||||||
import EditorClient from "./live_editor/editor_client";
|
import EditorClient from "./live_editor/editor_client";
|
||||||
import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter";
|
import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter";
|
||||||
import HookServerAdapter from "./live_editor/hook_server_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.
|
* Mounts cell source editor with real-time collaboration mechanism.
|
||||||
|
@ -15,6 +16,8 @@ class LiveEditor {
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this._onChange = null;
|
this._onChange = null;
|
||||||
this._onBlur = null;
|
this._onBlur = null;
|
||||||
|
this._onCursorSelectionChange = null;
|
||||||
|
this._remoteUserByClientPid = {};
|
||||||
|
|
||||||
this.__mountEditor();
|
this.__mountEditor();
|
||||||
|
|
||||||
|
@ -38,6 +41,11 @@ class LiveEditor {
|
||||||
this.editor.onDidBlurEditorWidget(() => {
|
this.editor.onDidBlurEditorWidget(() => {
|
||||||
this._onBlur && this._onBlur();
|
this._onBlur && this._onBlur();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.editor.onDidChangeCursorSelection((event) => {
|
||||||
|
this._onCursorSelectionChange &&
|
||||||
|
this._onCursorSelectionChange(event.selection);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -47,6 +55,13 @@ class LiveEditor {
|
||||||
this._onChange = callback;
|
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.
|
* Registers a callback called whenever the editor loses focus.
|
||||||
*/
|
*/
|
||||||
|
@ -74,7 +89,7 @@ class LiveEditor {
|
||||||
/**
|
/**
|
||||||
* Performs necessary cleanup actions.
|
* Performs necessary cleanup actions.
|
||||||
*/
|
*/
|
||||||
destroy() {
|
dispose() {
|
||||||
// Explicitly destroy the editor instance and its text model.
|
// Explicitly destroy the editor instance and its text model.
|
||||||
this.editor.dispose();
|
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() {
|
__mountEditor() {
|
||||||
this.editor = monaco.editor.create(this.container, {
|
this.editor = monaco.editor.create(this.container, {
|
||||||
language: this.type,
|
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
|
* Subsequent calls to `broadcast` with this topic
|
||||||
* will result in this function being called.
|
* will result in this function being called.
|
||||||
|
*
|
||||||
|
* Returns a function that unsubscribes
|
||||||
|
* as a shorthand for `unsubscribe`.
|
||||||
*/
|
*/
|
||||||
subscribe(topic, callback) {
|
subscribe(topic, callback) {
|
||||||
if (!Array.isArray(this.subscribersByTopic[topic])) {
|
if (!Array.isArray(this.subscribersByTopic[topic])) {
|
||||||
|
@ -18,6 +21,10 @@ export default class PubSub {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.subscribersByTopic[topic].push(callback);
|
this.subscribersByTopic[topic].push(callback);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.unsubscribe(topic, callback);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -63,3 +63,13 @@ export function encodeBase64(string) {
|
||||||
export function decodeBase64(binary) {
|
export function decodeBase64(binary) {
|
||||||
return decodeURIComponent(escape(atob(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";
|
} from "../lib/utils";
|
||||||
import KeyBuffer from "./key_buffer";
|
import KeyBuffer from "./key_buffer";
|
||||||
import { globalPubSub } from "../lib/pub_sub";
|
import { globalPubSub } from "../lib/pub_sub";
|
||||||
|
import monaco from "../cell/live_editor/monaco";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hook managing the whole session.
|
* 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 = {
|
const Session = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -21,8 +57,19 @@ const Session = {
|
||||||
focusedCellType: null,
|
focusedCellType: null,
|
||||||
insertMode: false,
|
insertMode: false,
|
||||||
keyBuffer: new KeyBuffer(),
|
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
|
// DOM events
|
||||||
|
|
||||||
this.handleDocumentKeyDown = (event) => {
|
this.handleDocumentKeyDown = (event) => {
|
||||||
|
@ -43,16 +90,20 @@ const Session = {
|
||||||
|
|
||||||
document.addEventListener("dblclick", this.handleDocumentDoubleClick);
|
document.addEventListener("dblclick", this.handleDocumentDoubleClick);
|
||||||
|
|
||||||
getSectionList().addEventListener("click", (event) => {
|
getSectionsList().addEventListener("click", (event) => {
|
||||||
handleSectionListClick(this, event);
|
handleSectionsListClick(this, event);
|
||||||
|
});
|
||||||
|
|
||||||
|
getClientsList().addEventListener("click", (event) => {
|
||||||
|
handleClientsListClick(this, event);
|
||||||
});
|
});
|
||||||
|
|
||||||
getSectionsListToggle().addEventListener("click", (event) => {
|
getSectionsListToggle().addEventListener("click", (event) => {
|
||||||
toggleSectionsList(this);
|
toggleSectionsList(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
getUsersListToggle().addEventListener("click", (event) => {
|
getClientsListToggle().addEventListener("click", (event) => {
|
||||||
toggleUsersList(this);
|
toggleClientsList(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
getNotebook().addEventListener("scroll", (event) => {
|
getNotebook().addEventListener("scroll", (event) => {
|
||||||
|
@ -89,34 +140,88 @@ const Session = {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.handleEvent("cell_moved", ({ cell_id: cellId }) => {
|
this.handleEvent("cell_moved", ({ cell_id }) => {
|
||||||
handleCellMoved(this, cellId);
|
handleCellMoved(this, cell_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handleEvent("section_inserted", ({ section_id: sectionId }) => {
|
this.handleEvent("section_inserted", ({ section_id }) => {
|
||||||
handleSectionInserted(this, sectionId);
|
handleSectionInserted(this, section_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handleEvent("section_deleted", ({ section_id: sectionId }) => {
|
this.handleEvent("section_deleted", ({ section_id }) => {
|
||||||
handleSectionDeleted(this, sectionId);
|
handleSectionDeleted(this, section_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handleEvent("section_moved", ({ section_id: sectionId }) => {
|
this.handleEvent("section_moved", ({ section_id }) => {
|
||||||
handleSectionMoved(this, sectionId);
|
handleSectionMoved(this, section_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handleEvent("cell_upload", ({ cell_id: cellId, url }) => {
|
this.handleEvent("cell_upload", ({ cell_id, url }) => {
|
||||||
handleCellUpload(this, cellId, 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() {
|
destroyed() {
|
||||||
|
this._unsubscribeFromSessionEvents();
|
||||||
|
|
||||||
document.removeEventListener("keydown", this.handleDocumentKeyDown);
|
document.removeEventListener("keydown", this.handleDocumentKeyDown);
|
||||||
document.removeEventListener("mousedown", this.handleDocumentMouseDown);
|
document.removeEventListener("mousedown", this.handleDocumentMouseDown);
|
||||||
document.removeEventListener("dblclick", this.handleDocumentDoubleClick);
|
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.
|
* Handles session keybindings.
|
||||||
*
|
*
|
||||||
|
@ -185,7 +290,7 @@ function handleDocumentKeyDown(hook, event) {
|
||||||
} else if (keyBuffer.tryMatch(["s", "s"])) {
|
} else if (keyBuffer.tryMatch(["s", "s"])) {
|
||||||
toggleSectionsList(hook);
|
toggleSectionsList(hook);
|
||||||
} else if (keyBuffer.tryMatch(["s", "u"])) {
|
} else if (keyBuffer.tryMatch(["s", "u"])) {
|
||||||
toggleUsersList(hook);
|
toggleClientsList(hook);
|
||||||
} else if (keyBuffer.tryMatch(["s", "r"])) {
|
} else if (keyBuffer.tryMatch(["s", "r"])) {
|
||||||
showNotebookRuntimeSettings(hook);
|
showNotebookRuntimeSettings(hook);
|
||||||
} else if (keyBuffer.tryMatch(["e", "x"])) {
|
} else if (keyBuffer.tryMatch(["e", "x"])) {
|
||||||
|
@ -274,9 +379,9 @@ function handleDocumentDoubleClick(hook, event) {
|
||||||
/**
|
/**
|
||||||
* Handles section link clicks in the section list.
|
* Handles section link clicks in the section list.
|
||||||
*/
|
*/
|
||||||
function handleSectionListClick(hook, event) {
|
function handleSectionsListClick(hook, event) {
|
||||||
const sectionButton = event.target.closest(
|
const sectionButton = event.target.closest(
|
||||||
`[data-element="section-list-item"]`
|
`[data-element="sections-list-item"]`
|
||||||
);
|
);
|
||||||
if (sectionButton) {
|
if (sectionButton) {
|
||||||
const sectionId = sectionButton.getAttribute("data-section-id");
|
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.
|
* Handles button clicks within cell indicators section.
|
||||||
*/
|
*/
|
||||||
|
@ -315,7 +475,7 @@ function focusCellFromUrl(hook) {
|
||||||
*/
|
*/
|
||||||
function updateSectionListHighlight() {
|
function updateSectionListHighlight() {
|
||||||
const currentListItem = document.querySelector(
|
const currentListItem = document.querySelector(
|
||||||
`[data-element="section-list-item"][data-js-is-viewed]`
|
`[data-element="sections-list-item"][data-js-is-viewed]`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentListItem) {
|
if (currentListItem) {
|
||||||
|
@ -334,7 +494,7 @@ function updateSectionListHighlight() {
|
||||||
if (viewedSection) {
|
if (viewedSection) {
|
||||||
const sectionId = viewedSection.getAttribute("data-section-id");
|
const sectionId = viewedSection.getAttribute("data-section-id");
|
||||||
const listItem = document.querySelector(
|
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");
|
listItem.setAttribute("data-js-is-viewed", "true");
|
||||||
}
|
}
|
||||||
|
@ -350,11 +510,11 @@ function toggleSectionsList(hook) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUsersList(hook) {
|
function toggleClientsList(hook) {
|
||||||
if (hook.el.getAttribute("data-js-side-panel-content") === "users-list") {
|
if (hook.el.getAttribute("data-js-side-panel-content") === "clients-list") {
|
||||||
hook.el.removeAttribute("data-js-side-panel-content");
|
hook.el.removeAttribute("data-js-side-panel-content");
|
||||||
} else {
|
} 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;
|
hook.state.focusedSectionId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
globalPubSub.broadcast("session", { type: "cell_focused", cellId });
|
globalPubSub.broadcast("cells", { type: "cell_focused", cellId });
|
||||||
|
|
||||||
setInsertMode(hook, false);
|
setInsertMode(hook, false);
|
||||||
}
|
}
|
||||||
|
@ -511,9 +671,14 @@ function setInsertMode(hook, insertModeEnabled) {
|
||||||
hook.el.setAttribute("data-js-insert-mode", "true");
|
hook.el.setAttribute("data-js-insert-mode", "true");
|
||||||
} else {
|
} else {
|
||||||
hook.el.removeAttribute("data-js-insert-mode");
|
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",
|
type: "insert_mode_changed",
|
||||||
enabled: insertModeEnabled,
|
enabled: insertModeEnabled,
|
||||||
});
|
});
|
||||||
|
@ -534,7 +699,7 @@ function handleCellDeleted(hook, cellId, siblingCellId) {
|
||||||
|
|
||||||
function handleCellMoved(hook, cellId) {
|
function handleCellMoved(hook, cellId) {
|
||||||
if (hook.state.focusedCellId === 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.
|
// The cell may have moved to another section, so update this information.
|
||||||
hook.state.focusedSectionId = getSectionIdByCellId(
|
hook.state.focusedSectionId = getSectionIdByCellId(
|
||||||
|
@ -575,7 +740,48 @@ function handleCellUpload(hook, cellId, url) {
|
||||||
setInsertMode(hook, true);
|
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() {
|
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
|
// Helpers
|
||||||
|
|
||||||
function nearbyCellId(cellId, offset) {
|
function nearbyCellId(cellId, offset) {
|
||||||
|
@ -645,8 +917,12 @@ function getSectionById(sectionId) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSectionList() {
|
function getSectionsList() {
|
||||||
return document.querySelector(`[data-element="section-list"]`);
|
return document.querySelector(`[data-element="sections-list"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientsList() {
|
||||||
|
return document.querySelector(`[data-element="clients-list"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCellIndicators() {
|
function getCellIndicators() {
|
||||||
|
@ -661,8 +937,8 @@ function getSectionsListToggle() {
|
||||||
return document.querySelector(`[data-element="sections-list-toggle"]`);
|
return document.querySelector(`[data-element="sections-list-toggle"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUsersListToggle() {
|
function getClientsListToggle() {
|
||||||
return document.querySelector(`[data-element="users-list-toggle"]`);
|
return document.querySelector(`[data-element="clients-list-toggle"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEvent(event) {
|
function cancelEvent(event) {
|
||||||
|
|
|
@ -14,6 +14,17 @@ describe("PubSub", () => {
|
||||||
expect(callback2).not.toHaveBeenCalled();
|
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", () => {
|
test("unsubscribed callback is not called on the specified topic", () => {
|
||||||
const pubsub = new PubSub();
|
const pubsub = new PubSub();
|
||||||
const callback1 = jest.fn();
|
const callback1 = jest.fn();
|
||||||
|
|
|
@ -32,6 +32,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
session_id: session_id,
|
session_id: session_id,
|
||||||
session_pid: session_pid,
|
session_pid: session_pid,
|
||||||
current_user: current_user,
|
current_user: current_user,
|
||||||
|
self: self(),
|
||||||
data_view: data_to_view(data)
|
data_view: data_to_view(data)
|
||||||
)
|
)
|
||||||
|> assign_private(data: data)
|
|> assign_private(data: data)
|
||||||
|
@ -79,7 +80,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="tooltip right distant" aria-label="Connected users (su)">
|
<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") %>
|
<%= remix_icon("group-fill") %>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
@ -110,10 +111,10 @@ defmodule LivebookWeb.SessionLive do
|
||||||
<h3 class="font-semibold text-gray-800 text-lg">
|
<h3 class="font-semibold text-gray-800 text-lg">
|
||||||
Sections
|
Sections
|
||||||
</h3>
|
</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 %>
|
<%= for section_item <- @data_view.sections_items do %>
|
||||||
<button class="text-left hover:text-gray-900 text-gray-500"
|
<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 %>">
|
data-section-id="<%= section_item.id %>">
|
||||||
<%= section_item.name %>
|
<%= section_item.name %>
|
||||||
</button>
|
</button>
|
||||||
|
@ -126,21 +127,42 @@ defmodule LivebookWeb.SessionLive do
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-element="users-list">
|
<div data-element="clients-list">
|
||||||
<div class="flex-grow flex flex-col">
|
<div class="flex-grow flex flex-col">
|
||||||
<h3 class="font-semibold text-gray-800 text-lg">
|
<h3 class="font-semibold text-gray-800 text-lg">
|
||||||
Users
|
Users
|
||||||
</h3>
|
</h3>
|
||||||
<h4 class="font text-gray-500 text-sm my-1">
|
<h4 class="font text-gray-500 text-sm my-1">
|
||||||
<%= length(@data_view.users) %> connected
|
<%= length(@data_view.clients) %> connected
|
||||||
</h4>
|
</h4>
|
||||||
<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 user <- @data_view.users do %>
|
<%= for {client_pid, user} <- @data_view.clients do %>
|
||||||
<div class="flex space-x-2 items-center">
|
<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") %>
|
<%= render_user_avatar(user, class: "h-7 w-7 flex-shrink-0", text_class: "text-xs") %>
|
||||||
<span class="text-gray-500">
|
<span><%= user.name || "Anonymous" %></span>
|
||||||
<%= user.name || "Anonymous" %>
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -276,6 +298,19 @@ defmodule LivebookWeb.SessionLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
def handle_event("cell_init", %{"cell_id" => cell_id}, socket) do
|
||||||
data = socket.private.data
|
data = socket.private.data
|
||||||
|
|
||||||
|
@ -490,6 +525,17 @@ defmodule LivebookWeb.SessionLive do
|
||||||
create_session(socket, notebook: notebook, copy_images_from: images_dir)
|
create_session(socket, notebook: notebook, copy_images_from: images_dir)
|
||||||
end
|
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
|
defp create_session(socket, opts) do
|
||||||
case SessionSupervisor.create_session(opts) do
|
case SessionSupervisor.create_session(opts) do
|
||||||
{:ok, id} ->
|
{:ok, id} ->
|
||||||
|
@ -549,8 +595,30 @@ defmodule LivebookWeb.SessionLive do
|
||||||
{:noreply, assign(socket, :current_user, user)}
|
{:noreply, assign(socket, :current_user, user)}
|
||||||
end
|
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}
|
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
|
defp after_operation(socket, _prev_socket, {:insert_section, client_pid, _index, section_id}) do
|
||||||
if client_pid == self() do
|
if client_pid == self() do
|
||||||
push_event(socket, "section_inserted", %{section_id: section_id})
|
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 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
|
defp normalize_name(name) do
|
||||||
name
|
name
|
||||||
|> String.trim()
|
|> String.trim()
|
||||||
|
@ -660,11 +732,10 @@ defmodule LivebookWeb.SessionLive do
|
||||||
for section <- data.notebook.sections do
|
for section <- data.notebook.sections do
|
||||||
%{id: section.id, name: section.name}
|
%{id: section.id, name: section.name}
|
||||||
end,
|
end,
|
||||||
users:
|
clients:
|
||||||
data.clients_map
|
data.clients_map
|
||||||
|> Map.values()
|
|> Enum.map(fn {client_pid, user_id} -> {client_pid, data.users_map[user_id]} end)
|
||||||
|> Enum.map(&data.users_map[&1])
|
|> Enum.sort_by(fn {_client_pid, user} -> user.name end),
|
||||||
|> Enum.sort_by(& &1.name),
|
|
||||||
section_views: Enum.map(data.notebook.sections, §ion_to_view(&1, data))
|
section_views: Enum.map(data.notebook.sections, §ion_to_view(&1, data))
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Reference in a new issue