livebook/assets/js/session/index.js

588 lines
15 KiB
JavaScript
Raw Normal View History

import {
isMacOS,
isEditableElement,
clamp,
selectElementContent,
smoothlyScrollToElement,
} from "../lib/utils";
import KeyBuffer from "./key_buffer";
import { globalPubSub } from "../lib/pub_sub";
/**
* A hook managing the whole session.
*
* Handles keybindings, focus changes and insert mode changes.
*/
const Session = {
mounted() {
this.state = {
focusedCellId: null,
focusedSectionId: null,
focusedCellType: null,
insertMode: false,
keyBuffer: new KeyBuffer(),
};
// DOM events
this.handleDocumentKeyDown = (event) => {
handleDocumentKeyDown(this, event);
};
document.addEventListener("keydown", this.handleDocumentKeyDown, true);
this.handleDocumentMouseDown = (event) => {
handleDocumentMouseDown(this, event);
};
document.addEventListener("mousedown", this.handleDocumentMouseDown);
this.handleDocumentDoubleClick = (event) => {
handleDocumentDoubleClick(this, event);
};
document.addEventListener("dblclick", this.handleDocumentDoubleClick);
getSectionList().addEventListener("click", (event) => {
handleSectionListClick(this, event);
});
getSectionsPanelToggle().addEventListener("click", (event) => {
toggleSectionsPanel(this);
});
getNotebook().addEventListener("scroll", (event) => {
updateSectionListHighlight();
});
getCellIndicators().addEventListener("click", (event) => {
handleCellIndicatorsClick(this, event);
});
// DOM setup
updateSectionListHighlight();
focusNotebookNameIfNew();
// Server events
this.handleEvent("cell_inserted", ({ cell_id: cellId }) => {
handleCellInserted(this, cellId);
});
this.handleEvent(
"cell_deleted",
({ cell_id: cellId, sibling_cell_id: siblingCellId }) => {
handleCellDeleted(this, cellId, siblingCellId);
}
);
this.handleEvent("cell_moved", ({ cell_id: cellId }) => {
handleCellMoved(this, cellId);
});
this.handleEvent("section_inserted", ({ section_id: sectionId }) => {
handleSectionInserted(this, sectionId);
});
this.handleEvent("section_deleted", ({ section_id: sectionId }) => {
handleSectionDeleted(this, sectionId);
});
},
destroyed() {
document.removeEventListener("keydown", this.handleDocumentKeyDown);
document.removeEventListener("mousedown", this.handleDocumentMouseDown);
document.removeEventListener("dblclick", this.handleDocumentDoubleClick);
},
};
/**
* Handles session keybindings.
*
* Make sure to keep the shortcuts help modal up to date.
*/
function handleDocumentKeyDown(hook, event) {
if (event.repeat) {
return;
}
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
const key = event.key;
const keyBuffer = hook.state.keyBuffer;
if (hook.state.insertMode) {
keyBuffer.reset();
if (key === "Escape") {
// Ignore Escape if it's supposed to close some Monaco input (like the find/replace box)
if (!event.target.closest(".monaco-inputbox")) {
escapeInsertMode(hook);
}
} else if (cmd && key === "Enter") {
cancelEvent(event);
if (hook.state.focusedCellType === "elixir") {
queueFocusedCellEvaluation(hook);
}
}
} else {
// Ignore inputs and notebook/section title fields
if (isEditableElement(event.target)) {
keyBuffer.reset();
return;
}
keyBuffer.push(event.key);
if (keyBuffer.tryMatch(["d", "d"])) {
deleteFocusedCell(hook);
} else if (
hook.state.focusedCellType === "elixir" &&
(keyBuffer.tryMatch(["e", "e"]) || (cmd && key === "Enter"))
) {
queueFocusedCellEvaluation(hook);
} else if (keyBuffer.tryMatch(["e", "a"])) {
queueAllCellsEvaluation(hook);
} else if (keyBuffer.tryMatch(["e", "s"])) {
queueFocusedSectionEvaluation(hook);
} else if (keyBuffer.tryMatch(["e", "j"])) {
queueChildCellsEvaluation(hook);
} else if (keyBuffer.tryMatch(["s", "s"])) {
toggleSectionsPanel(hook);
} else if (keyBuffer.tryMatch(["s", "n"])) {
showNotebookSettings(hook);
} else if (keyBuffer.tryMatch(["e", "x"])) {
cancelFocusedCellEvaluation(hook);
} else if (keyBuffer.tryMatch(["?"])) {
showShortcuts(hook);
} else if (keyBuffer.tryMatch(["i"])) {
cancelEvent(event);
enterInsertMode(hook);
} else if (keyBuffer.tryMatch(["j"])) {
moveCellFocus(hook, 1);
} else if (keyBuffer.tryMatch(["k"])) {
moveCellFocus(hook, -1);
} else if (keyBuffer.tryMatch(["J"])) {
moveFocusedCell(hook, 1);
} else if (keyBuffer.tryMatch(["K"])) {
moveFocusedCell(hook, -1);
} else if (keyBuffer.tryMatch(["n"])) {
insertCellBelowFocused(hook, "elixir");
} else if (keyBuffer.tryMatch(["N"])) {
insertCellAboveFocused(hook, "elixir");
} else if (keyBuffer.tryMatch(["m"])) {
insertCellBelowFocused(hook, "markdown");
} else if (keyBuffer.tryMatch(["M"])) {
insertCellAboveFocused(hook, "markdown");
} else if (keyBuffer.tryMatch(["S"])) {
addSection(hook);
}
}
}
/**
* 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)
*/
function handleDocumentMouseDown(hook, event) {
// If click targets cell actions, keep the focus as is
if (
event.target.closest(
`[data-element="actions"], [data-element="insert-buttons"]`
)
) {
// If the pencil icon is clicked, enter insert mode
if (event.target.closest(`[data-element="enable-insert-mode-button"]`)) {
setInsertMode(hook, true);
}
return;
}
// Find the cell element, if one was clicked
const cell = event.target.closest(`[data-element="cell"]`);
const cellId = cell ? cell.dataset.cellId : null;
if (cellId !== hook.state.focusedCellId) {
setFocusedCell(hook, cellId);
}
// Depending on whether the click targets editor disable/enable insert mode
if (cell) {
const editorContainer = cell.querySelector(
`[data-element="editor-container"]`
);
const editorClicked = editorContainer.contains(event.target);
const insertMode = editorClicked;
if (hook.state.insertMode !== insertMode) {
setInsertMode(hook, insertMode);
}
}
}
/**
* Enters insert mode when a markdown cell is double-clicked.
*/
function handleDocumentDoubleClick(hook, event) {
const markdownCell = event.target.closest(
`[data-element="cell"][data-type="markdown"]`
);
if (markdownCell && hook.state.focusedCellId && !hook.state.insertMode) {
setInsertMode(hook, true);
}
}
/**
* Handles section link clicks in the section list.
*/
function handleSectionListClick(hook, event) {
const sectionButton = event.target.closest(
`[data-element="section-list-item"]`
);
if (sectionButton) {
const sectionId = sectionButton.getAttribute("data-section-id");
const section = getSectionById(sectionId);
section.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
/**
* Handles button clicks within cell indicators section.
*/
function handleCellIndicatorsClick(hook, event) {
const button = event.target.closest(`[data-element="focus-cell-button"]`);
if (button) {
const cellId = button.getAttribute("data-target");
setFocusedCell(hook, cellId);
}
}
/**
* Handles the main notebook area being scrolled.
*/
function updateSectionListHighlight() {
const currentListItem = document.querySelector(
`[data-element="section-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 = 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 = document.querySelector(
`[data-element="section-list-item"][data-section-id="${sectionId}"]`
);
listItem.setAttribute("data-js-is-viewed", "true");
}
}
// User action handlers (mostly keybindings)
function toggleSectionsPanel(hook) {
hook.el.toggleAttribute("data-js-sections-panel-expanded");
}
function showNotebookSettings(hook) {
hook.pushEvent("show_notebook_settings", {});
}
function deleteFocusedCell(hook) {
if (hook.state.focusedCellId) {
hook.pushEvent("delete_cell", { cell_id: hook.state.focusedCellId });
}
}
function queueFocusedCellEvaluation(hook) {
if (hook.state.focusedCellId) {
hook.pushEvent("queue_cell_evaluation", {
cell_id: hook.state.focusedCellId,
});
}
}
function queueAllCellsEvaluation(hook) {
hook.pushEvent("queue_all_cells_evaluation", {});
}
function queueFocusedSectionEvaluation(hook) {
if (hook.state.focusedSectionId) {
hook.pushEvent("queue_section_cells_evaluation", {
section_id: hook.state.focusedSectionId,
});
}
}
function queueChildCellsEvaluation(hook) {
if (hook.state.focusedCellId) {
hook.pushEvent("queue_child_cells_evaluation", {
cell_id: hook.state.focusedCellId,
});
}
}
function cancelFocusedCellEvaluation(hook) {
if (hook.state.focusedCellId) {
hook.pushEvent("cancel_cell_evaluation", {
cell_id: hook.state.focusedCellId,
});
}
}
function showShortcuts(hook) {
hook.pushEvent("show_shortcuts", {});
}
function enterInsertMode(hook) {
if (hook.state.focusedCellId) {
setInsertMode(hook, true);
}
}
function escapeInsertMode(hook) {
setInsertMode(hook, false);
}
function moveCellFocus(hook, offset) {
const cellId = nearbyCellId(hook.state.focusedCellId, offset);
setFocusedCell(hook, cellId);
}
function moveFocusedCell(hook, offset) {
if (hook.state.focusedCellId) {
hook.pushEvent("move_cell", { cell_id: hook.state.focusedCellId, offset });
}
}
function insertCellBelowFocused(hook, type) {
if (hook.state.focusedCellId) {
hook.pushEvent("insert_cell_below", {
cell_id: hook.state.focusedCellId,
type,
});
} else {
// If no cell is focused, insert below the last cell
const cellIds = getCellIds();
if (cellIds.length > 0) {
const lastCellId = cellIds[cellIds.length - 1];
hook.pushEvent("insert_cell_below", { cell_id: lastCellId, type });
} else {
insertFirstCell(hook, type);
}
}
}
function insertCellAboveFocused(hook, type) {
if (hook.state.focusedCellId) {
hook.pushEvent("insert_cell_above", {
cell_id: hook.state.focusedCellId,
type,
});
} else {
// If no cell is focused, insert above the first cell
const cellIds = getCellIds();
if (cellIds.length > 0) {
const lastCellId = cellIds[0];
hook.pushEvent("insert_cell_above", { cell_id: lastCellId, type });
} else {
insertFirstCell(hook, type);
}
}
}
function addSection(hook) {
hook.pushEvent("add_section", {});
}
function insertFirstCell(hook, type) {
const sectionIds = getSectionIds();
if (sectionIds.length > 0) {
hook.pushEvent("insert_cell", {
section_id: sectionIds[0],
index: 0,
type,
});
}
}
function setFocusedCell(hook, cellId) {
hook.state.focusedCellId = cellId;
if (hook.state.focusedCellId) {
const cell = getCellById(hook.state.focusedCellId);
hook.state.focusedCellType = cell.getAttribute("data-type");
hook.state.focusedSectionId = getSectionIdByCellId(
hook.state.focusedCellId
);
} else {
hook.state.focusedCellType = null;
hook.state.focusedSectionId = null;
}
globalPubSub.broadcast("session", { type: "cell_focused", cellId });
setInsertMode(hook, false);
}
function setInsertMode(hook, insertModeEnabled) {
hook.state.insertMode = insertModeEnabled;
if (insertModeEnabled) {
hook.el.setAttribute("data-js-insert-mode", "true");
} else {
hook.el.removeAttribute("data-js-insert-mode");
}
globalPubSub.broadcast("session", {
type: "insert_mode_changed",
enabled: insertModeEnabled,
});
}
// Server event handlers
function handleCellInserted(hook, cellId) {
setFocusedCell(hook, cellId);
setInsertMode(hook, true);
}
function handleCellDeleted(hook, cellId, siblingCellId) {
if (hook.state.focusedCellId === cellId) {
setFocusedCell(hook, siblingCellId);
}
}
function handleCellMoved(hook, cellId) {
if (hook.state.focusedCellId === cellId) {
globalPubSub.broadcast("session", { type: "cell_moved", cellId });
// The cell may have moved to another section, so update this information.
hook.state.focusedSectionId = getSectionIdByCellId(
hook.state.focusedCellId
);
}
}
function handleSectionInserted(hook, sectionId) {
if (hook.state.focusedSectionId) {
setFocusedCell(hook, null);
}
const section = getSectionById(sectionId);
const nameElement = section.querySelector(`[data-element="section-name"]`);
nameElement.focus({ preventScroll: true });
selectElementContent(nameElement);
smoothlyScrollToElement(nameElement);
}
function handleSectionDeleted(hook, sectionId) {
if (hook.state.focusedSectionId === sectionId) {
setFocusedCell(hook, null);
}
}
function focusNotebookNameIfNew() {
const sections = getSections();
const nameElement = document.querySelector(`[data-element="notebook-name"]`);
if (sections.length === 0 && nameElement.innerText === "Untitled notebook") {
nameElement.focus();
selectElementContent(nameElement);
}
}
// Helpers
function nearbyCellId(cellId, offset) {
const cellIds = getCellIds();
if (cellIds.length === 0) {
return null;
}
const idx = cellIds.indexOf(cellId);
if (idx === -1) {
return cellIds[0];
} else {
const siblingIdx = clamp(idx + offset, 0, cellIds.length - 1);
return cellIds[siblingIdx];
}
}
function getCellIds() {
const cells = getCells();
return cells.map((cell) => cell.getAttribute("data-cell-id"));
}
function getCells() {
return Array.from(document.querySelectorAll(`[data-element="cell"]`));
}
function getCellById(cellId) {
return document.querySelector(
`[data-element="cell"][data-cell-id="${cellId}"]`
);
}
function getSectionIdByCellId(cellId) {
const cell = document.querySelector(
`[data-element="cell"][data-cell-id="${cellId}"]`
);
const section = cell.closest(`[data-element="section"]`);
return section.getAttribute("data-section-id");
}
function getSectionIds() {
const sections = getSections();
return sections.map((section) => section.getAttribute("data-section-id"));
}
function getSections() {
return Array.from(document.querySelectorAll(`[data-element="section"]`));
}
function getSectionById(sectionId) {
return document.querySelector(
`[data-element="section"][data-section-id="${sectionId}"]`
);
}
function getSectionList() {
return document.querySelector(`[data-element="section-list"]`);
}
function getCellIndicators() {
return document.querySelector(`[data-element="notebook-indicators"]`);
}
function getNotebook() {
return document.querySelector(`[data-element="notebook"]`);
}
function getSectionsPanelToggle() {
return document.querySelector(`[data-element="sections-panel-toggle"]`);
}
function cancelEvent(event) {
// Cancel any default browser behavior.
event.preventDefault();
// Stop event propagation (e.g. so it doesn't reach the editor).
event.stopPropagation();
}
export default Session;