livebook/assets/js/hooks/session.js

1430 lines
41 KiB
JavaScript

import {
isMacOS,
isEditableElement,
clamp,
selectElementContent,
smoothlyScrollToElement,
setFavicon,
cancelEvent,
isElementInViewport,
isElementHidden,
pop,
isSafari,
} from "../lib/utils";
import { parseHookProps } from "../lib/attribute";
import KeyBuffer from "../lib/key_buffer";
import { globalPubsub } from "../lib/pubsub";
import { leaveChannel } from "./js_view/channel";
import { isDirectlyEditable, isEvaluable } from "../lib/notebook";
import { settingsStore } from "../lib/settings";
import { LiveStore } from "../lib/live_store";
/**
* A hook managing the whole session.
*
* 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.
*
* ## Props
*
* * `autofocus-cell-id` - id of the cell that gets initial
* focus once the notebook is loaded
*
* * `global-status` - global evaluation status
*
* ## Shortcuts
*
* This hook registers session shortcut handlers,
* see `LivebookWeb.SessionLive.ShortcutsComponent`
* for the complete list of available shortcuts.
*
* ## Navigation
*
* This hook handles focusing section titles, 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 applying 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). When multiple clients are connected, they report
* own location to each other whenever it changes. The user can jump
* to the cell focused by any other client.
*
* 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, that is we focus the same cells
* to effectively mimic how the followed client moves around.
*
* Note that cursor and selection tracking is handled separately by
* each editor, as it involves transforming the positions with local
* and incoming remote changes.
*
* Initially we load basic information about connected clients using
* the `"session_init"` event and then update this information whenever
* clients join/leave/update. This way subsequent messages only include
* the client id and we already have the necessary color/name locally.
*/
const Session = {
mounted() {
this.props = this.getProps();
this.focusedId = null;
this.insertMode = false;
this.view = null;
this.viewOptions = null;
this.keyBuffer = new KeyBuffer();
this.lastLocationReportByClientId = {};
this.followedClientId = null;
this.store = LiveStore.create("session");
setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus));
this.updateSectionListHighlight();
// DOM events
this._handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this._handleEditorEscape = this.handleEditorEscape.bind(this);
this._handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
this._handleDocumentFocus = this.handleDocumentFocus.bind(this);
this._handleDocumentClick = this.handleDocumentClick.bind(this);
// Note: we register for the capture phase, so that we handle the
// event before the editor. Specifically, in case of Ctrl + Enter
// we want to evaluate the cell and cancel the event, so that the
// editor doesn't insert a newline
document.addEventListener("keydown", this._handleDocumentKeyDown, true);
document.addEventListener("lb:editor_escape", this._handleEditorEscape);
document.addEventListener("mousedown", this._handleDocumentMouseDown);
// Note: the focus event doesn't bubble, so we register for the capture phase
document.addEventListener("focus", this._handleDocumentFocus, true);
document.addEventListener("click", this._handleDocumentClick);
this.getElement("sections-list").addEventListener("click", (event) => {
this.handleSectionsListClick(event);
this.handleCellIndicatorsClick(event);
});
this.getElement("clients-list").addEventListener("click", (event) =>
this.handleClientsListClick(event),
);
this.getElement("sections-list-toggle").addEventListener("click", (event) =>
this.toggleSectionsList(),
);
this.getElement("clients-list-toggle").addEventListener("click", (event) =>
this.toggleClientsList(),
);
this.getElement("secrets-list-toggle").addEventListener("click", (event) =>
this.toggleSecretsList(),
);
this.getElement("runtime-info-toggle").addEventListener("click", (event) =>
this.toggleRuntimeInfo(),
);
this.getElement("app-info-toggle").addEventListener("click", (event) =>
this.toggleAppInfo(),
);
this.getElement("files-list-toggle").addEventListener("click", (event) =>
this.toggleFilesList(),
);
this.getElement("notebook").addEventListener("scroll", (event) =>
this.updateSectionListHighlight(),
);
this.getElement("notebook-indicators").addEventListener("click", (event) =>
this.handleCellIndicatorsClick(event),
);
this.getElement("views").addEventListener("click", (event) => {
this.handleViewsClick(event);
});
this.getElement("section-toggle-collapse-all-button").addEventListener(
"click",
(event) => this.toggleCollapseAllSections(),
);
this.initializeDragAndDrop();
window.addEventListener(
"phx:page-loading-stop",
() => {
this.initializeFocus();
},
{ once: true },
);
// Server events
this.handleEvent("session_init", ({ clients, client_id }) => {
const clientsMap = {};
for (const client of clients) {
clientsMap[client.id] = client;
}
// Note that we keep clients in a global store, so that all cell
// hooks can access this information, without pushing it for each
// of them separately
this.store.set("clients", clientsMap);
this.store.set("clientId", client_id);
});
this.handleEvent("cell_inserted", ({ cell_id: cellId }) => {
this.handleCellInserted(cellId);
});
this.handleEvent(
"cell_deleted",
({ cell_id: cellId, sibling_cell_id: siblingCellId }) => {
this.handleCellDeleted(cellId, siblingCellId);
},
);
this.handleEvent("cell_restored", ({ cell_id: cellId }) => {
this.handleCellRestored(cellId);
});
this.handleEvent("cell_moved", ({ cell_id }) => {
this.handleCellMoved(cell_id);
});
this.handleEvent("section_inserted", ({ section_id }) => {
this.handleSectionInserted(section_id);
});
this.handleEvent("section_deleted", ({ section_id }) => {
this.handleSectionDeleted(section_id);
});
this.handleEvent("section_moved", ({ section_id }) => {
this.handleSectionMoved(section_id);
});
this.handleEvent("client_joined", ({ client }) => {
this.handleClientJoined(client);
});
this.handleEvent("client_left", ({ client_id }) => {
this.handleClientLeft(client_id);
});
this.handleEvent("clients_updated", ({ clients }) => {
this.handleClientsUpdated(clients);
});
this.handleEvent(
"secret_selected",
({ select_secret_ref, secret_name }) => {
this.handleSecretSelected(select_secret_ref, secret_name);
},
);
this.handleEvent("location_report", ({ client_id, focusable_id }) => {
const report = { focusableId: focusable_id };
this.handleLocationReport(client_id, report);
});
},
updated() {
const prevProps = this.props;
this.props = this.getProps();
if (this.props.globalStatus !== prevProps.globalStatus) {
setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus));
}
},
disconnected() {
// Reinitialize on reconnection
this.el.removeAttribute("id");
// If we reconnect, a new hook is mounted and it becomes responsible
// for leaving the channel when destroyed
this.keepChannel = true;
},
destroyed() {
document.removeEventListener("keydown", this._handleDocumentKeyDown, true);
document.removeEventListener("lb:editor_scape", this._handleEditorEscape);
document.removeEventListener("mousedown", this._handleDocumentMouseDown);
document.removeEventListener("focus", this._handleDocumentFocus, true);
document.removeEventListener("click", this._handleDocumentClick);
setFavicon("favicon");
if (!this.keepChannel) {
leaveChannel();
}
this.store.destroy();
},
getProps() {
return parseHookProps(this.el, ["autofocus-cell-id", "global-status"]);
},
faviconForEvaluationStatus(evaluationStatus) {
if (evaluationStatus === "evaluating") return "favicon-evaluating";
if (evaluationStatus === "stale") return "favicon-stale";
if (evaluationStatus === "errored") return "favicon-errored";
return "favicon";
},
// DOM event handlers
/**
* Handles session keybindings.
*
* Make sure to keep the shortcuts help modal up to date.
*/
handleDocumentKeyDown(event) {
if (event.repeat) {
return;
}
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
const alt = event.altKey;
const shift = event.shiftKey;
const key = event.key;
const keyBuffer = this.keyBuffer;
// Universal shortcuts (ignore editable elements in cell output)
if (
!(
isEditableElement(event.target) &&
event.target.closest(`[data-el-outputs-container]`)
)
) {
if (cmd && shift && !alt && key === "Enter") {
cancelEvent(event);
this.queueFullCellsEvaluation(true);
return;
} else if (!cmd && shift && !alt && key === "Enter") {
cancelEvent(event);
if (isEvaluable(this.focusedCellType())) {
this.queueFocusedCellEvaluation();
}
this.moveFocus(1);
return;
} else if (cmd && !alt && key === "Enter") {
cancelEvent(event);
if (isEvaluable(this.focusedCellType())) {
this.queueFocusedCellEvaluation();
}
return;
} else if (cmd && key === "s") {
cancelEvent(event);
this.saveNotebook();
return;
} else if (cmd || alt) {
return;
}
}
if (this.insertMode) {
keyBuffer.reset();
// We handle editor escape in a dedicated handler
const isEditor = !!event.target.closest(`[data-el-editor-container]`);
if (!isEditor && key === "Escape") {
this.escapeInsertMode();
}
// Ignore keystrokes on input fields
} else if (isEditableElement(event.target)) {
keyBuffer.reset();
// Use Escape for universal blur
if (key === "Escape") {
event.target.blur();
}
} else {
keyBuffer.push(event.key);
if (keyBuffer.tryMatch(["d", "d"])) {
this.deleteFocusedCell();
} else if (keyBuffer.tryMatch(["e", "a"])) {
this.queueFullCellsEvaluation(false);
} else if (keyBuffer.tryMatch(["e", "e"])) {
if (isEvaluable(this.focusedCellType())) {
this.queueFocusedCellEvaluation();
}
} else if (keyBuffer.tryMatch(["e", "s"])) {
this.queueFocusedSectionEvaluation();
} else if (keyBuffer.tryMatch(["s", "s"])) {
this.toggleSectionsList();
} else if (keyBuffer.tryMatch(["s", "e"])) {
this.toggleSecretsList();
} else if (keyBuffer.tryMatch(["s", "a"])) {
this.toggleAppInfo();
} else if (keyBuffer.tryMatch(["s", "u"])) {
this.toggleClientsList();
} else if (keyBuffer.tryMatch(["s", "f"])) {
this.toggleFilesList();
} else if (keyBuffer.tryMatch(["s", "r"])) {
this.toggleRuntimeInfo();
} else if (keyBuffer.tryMatch(["s", "b"])) {
this.showBin();
} else if (keyBuffer.tryMatch(["s", "p"])) {
this.showPackageSearch();
} else if (keyBuffer.tryMatch(["e", "x"])) {
this.cancelFocusedCellEvaluation();
} else if (keyBuffer.tryMatch(["0", "0"])) {
this.reconnectRuntime();
} else if (keyBuffer.tryMatch(["Escape", "Escape"])) {
this.setFocusedEl(null);
} else if (keyBuffer.tryMatch(["?"])) {
this.showShortcuts();
} else if (
keyBuffer.tryMatch(["i"]) ||
(event.target.matches(
`body, [data-el-cell-body], [data-el-heading], [data-focusable-id]`,
) &&
this.focusedId &&
key === "Enter")
) {
cancelEvent(event);
if (this.isInsertModeAvailable()) {
this.enterInsertMode();
}
} else if (keyBuffer.tryMatch(["j"])) {
this.moveFocus(1);
} else if (keyBuffer.tryMatch(["k"])) {
this.moveFocus(-1);
} else if (keyBuffer.tryMatch(["J"])) {
this.moveFocusedCell(1);
} else if (keyBuffer.tryMatch(["K"])) {
this.moveFocusedCell(-1);
} else if (keyBuffer.tryMatch(["n"])) {
this.insertCellBelowFocused("code");
} else if (keyBuffer.tryMatch(["N"])) {
this.insertCellAboveFocused("code");
} else if (keyBuffer.tryMatch(["m"])) {
if (!this.view || this.viewOptions.showMarkdown) {
this.insertCellBelowFocused("markdown");
}
} else if (keyBuffer.tryMatch(["M"])) {
if (!this.view || this.viewOptions.showMarkdown) {
this.insertCellAboveFocused("markdown");
}
} else if (keyBuffer.tryMatch(["v", "z"])) {
this.toggleView("code-zen");
} else if (keyBuffer.tryMatch(["v", "p"])) {
this.toggleView("presentation");
} else if (keyBuffer.tryMatch(["v", "c"])) {
this.toggleView("custom");
} else if (keyBuffer.tryMatch(["c"])) {
if (!this.view || this.viewOptions.showSection) {
this.toggleCollapseSection();
}
} else if (keyBuffer.tryMatch(["C"])) {
if (!this.view || this.viewOptions.showSection) {
this.toggleCollapseAllSections();
}
}
}
},
handleEditorEscape() {
if (this.insertMode) {
this.keyBuffer.reset();
this.escapeInsertMode();
}
},
/**
* Focuses/blurs a cell when the user clicks somewhere.
*
* Note: we use mousedown event to more reliably focus editor
* (e.g. if the user starts selecting some text within the editor)
*/
handleDocumentMouseDown(event) {
// If the click is outside the notebook element, keep the focus as is
if (!event.target.closest(`[data-el-notebook]`)) {
if (this.insertMode) {
this.setInsertMode(false);
}
return;
}
// If the click is inside an editor tooltip, exit insert mode to
// allow for text selection within the tooltip
if (event.target.closest(`.cm-tooltip`)) {
if (this.insertMode) {
this.setInsertMode(false);
}
return;
}
// When clicking an insert button, keep focus and insert mode as is.
// This is relevant for markdown cells, since we show the markdown
// preview in insert mode and exiting insert mode on mousedown would
// result in layout shift, so mouseup would happen outside the target
// button and the click would be ignored
if (event.target.closest(`[data-el-insert-buttons] button`)) {
return;
}
// Find the focusable element, if one was clicked
const focusableEl = event.target.closest(`[data-focusable-id]`);
const focusableId = focusableEl ? focusableEl.dataset.focusableId : null;
const insertMode = this.editableElementClicked(event, focusableEl);
if (focusableId !== this.focusedId) {
this.setFocusedEl(focusableId, { scroll: false, focusElement: false });
}
// If a cell action is clicked, keep the insert mode as is
if (event.target.closest(`[data-el-actions]`)) {
return;
}
// Depending on whether the click targets editor or input disable/enable insert mode
if (this.insertMode !== insertMode) {
this.setInsertMode(insertMode);
}
},
editableElementClicked(event, focusableEl) {
if (focusableEl) {
const editableElement = event.target.closest(
`[data-el-editor-container], [data-el-heading]`,
);
return editableElement && focusableEl.contains(editableElement);
}
return false;
},
/**
* Focuses a focusable element if the user "tab"s anywhere into it.
*/
handleDocumentFocus(event) {
const focusableEl =
event.target.closest && event.target.closest(`[data-focusable-id]`);
if (focusableEl) {
const focusableId = focusableEl.dataset.focusableId;
if (focusableId !== this.focusedId) {
this.setFocusedEl(focusableId, { scroll: false, focusElement: false });
}
}
},
/**
* Enters insert mode when markdown edit action is clicked.
*/
handleDocumentClick(event) {
if (event.target.closest(`[data-el-enable-insert-mode-button]`)) {
this.setInsertMode(true);
}
if (event.target.closest(`[data-btn-package-search]`) && this.insertMode) {
this.setInsertMode(false);
}
const evalButton = event.target.closest(
`[data-el-queue-cell-evaluation-button]`,
);
if (evalButton) {
const cellId = evalButton.getAttribute("data-cell-id");
const disableDependenciesCache = evalButton.hasAttribute(
"data-disable-dependencies-cache",
);
this.queueCellEvaluation(cellId, disableDependenciesCache);
}
const hash = window.location.hash;
if (hash) {
const htmlId = hash.replace(/^#/, "");
const hashEl = document.getElementById(htmlId);
// Remove hash from the URL when the user clicks somewhere else on the page
if (!hashEl.contains(event.target) && !event.target.closest(`a`)) {
history.pushState(
null,
document.title,
window.location.pathname + window.location.search,
);
}
}
},
/**
* Handles section link clicks in the section list.
*/
handleSectionsListClick(event) {
const sectionButton = event.target.closest(`[data-el-sections-list-item]`);
if (sectionButton) {
const sectionId = sectionButton.getAttribute("data-section-id");
const section = this.getSectionById(sectionId);
section.scrollIntoView({ behavior: "instant", block: "start" });
}
},
/**
* Handles client link clicks in the clients list.
*/
handleClientsListClick(event) {
const clientListItem = event.target.closest(`[data-el-clients-list-item]`);
if (clientListItem) {
const clientId = clientListItem.getAttribute("data-client-id");
const clientLink = event.target.closest(`[data-el-client-link]`);
if (clientLink) {
this.handleClientLinkClick(clientId);
}
const clientFollowToggle = event.target.closest(
`[data-el-client-follow-toggle]`,
);
if (clientFollowToggle) {
this.handleClientFollowToggleClick(clientId, clientListItem);
}
}
},
handleClientLinkClick(clientId) {
this.mirrorClientFocus(clientId);
},
handleClientFollowToggleClick(clientId, clientListItem) {
const followedClientListItem = this.el.querySelector(
`[data-el-clients-list-item][data-js-followed]`,
);
if (followedClientListItem) {
followedClientListItem.removeAttribute("data-js-followed");
}
if (clientId === this.followedClientId) {
this.followedClientId = null;
} else {
clientListItem.setAttribute("data-js-followed", "");
this.followedClientId = clientId;
this.mirrorClientFocus(clientId);
}
},
mirrorClientFocus(clientId) {
const locationReport = this.lastLocationReportByClientId[clientId];
if (locationReport && locationReport.focusableId) {
this.setFocusedEl(locationReport.focusableId);
}
},
/**
* Handles button clicks within cell indicators section.
*/
handleCellIndicatorsClick(event) {
const button = event.target.closest(`[data-el-focus-cell-button]`);
if (button) {
const cellId = button.getAttribute("data-target");
this.setFocusedEl(cellId);
}
},
/**
* Focuses cell or any other element based on the current
* URL and hook attributes.
*/
initializeFocus() {
const hash = window.location.hash;
if (hash) {
const htmlId = hash.replace(/^#/, "");
const hashEl = document.getElementById(htmlId);
if (hashEl) {
const focusableEl = hashEl.closest("[data-focusable-id]");
if (focusableEl) {
this.setFocusedEl(focusableEl.dataset.focusableId);
} else {
// Explicitly scroll to the target element
// after the loading finishes
hashEl.scrollIntoView();
}
}
} else if (this.props.autofocusCellId) {
this.setFocusedEl(this.props.autofocusCellId, { scroll: false });
this.setInsertMode(true);
}
},
/**
* Handles the main notebook area being scrolled.
*/
updateSectionListHighlight() {
const currentListItem = this.el.querySelector(
`[data-el-sections-list-item][data-js-is-viewed]`,
);
if (currentListItem) {
currentListItem.removeAttribute("data-js-is-viewed");
}
// Consider a section being viewed if it is within the top 35% of the screen
const viewedSection = this.getSections()
.reverse()
.find((section) => {
const { top } = section.getBoundingClientRect();
const scrollTop = document.documentElement.scrollTop;
return top <= scrollTop + window.innerHeight * 0.35;
});
if (viewedSection) {
const sectionId = viewedSection.getAttribute("data-section-id");
const listItem = this.el.querySelector(
`[data-el-sections-list-item][data-section-id="${sectionId}"]`,
);
listItem.setAttribute("data-js-is-viewed", "");
}
},
/**
* Initializes drag and drop event handlers.
*/
initializeDragAndDrop() {
let isDragging = false;
let draggedEl = null;
let files = null;
const startDragging = (element = null) => {
if (!isDragging) {
isDragging = true;
draggedEl = element;
const type = element ? "internal" : "external";
this.el.setAttribute("data-js-dragging", type);
if (type === "external") {
this.toggleFilesList(true);
}
}
};
const stopDragging = () => {
if (isDragging) {
isDragging = false;
this.el.removeAttribute("data-js-dragging");
}
};
this.el.addEventListener("dragstart", (event) => {
startDragging(event.target);
});
this.el.addEventListener("dragenter", (event) => {
startDragging();
});
this.el.addEventListener("dragleave", (event) => {
// The related target should point to the newly entered element,
// and be null when the cursor leaves the window. However, in
// Safari the related target is always null (1), so we ignore
// the leave event altogether. The side effect is that dropping
// the file outside the window will keep the drop areas open and
// require page refresh to hide them, but the workaround is not
// worth its complexity, hence we accept this edge case.
//
// (1): https://stackoverflow.com/a/71744945
if (isSafari()) return;
if (!this.el.contains(event.relatedTarget)) {
stopDragging();
}
});
this.el.addEventListener("dragover", (event) => {
event.stopPropagation();
event.preventDefault();
});
this.el.addEventListener("drop", (event) => {
event.stopPropagation();
event.preventDefault();
const insertDropEl = event.target.closest(`[data-el-insert-drop-area]`);
const filesDropEl = event.target.closest(`[data-el-files-drop-area]`);
if (insertDropEl) {
const sectionId = insertDropEl.getAttribute("data-section-id") || null;
const cellId = insertDropEl.getAttribute("data-cell-id") || null;
if (event.dataTransfer.files.length > 0) {
files = event.dataTransfer.files;
this.pushEvent("handle_file_drop", {
section_id: sectionId,
cell_id: cellId,
});
} else if (draggedEl && draggedEl.matches("[data-el-file-entry]")) {
const fileEntryName = draggedEl.getAttribute("data-name");
this.pushEvent("insert_file", {
file_entry_name: fileEntryName,
section_id: sectionId,
cell_id: cellId,
});
}
} else if (filesDropEl) {
if (event.dataTransfer.files.length > 0) {
files = event.dataTransfer.files;
this.pushEvent("handle_file_drop", {});
}
}
stopDragging();
});
this.handleEvent("finish_file_drop", (event) => {
const inputEl = document.querySelector(
`#add-file-entry-modal input[type="file"]`,
);
if (inputEl) {
inputEl.files = files;
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
}
});
},
// User action handlers (mostly keybindings)
toggleSectionsList(force = null) {
this.toggleSidePanelContent("sections-list", force);
},
toggleClientsList(force = null) {
this.toggleSidePanelContent("clients-list", force);
},
toggleSecretsList(force = null) {
this.toggleSidePanelContent("secrets-list", force);
},
toggleAppInfo(force = null) {
this.toggleSidePanelContent("app-info", force);
},
toggleFilesList(force = null) {
this.toggleSidePanelContent("files-list", force);
},
toggleRuntimeInfo(force = null) {
this.toggleSidePanelContent("runtime-info", force);
},
toggleSidePanelContent(name, force = null) {
const shouldOpen =
force === null
? this.el.getAttribute("data-js-side-panel-content") !== name
: force;
if (shouldOpen) {
this.el.setAttribute("data-js-side-panel-content", name);
} else {
this.el.removeAttribute("data-js-side-panel-content");
}
},
showBin() {
const actionEl = this.el.querySelector(`[data-btn-show-bin]`);
actionEl && actionEl.click();
},
showPackageSearch() {
this.setFocusedEl("setup");
const actionEl = this.el.querySelector(`[data-btn-package-search]`);
actionEl && actionEl.click();
},
saveNotebook() {
this.pushEvent("save", {});
},
deleteFocusedCell() {
if (this.focusedId && this.isCell(this.focusedId)) {
this.pushEvent("delete_cell", { cell_id: this.focusedId });
}
},
queueCellEvaluation(cellId, disableDependenciesCache) {
this.dispatchQueueEvaluation(() => {
this.pushEvent("queue_cell_evaluation", {
cell_id: cellId,
disable_dependencies_cache: disableDependenciesCache,
});
});
},
queueFocusedCellEvaluation() {
if (this.focusedId && this.isCell(this.focusedId)) {
this.dispatchQueueEvaluation(() => {
this.pushEvent("queue_cell_evaluation", { cell_id: this.focusedId });
});
}
},
queueFullCellsEvaluation(includeFocused) {
const forcedCellIds =
includeFocused && this.focusedId && this.isCell(this.focusedId)
? [this.focusedId]
: [];
this.dispatchQueueEvaluation(() => {
this.pushEvent("queue_full_evaluation", {
forced_cell_ids: forcedCellIds,
});
});
},
queueFocusedSectionEvaluation() {
if (this.focusedId) {
const sectionId = this.getSectionIdByFocusableId(this.focusedId);
if (sectionId) {
this.dispatchQueueEvaluation(() => {
this.pushEvent("queue_section_evaluation", {
section_id: sectionId,
});
});
}
}
},
dispatchQueueEvaluation(dispatch) {
if (isEvaluable(this.focusedCellType())) {
// If an evaluable cell is focused, we forward the evaluation
// request to that cell, so it can synchronize itself before
// sending the request to the server
globalPubsub.broadcast(`cells:${this.focusedId}`, {
type: "dispatch_queue_evaluation",
dispatch,
});
} else {
dispatch();
}
},
cancelFocusedCellEvaluation() {
if (this.focusedId && this.isCell(this.focusedId)) {
this.pushEvent("cancel_cell_evaluation", {
cell_id: this.focusedId,
});
}
},
reconnectRuntime() {
this.pushEvent("reconnect_runtime", {});
},
showShortcuts() {
const actionEl = this.el.querySelector(`[data-btn-show-shortcuts]`);
actionEl && actionEl.click();
},
isInsertModeAvailable() {
if (!this.focusedId) {
return false;
}
const el = this.getFocusableEl(this.focusedId);
return (
!this.isCell(this.focusedId) ||
!el.hasAttribute("data-js-insert-mode-disabled")
);
},
enterInsertMode() {
if (this.focusedId) {
this.setInsertMode(true);
}
},
escapeInsertMode() {
this.setInsertMode(false);
},
moveFocus(offset) {
const focusableId = this.nearbyFocusableId(this.focusedId, offset);
this.setFocusedEl(focusableId);
},
moveFocusedCell(offset) {
if (this.focusedId && this.isCell(this.focusedId)) {
this.pushEvent("move_cell", { cell_id: this.focusedId, offset });
}
},
insertCellBelowFocused(type) {
if (this.focusedId) {
this.insertCellBelowFocusableId(this.focusedId, type);
} else {
const focusableIds = this.getFocusableIds();
if (focusableIds.length > 0) {
this.insertCellBelowFocusableId(
focusableIds[focusableIds.length - 1],
type,
);
}
}
},
insertCellAboveFocused(type) {
if (this.focusedId) {
const prevFocusableId = this.nearbyFocusableId(this.focusedId, -1);
this.insertCellBelowFocusableId(prevFocusableId, type);
} else {
const focusableIds = this.getFocusableIds();
if (focusableIds.length > 0) {
this.insertCellBelowFocusableId(focusableIds[0], type);
}
}
},
insertCellBelowFocusableId(focusableId, type) {
if (this.isCell(focusableId)) {
this.pushEvent("insert_cell_below", { type, cell_id: focusableId });
} else if (this.isSection(focusableId)) {
this.pushEvent("insert_cell_below", { type, section_id: focusableId });
} else if (this.isNotebook(focusableId)) {
const sectionIds = this.getSectionIds();
if (sectionIds.length > 0) {
this.pushEvent("insert_cell_below", {
type,
section_id: sectionIds[0],
});
}
}
},
setFocusedEl(focusableId, { scroll = true, focusElement = true } = {}) {
this.focusedId = focusableId;
if (focusableId) {
this.el.setAttribute("data-js-focused-id", focusableId);
} else {
this.el.removeAttribute("data-js-focused-id");
}
if (focusableId) {
// If the element is inside collapsed section, expand that section
if (!this.isSection(focusableId)) {
const sectionId = this.getSectionIdByFocusableId(focusableId);
if (sectionId) {
const section = this.getSectionById(sectionId);
section.removeAttribute("data-js-collapsed");
}
}
const el = this.getFocusableEl(focusableId);
if (focusElement) {
// Focus the primary content in the focusable element, this is important for screen readers
const contentEl =
el.querySelector(`[data-el-cell-body]`) ||
el.querySelector(`[data-el-heading]`) ||
el;
contentEl.focus({ preventScroll: true });
}
}
globalPubsub.broadcast("navigation", {
type: "element_focused",
focusableId: focusableId,
scroll,
});
this.setInsertMode(false);
this.sendLocationReport({ focusableId });
},
setInsertMode(insertModeEnabled) {
this.insertMode = insertModeEnabled;
if (insertModeEnabled) {
this.el.setAttribute("data-js-insert-mode", "");
} else {
this.el.removeAttribute("data-js-insert-mode");
}
globalPubsub.broadcast("navigation", {
type: "insert_mode_changed",
enabled: insertModeEnabled,
});
},
handleViewsClick(event) {
const button = event.target.closest(`[data-el-view-toggle]`);
if (button) {
const view = button.getAttribute("data-el-view-toggle");
this.toggleView(view);
}
},
toggleView(view) {
if (view === this.view) {
this.unsetView();
if (view === "custom") {
this.customViewSettingsSubscription.destroy();
}
} else if (view === "code-zen") {
this.setView(view, {
showSection: false,
showMarkdown: false,
showOutput: true,
spotlight: false,
});
} else if (view === "presentation") {
this.setView(view, {
showSection: true,
showMarkdown: true,
showOutput: true,
spotlight: true,
});
} else if (view === "custom") {
this.customViewSettingsSubscription = settingsStore.getAndSubscribe(
(settings) => {
this.setView(view, {
showSection: settings.custom_view_show_section,
showMarkdown: settings.custom_view_show_markdown,
showOutput: settings.custom_view_show_output,
spotlight: settings.custom_view_spotlight,
});
},
);
this.pushEvent("open_custom_view_settings");
}
// If nothing is focused, use the first cell in the viewport
const focusedId = this.focusedId || this.nearbyFocusableId(null, 0);
if (focusedId) {
const visibleId = this.ensureVisibleFocusableEl(focusedId);
if (visibleId !== this.focused) {
this.setFocusedEl(visibleId, { scroll: false });
}
if (visibleId) {
this.getFocusableEl(visibleId).scrollIntoView({ block: "center" });
}
}
},
setView(view, options) {
this.view = view;
this.viewOptions = options;
this.el.setAttribute("data-js-view", view);
this.el.toggleAttribute("data-js-hide-section", !options.showSection);
this.el.toggleAttribute("data-js-hide-markdown", !options.showMarkdown);
this.el.toggleAttribute("data-js-hide-output", !options.showOutput);
this.el.toggleAttribute("data-js-spotlight", options.spotlight);
},
unsetView() {
this.view = null;
this.viewOptions = null;
this.el.removeAttribute("data-js-view");
this.el.removeAttribute("data-js-hide-section");
this.el.removeAttribute("data-js-hide-markdown");
this.el.removeAttribute("data-js-hide-output");
this.el.removeAttribute("data-js-spotlight");
},
toggleCollapseSection() {
if (this.focusedId) {
const sectionId = this.getSectionIdByFocusableId(this.focusedId);
if (sectionId) {
const section = this.getSectionById(sectionId);
if (section.hasAttribute("data-js-collapsed")) {
section.removeAttribute("data-js-collapsed");
} else {
section.setAttribute("data-js-collapsed", "");
this.setFocusedEl(sectionId, { scroll: true });
}
}
}
},
toggleCollapseAllSections() {
const allCollapsed = this.getSections().every((section) =>
section.hasAttribute("data-js-collapsed"),
);
this.getSections().forEach((section) => {
section.toggleAttribute("data-js-collapsed", !allCollapsed);
});
if (this.focusedId) {
const focusedSectionId = this.getSectionIdByFocusableId(this.focusedId);
if (focusedSectionId) {
this.setFocusedEl(focusedSectionId, { scroll: true });
}
}
},
// Server event handlers
handleCellInserted(cellId) {
this.setFocusedEl(cellId);
if (isDirectlyEditable(this.focusedCellType())) {
this.setInsertMode(true);
}
},
handleCellDeleted(cellId, siblingCellId) {
if (this.focusedId === cellId) {
if (this.view) {
const visibleSiblingId = this.ensureVisibleFocusableEl(siblingCellId);
this.setFocusedEl(visibleSiblingId);
} else {
this.setFocusedEl(siblingCellId);
}
}
},
handleCellRestored(cellId) {
this.setFocusedEl(cellId);
},
handleCellMoved(cellId) {
this.repositionJSViews();
if (this.focusedId === cellId) {
globalPubsub.broadcast("cells", { type: "cell_moved", cellId });
}
},
handleSectionInserted(sectionId) {
const section = this.getSectionById(sectionId);
const headlineEl = section.querySelector(`[data-el-section-headline]`);
const { focusableId } = headlineEl.dataset;
this.setFocusedEl(focusableId);
this.setInsertMode(true);
selectElementContent(document.activeElement);
},
handleSectionDeleted(sectionId) {
// Clear focus if the element no longer exists
if (this.focusedId && !this.getFocusableEl(this.focusedId)) {
this.setFocusedEl(null);
}
},
handleSectionMoved(sectionId) {
this.repositionJSViews();
const section = this.getSectionById(sectionId);
smoothlyScrollToElement(section);
},
handleClientJoined(client) {
const clientsMap = this.store.get("clients");
this.store.set("clients", { ...clientsMap, [client.id]: client });
},
handleClientLeft(clientId) {
const clientsMap = this.store.get("clients");
const client = clientsMap[clientId];
if (client) {
const [, newClientsMap] = pop(clientsMap, clientId);
this.store.set("clients", newClientsMap);
if (client.id === this.followedClientId) {
this.followedClientId = null;
}
}
},
handleClientsUpdated(updatedClients) {
const clientsMap = this.store.get("clients");
const newClientsMap = { ...clientsMap };
for (const client of updatedClients) {
newClientsMap[client.id] = client;
}
this.store.set("clients", newClientsMap);
},
handleSecretSelected(select_secret_ref, secretName) {
globalPubsub.broadcast(`js_views:${select_secret_ref}`, {
type: "secretSelected",
secretName,
});
},
handleLocationReport(clientId, report) {
const client = this.store.get("clients")[clientId];
this.lastLocationReportByClientId[clientId] = report;
if (client) {
if (
client.id === this.followedClientId &&
report.focusableId !== this.focusedId
) {
this.setFocusedEl(report.focusableId);
}
}
},
repositionJSViews() {
globalPubsub.broadcast("js_views", { type: "reposition" });
},
/**
* Sends local location report to the server.
*/
sendLocationReport(report) {
const numberOfClients = Object.keys(this.store.get("clients")).length;
// Only send reports if there are other people to send to
if (numberOfClients > 1) {
this.pushEvent("location_report", { focusable_id: report.focusableId });
}
},
// Helpers
focusedCellType() {
if (this.focusedId && this.isCell(this.focusedId)) {
const el = this.getFocusableEl(this.focusedId);
return el.getAttribute("data-type");
} else {
return null;
}
},
nearbyFocusableId(focusableId, offset) {
const focusableIds = this.getFocusableIds();
if (focusableIds.length === 0) {
return null;
}
const idx = focusableIds.indexOf(focusableId);
if (idx === -1) {
const focusableElInViewport =
this.getFocusableEls().find(isElementInViewport);
if (focusableElInViewport) {
return focusableElInViewport.getAttribute("data-focusable-id");
}
return focusableIds[0];
} else {
const siblingIdx = clamp(idx + offset, 0, focusableIds.length - 1);
return focusableIds[siblingIdx];
}
},
ensureVisibleFocusableEl(cellId) {
const focusableEl = this.getFocusableEl(cellId);
const allFocusableEls = Array.from(
this.el.querySelectorAll(`[data-focusable-id]`),
);
const idx = allFocusableEls.indexOf(focusableEl);
const visibleSibling = [
...allFocusableEls.slice(idx, -1),
...allFocusableEls.slice(0, idx).reverse(),
].find((el) => !isElementHidden(el));
return visibleSibling && visibleSibling.getAttribute("data-focusable-id");
},
isCell(focusableId) {
const el = this.getFocusableEl(focusableId);
return el.hasAttribute("data-el-cell");
},
isSection(focusableId) {
const el = this.getFocusableEl(focusableId);
return el.hasAttribute("data-el-section-headline");
},
isNotebook(focusableId) {
const el = this.getFocusableEl(focusableId);
return el.hasAttribute("data-el-notebook-headline");
},
getFocusableEl(focusableId) {
return this.el.querySelector(`[data-focusable-id="${focusableId}"]`);
},
getFocusableEls() {
return Array.from(this.el.querySelectorAll(`[data-focusable-id]`)).filter(
(el) => !isElementHidden(el),
);
},
getFocusableIds() {
return this.getFocusableEls().map((el) =>
el.getAttribute("data-focusable-id"),
);
},
getSectionIdByFocusableId(focusableId) {
const el = this.getFocusableEl(focusableId);
const section = el.closest(`[data-el-section]`);
return section && section.getAttribute("data-section-id");
},
getSectionIds() {
const sections = this.getSections();
return sections.map((section) => section.getAttribute("data-section-id"));
},
getSections() {
return Array.from(this.el.querySelectorAll(`[data-el-section]`));
},
getSectionById(sectionId) {
return this.el.querySelector(
`[data-el-section][data-section-id="${sectionId}"]`,
);
},
getElement(name) {
return this.el.querySelector(`[data-el-${name}]`);
},
};
export default Session;