Move focus navigation to the client (#74)

* Show all sections and enable cross-section focus navigation

* Move focus to the client

* Add shortcut for evaluating all cells

* Fix and expand tests

* Make section links scroll to the given section
This commit is contained in:
Jonatan Kłosko 2021-03-11 15:28:18 +01:00 committed by GitHub
parent ac9b5526fe
commit 266bf35bd0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 912 additions and 497 deletions

View file

@ -10,3 +10,4 @@
@import "./live_view.css";
@import "./markdown.css";
@import "./ansi.css";
@import "./session.css";

40
assets/css/session.css Normal file
View file

@ -0,0 +1,40 @@
/*
Conditional notebook elements display.
The Section and Cell hooks dynamically set attributes
based on which we hide/show certain elements.
This way we don't have to engage the server in
solely client-side operations like moving the focus
and entering/escaping insert mode.
*/
[data-element="session"]:not([data-js-insert-mode])
[data-element="insert-indicator"] {
@apply hidden;
}
[data-element="session"]
[data-element="cell"][data-type="markdown"]
[data-element="editor-box"] {
@apply hidden;
}
[data-element="session"][data-js-insert-mode]
[data-element="cell"][data-type="markdown"][data-js-focused]
[data-element="editor-box"] {
@apply block;
}
[data-element="session"][data-js-insert-mode]
[data-element="cell"][data-type="markdown"][data-js-focused]
[data-element="enable-insert-mode-button"] {
@apply hidden;
}
[data-element="cell"][data-js-focused] {
@apply border-blue-300 border-opacity-100;
}
[data-element="cell"]:not([data-js-focused]) [data-element="actions"] {
@apply hidden;
}

View file

@ -27,6 +27,17 @@ const csrfToken = document
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
dom: {
onBeforeElUpdated(from, to) {
// Keep element attributes starting with data-js-
// which we set on the client.
for (const attr of from.attributes) {
if (attr.name.startsWith("data-js-")) {
to.setAttribute(attr.name, attr.value);
}
}
},
},
});
// Show progress bar on live navigation and form submits

View file

@ -1,10 +1,8 @@
import {
getAttributeOrThrow,
parseBoolean,
parseInteger,
} from "../lib/attribute";
import { getAttributeOrThrow } from "../lib/attribute";
import LiveEditor from "./live_editor";
import Markdown from "./markdown";
import { globalPubSub } from "../lib/pub_sub";
import { smoothlyScrollToElement } from "../lib/utils";
/**
* A hook managing a single cell.
@ -16,24 +14,27 @@ import Markdown from "./markdown";
*
* * `data-cell-id` - id of the cell being edited
* * `data-type` - editor type (i.e. language), either "markdown" or "elixir" is expected
* * `data-focused` - whether the cell is currently focused
* * `data-insert-mode` - whether insert mode is currently enabled
*/
const Cell = {
mounted() {
this.props = getProps(this);
this.state = {
liveEditor: null,
isFocused: false,
insertMode: false,
};
this.pushEvent("cell_init", { cell_id: this.props.cellId }, (payload) => {
const { source, revision } = payload;
const editorContainer = this.el.querySelector("[data-editor-container]");
const editorContainer = this.el.querySelector(`[data-element="editor-container"]`);
// Remove the content placeholder.
editorContainer.firstElementChild.remove();
// Create an empty container for the editor to be mounted in.
const editorElement = document.createElement("div");
editorContainer.appendChild(editorElement);
// Setup the editor instance.
this.liveEditor = new LiveEditor(
this.state.liveEditor = new LiveEditor(
this,
editorElement,
this.props.cellId,
@ -45,52 +46,43 @@ const Cell = {
// Setup markdown rendering.
if (this.props.type === "markdown") {
const markdownContainer = this.el.querySelector(
"[data-markdown-container]"
`[data-element="markdown-container"]`
);
const markdown = new Markdown(markdownContainer, source);
this.liveEditor.onChange((newSource) => {
this.state.liveEditor.onChange((newSource) => {
markdown.setContent(newSource);
});
}
// New cells are initially focused, so check for such case.
if (isEditorActive(this.props)) {
this.liveEditor.focus();
// Once the editor is created, reflect the current state.
if (this.state.isFocused && this.state.insertMode) {
this.state.liveEditor.focus();
// If the element is being scrolled to, focus interrupts it,
// so ensure the scrolling continues.
smoothlyScrollToElement(this.el);
}
if (this.props.isFocused) {
this.el.scrollIntoView({ behavior: "smooth", block: "center" });
}
this.liveEditor.onBlur(() => {
if (isEditorActive(this.props)) {
this.liveEditor.focus();
this.state.liveEditor.onBlur(() => {
// Prevent from blurring unless the state changes.
// For example when we move cell using buttons
// the editor should keep focus.
if (this.state.isFocused && this.state.insertMode) {
this.state.liveEditor.focus();
}
});
});
this.handleSessionEvent = (event) => handleSessionEvent(this, event);
globalPubSub.subscribe("session", this.handleSessionEvent);
},
destroyed() {
globalPubSub.unsubscribe("session", this.handleSessionEvent);
},
updated() {
const prevProps = this.props;
this.props = getProps(this);
// Note: this.liveEditor is crated once we receive initial data
// so here we have to make sure it's defined.
if (!isEditorActive(prevProps) && isEditorActive(this.props)) {
this.liveEditor && this.liveEditor.focus();
}
if (isEditorActive(prevProps) && !isEditorActive(this.props)) {
this.liveEditor && this.liveEditor.blur();
}
if (!prevProps.isFocused && this.props.isFocused) {
// Note: it's important to trigger scrolling after focus, so it doesn't get interrupted.
this.el.scrollIntoView({ behavior: "smooth", block: "center" });
}
},
};
@ -98,16 +90,49 @@ function getProps(hook) {
return {
cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
type: getAttributeOrThrow(hook.el, "data-type"),
isFocused: getAttributeOrThrow(hook.el, "data-focused", parseBoolean),
insertMode: getAttributeOrThrow(hook.el, "data-insert-mode", parseBoolean),
};
}
/**
* Checks if the cell editor is active and should have focus.
* Handles client-side session event.
*/
function isEditorActive(props) {
return props.isFocused && props.insertMode;
function handleSessionEvent(hook, event) {
if (event.type === "cell_focused") {
handleCellFocused(hook, event.cellId);
} else if (event.type === "insert_mode_changed") {
handleInsertModeChanged(hook, event.enabled);
} else if (event.type === "cell_moved") {
handleCellMoved(hook, event.cellId);
}
}
function handleCellFocused(hook, cellId) {
if (hook.props.cellId === cellId) {
hook.state.isFocused = true;
hook.el.setAttribute("data-js-focused", "true");
smoothlyScrollToElement(hook.el);
} else if (hook.state.isFocused) {
hook.state.isFocused = false;
hook.el.removeAttribute("data-js-focused");
}
}
function handleInsertModeChanged(hook, insertMode) {
if (hook.state.isFocused) {
hook.state.insertMode = insertMode;
if (hook.state.insertMode) {
hook.state.liveEditor && hook.state.liveEditor.focus();
} else {
hook.state.liveEditor && hook.state.liveEditor.blur();
}
}
}
function handleCellMoved(hook, cellId) {
if (hook.state.isFocused && cellId === hook.props.cellId) {
smoothlyScrollToElement(hook.el);
}
}
export default Cell;

50
assets/js/lib/pub_sub.js Normal file
View file

@ -0,0 +1,50 @@
/**
* A basic pub-sub implementation for client-side communication.
*/
export default class PubSub {
constructor() {
this.subscribersByTopic = {};
}
/**
* Links the given function to the given topic.
*
* Subsequent calls to `broadcast` with this topic
* will result in this function being called.
*/
subscribe(topic, callback) {
if (!Array.isArray(this.subscribersByTopic[topic])) {
this.subscribersByTopic[topic] = [];
}
this.subscribersByTopic[topic].push(callback);
}
/**
* Unlinks the given function from the given topic.
*
* Note that you must pass the same function reference
* as you passed to `subscribe`.
*/
unsubscribe(topic, callback) {
const idx = this.subscribersByTopic[topic].indexOf(callback);
if (idx !== -1) {
this.subscribersByTopic[topic].splice(idx, 1);
}
}
/**
* Calls all functions linked to the given topic
* and passes `payload` as the argument.
*/
broadcast(topic, payload) {
if (Array.isArray(this.subscribersByTopic[topic])) {
this.subscribersByTopic[topic].forEach((callback) => {
callback(payload);
});
}
}
}
export const globalPubSub = new PubSub();

View file

@ -8,3 +8,38 @@ export function isEditableElement(element) {
element.contentEditable === "true"
);
}
export function clamp(n, x, y) {
return Math.min(Math.max(n, x), y);
}
export function getLineHeight(element) {
const computedStyle = window.getComputedStyle(element);
const lineHeight = parseInt(computedStyle.lineHeight, 10);
if (Number.isNaN(lineHeight)) {
const clone = element.cloneNode();
clone.innerHTML = "<br>";
element.appendChild(clone);
const singleLineHeight = clone.clientHeight;
clone.innerHTML = "<br><br>";
const doubleLineHeight = clone.clientHeight;
element.removeChild(clone);
const lineHeight = doubleLineHeight - singleLineHeight;
return lineHeight;
} else {
return lineHeight;
}
}
export function selectElementContent(element) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
}
export function smoothlyScrollToElement(element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
}

View file

@ -1,141 +1,448 @@
import { getAttributeOrThrow, parseBoolean } from "../lib/attribute";
import { isMacOS, isEditableElement } from "../lib/utils";
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.
*
* Registers event listeners to handle keybindings and focus events.
*
* Configuration:
*
* * `data-focused-cell-id` - id of the cell currently being focused
* Handles keybindings, focus changes and insert mode changes.
*/
const Session = {
mounted() {
this.props = getProps(this);
// Keybindings
// Note: make sure to keep the shortcuts help modal up to date.
const keyBuffer = new KeyBuffer();
this.handleDocumentKeydown = (event) => {
if (event.repeat) {
return;
}
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
const key = event.key;
if (this.props.insertMode) {
keyBuffer.reset();
if (key === "Escape") {
this.pushEvent("set_insert_mode", { enabled: false });
} else if (
this.props.focusedCellType === "elixir" &&
cmd &&
key === "Enter"
) {
cancelEvent(event);
this.pushEvent("queue_focused_cell_evaluation");
}
} else {
if (isEditableElement(event.target)) {
keyBuffer.reset();
return;
}
keyBuffer.push(event.key);
if (keyBuffer.tryMatch(["d", "d"])) {
this.pushEvent("delete_focused_cell", {});
} else if (
this.props.focusedCellType === "elixir" &&
(keyBuffer.tryMatch(["e", "e"]) || (cmd && key === "Enter"))
) {
this.pushEvent("queue_focused_cell_evaluation", {});
} else if (keyBuffer.tryMatch(["e", "s"])) {
this.pushEvent("queue_section_cells_evaluation", {});
} else if (keyBuffer.tryMatch(["e", "j"])) {
this.pushEvent("queue_child_cells_evaluation", {});
} else if (keyBuffer.tryMatch(["e", "x"])) {
this.pushEvent("cancel_focused_cell_evaluation", {});
} else if (keyBuffer.tryMatch(["?"])) {
this.pushEvent("show_shortcuts", {});
} else if (key === "i") {
this.pushEvent("set_insert_mode", { enabled: true });
} else if (key === "j") {
this.pushEvent("move_cell_focus", { offset: 1 });
} else if (key === "k") {
this.pushEvent("move_cell_focus", { offset: -1 });
} else if (key === "J") {
this.pushEvent("move_focused_cell", { offset: 1 });
} else if (key === "K") {
this.pushEvent("move_focused_cell", { offset: -1 });
} else if (key === "n") {
this.pushEvent("insert_cell_below_focused", { type: "elixir" });
} else if (key === "N") {
this.pushEvent("insert_cell_above_focused", { type: "elixir" });
} else if (key === "m") {
this.pushEvent("insert_cell_below_focused", { type: "markdown" });
} else if (key === "M") {
this.pushEvent("insert_cell_above_focused", { type: "markdown" });
}
}
this.state = {
focusedCellId: null,
focusedSectionId: null,
focusedCellType: null,
insertMode: false,
keyBuffer: new KeyBuffer(),
};
document.addEventListener("keydown", this.handleDocumentKeydown, true);
// DOM events
// Focus/unfocus 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)
this.handleDocumentKeyDown = (event) => {
handleDocumentKeyDown(this, event);
};
document.addEventListener("keydown", this.handleDocumentKeyDown, true);
this.handleDocumentMouseDown = (event) => {
// If click targets cell actions, keep the focus as is
if (event.target.closest("[data-cell-actions]")) {
return;
}
// Find the parent with cell id info, if there is one
const cell = event.target.closest("[data-cell-id]");
const cellId = cell ? cell.dataset.cellId : null;
if (cellId !== this.props.focusedCellId) {
this.pushEvent("focus_cell", { cell_id: cellId });
}
// Depending if the click targets editor or not disable/enable insert mode.
if (cell) {
const editorContainer = cell.querySelector("[data-editor-container]");
const editorClicked = editorContainer.contains(event.target);
this.pushEvent("set_insert_mode", { enabled: editorClicked });
}
handleDocumentMouseDown(this, event);
};
document.addEventListener("mousedown", this.handleDocumentMouseDown);
},
updated() {
this.props = getProps(this);
this.el.querySelector(`[data-element="section-list"]`).addEventListener("click", event => {
handleSectionListClick(this, event);
});
// 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("keydown", this.handleDocumentKeyDown);
document.removeEventListener("mousedown", this.handleDocumentMouseDown);
},
};
function getProps(hook) {
return {
insertMode: getAttributeOrThrow(hook.el, "data-insert-mode", parseBoolean),
focusedCellId: getAttributeOrThrow(
hook.el,
"data-focused-cell-id",
(value) => value || null
),
focusedCellType: getAttributeOrThrow(hook.el, "data-focused-cell-type"),
};
/**
* 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 (
hook.state.focusedCellType === "elixir" &&
cmd &&
key === "Enter"
) {
cancelEvent(event);
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(["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");
}
}
}
/**
* 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"]`)) {
// 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);
}
}
}
/**
* 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" });
}
}
// User action handlers (mostly keybindings)
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 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) {
const section = getSectionById(sectionId);
const nameElement = section.querySelector("[data-section-name]");
nameElement.focus({ preventScroll: true });
selectElementContent(nameElement);
smoothlyScrollToElement(nameElement);
}
function handleSectionDeleted(hook, sectionId) {
if (hook.state.focusedSectionId === sectionId) {
setFocusedCell(hook, null);
}
}
// 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 cancelEvent(event) {

View file

@ -1,5 +1,6 @@
import HyperList from "hyperlist";
import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
import { getLineHeight } from "../lib/utils";
/**
* A hook used to render text lines as a virtual list,
@ -20,39 +21,46 @@ import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
const VirtualizedLines = {
mounted() {
this.props = getProps(this);
this.state = {
lineHeight: null,
templateElement: null,
contentElement: null,
virtualizedList: null,
};
const computedStyle = window.getComputedStyle(this.el);
this.lineHeight = parseInt(computedStyle.lineHeight, 10);
this.state.lineHeight = getLineHeight(this.el);
this.templateElement = this.el.querySelector('[data-template]');
this.state.templateElement = this.el.querySelector("[data-template]");
if (!this.templateElement) {
throw new Error('VirtualizedLines must have a child with data-template attribute');
if (!this.state.templateElement) {
throw new Error(
"VirtualizedLines must have a child with data-template attribute"
);
}
this.contentElement = this.el.querySelector('[data-content]');
this.state.contentElement = this.el.querySelector("[data-content]");
if (!this.templateElement) {
throw new Error('VirtualizedLines must have a child with data-content');
if (!this.state.templateElement) {
throw new Error("VirtualizedLines must have a child with data-content");
}
const config = hyperListConfig(
this.templateElement,
this.state.templateElement,
this.props.maxHeight,
this.lineHeight
this.state.lineHeight
);
this.virtualizedList = new HyperList(this.contentElement, config);
this.virtualizedList = new HyperList(this.state.contentElement, config);
},
updated() {
this.props = getProps(this);
const config = hyperListConfig(
this.templateElement,
this.state.templateElement,
this.props.maxHeight,
this.lineHeight
this.state.lineHeight
);
this.virtualizedList.refresh(this.contentElement, config);
this.virtualizedList.refresh(this.state.contentElement, config);
},
};

View file

@ -7,7 +7,7 @@
"watch": "webpack --mode development --watch",
"format": "prettier --trailing-comma es5 --write {js,test,css}/**/*.{js,json,css,scss,md}",
"test": "jest",
"test:watch": "jest"
"test:watch": "jest --watch"
},
"dependencies": {
"dompurify": "^2.2.6",

View file

@ -0,0 +1,27 @@
import PubSub from "../../js/lib/pub_sub";
describe("PubSub", () => {
test("subscribed callback is called on the specified topic", () => {
const pubsub = new PubSub();
const callback1 = jest.fn();
const callback2 = jest.fn();
pubsub.subscribe('topic1', callback1);
pubsub.subscribe('topic2', callback2);
pubsub.broadcast('topic1', { data: 1 });
expect(callback1).toHaveBeenCalledWith({ data: 1 });
expect(callback2).not.toHaveBeenCalled();
});
test("unsubscribed callback is not called on the specified topic", () => {
const pubsub = new PubSub();
const callback1 = jest.fn();
pubsub.subscribe('topic1', callback1);
pubsub.unsubscribe('topic1', callback1);
pubsub.broadcast('topic1', {});
expect(callback1).not.toHaveBeenCalled();
});
});

View file

@ -67,19 +67,18 @@ defmodule Livebook.Notebook do
end
@doc """
Finds a cell being `offset` from the given cell within the same section.
Finds a cell being `offset` from the given cell (with regard to all sections).
"""
@spec fetch_cell_sibling(t(), Cell.id(), integer()) :: {:ok, Cell.t()} | :error
def fetch_cell_sibling(notebook, cell_id, offset) do
with {:ok, cell, section} <- fetch_cell_and_section(notebook, cell_id) do
idx = Enum.find_index(section.cells, &(&1 == cell))
sibling_idx = idx + offset
all_cells = for(section <- notebook.sections, cell <- section.cells, do: cell)
if sibling_idx >= 0 and sibling_idx < length(section.cells) do
{:ok, Enum.at(section.cells, sibling_idx)}
else
:error
end
with idx when idx != nil <- Enum.find_index(all_cells, &(&1.id == cell_id)),
sibling_idx <- idx + offset,
true <- 0 <= sibling_idx and sibling_idx < length(all_cells) do
{:ok, Enum.at(all_cells, sibling_idx)}
else
_ -> :error
end
end
@ -165,20 +164,48 @@ defmodule Livebook.Notebook do
end
@doc """
Moves cell within the given section at the specified position to a new position.
"""
@spec move_cell(t(), Section.id(), non_neg_integer(), non_neg_integer()) :: t()
def move_cell(notebook, section_id, from_idx, to_idx) do
update_section(notebook, section_id, fn section ->
{cell, cells} = List.pop_at(section.cells, from_idx)
Moves cell by the given offset.
if cell do
cells = List.insert_at(cells, to_idx, cell)
%{section | cells: cells}
else
section
end
end)
The cell may move to another section if the offset indicates so.
"""
@spec move_cell(t(), Cell.id(), integer()) :: t()
def move_cell(notebook, cell_id, offset) do
# We firstly create a flat list of cells interspersed with `:separator`
# at section boundaries. Then we move the given cell by the given offset.
# Finally we split the flat list back into cell lists
# and put them in the corresponding sections.
separated_cells =
notebook.sections
|> Enum.map_intersperse(:separator, & &1.cells)
|> List.flatten()
idx =
Enum.find_index(separated_cells, fn
:separator -> false
cell -> cell.id == cell_id
end)
new_idx = (idx + offset) |> clamp_index(separated_cells)
{cell, separated_cells} = List.pop_at(separated_cells, idx)
separated_cells = List.insert_at(separated_cells, new_idx, cell)
cell_groups =
separated_cells
|> Enum.chunk_by(&(&1 == :separator))
|> Enum.reject(&(&1 == [:separator]))
sections =
notebook.sections
|> Enum.zip(cell_groups)
|> Enum.map(fn {section, cells} -> %{section | cells: cells} end)
%{notebook | sections: sections}
end
defp clamp_index(index, list) do
index |> max(0) |> min(length(list) - 1)
end
@doc """

View file

@ -215,11 +215,11 @@ defmodule Livebook.Session.Data do
end
def apply_operation(data, {:move_cell, _client_pid, id, offset}) do
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id),
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, id),
true <- offset != 0 do
data
|> with_actions()
|> move_cell(cell, section, offset)
|> move_cell(cell, offset)
|> set_dirty()
|> wrap_ok()
else
@ -443,11 +443,8 @@ defmodule Livebook.Session.Data do
|> set!(cell_infos: Map.delete(data.cell_infos, cell.id))
end
defp move_cell({data, _} = data_actions, cell, section, offset) do
idx = Enum.find_index(section.cells, &(&1 == cell))
new_idx = (idx + offset) |> clamp_index(section.cells)
updated_notebook = Notebook.move_cell(data.notebook, section.id, idx, new_idx)
defp move_cell({data, _} = data_actions, cell, offset) do
updated_notebook = Notebook.move_cell(data.notebook, cell.id, offset)
cells_with_section_before = Notebook.elixir_cells_with_section(data.notebook)
cells_with_section_after = Notebook.elixir_cells_with_section(updated_notebook)
@ -466,10 +463,6 @@ defmodule Livebook.Session.Data do
|> unqueue_cells_evaluation(affected_cells_with_section)
end
defp clamp_index(index, list) do
index |> max(0) |> min(length(list) - 1)
end
defp queue_cell_evaluation(data_actions, cell, section) do
data_actions
|> update_section_info!(section.id, fn section ->

View file

@ -3,13 +3,12 @@ defmodule LivebookWeb.CellComponent do
def render(assigns) do
~L"""
<div class="cell flex flex-col relative mr-10 border-l-4 pl-4 -ml-4 border-blue-100 border-opacity-0 hover:border-opacity-100 <%= if @focused, do: "border-blue-300 border-opacity-100"%>"
<div class="cell flex flex-col relative mr-10 border-l-4 pl-4 -ml-4 border-blue-100 border-opacity-0 hover:border-opacity-100"
data-element="cell"
id="cell-<%= @cell.id %>"
phx-hook="Cell"
data-cell-id="<%= @cell.id %>"
data-type="<%= @cell.type %>"
data-focused="<%= @focused %>"
data-insert-mode="<%= @insert_mode %>">
data-type="<%= @cell.type %>">
<%= render_cell_content(assigns) %>
</div>
"""
@ -17,34 +16,34 @@ defmodule LivebookWeb.CellComponent do
def render_cell_content(%{cell: %{type: :markdown}} = assigns) do
~L"""
<%= if @focused do %>
<div class="flex flex-col items-center space-y-2 absolute z-50 right-0 top-0 -mr-10" data-cell-actions>
<%= unless @insert_mode do %>
<button phx-click="enable_insert_mode" class="text-gray-500 hover:text-current">
<%= Icons.svg(:pencil, class: "h-6") %>
</button>
<% end %>
<button phx-click="delete_focused_cell" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
<button class="text-gray-500 hover:text-current"
phx-click="move_focused_cell"
phx-value-offset="-1">
<%= Icons.svg(:chevron_up, class: "h-6") %>
</button>
<button class="text-gray-500 hover:text-current"
phx-click="move_focused_cell"
phx-value-offset="1">
<%= Icons.svg(:chevron_down, class: "h-6") %>
</button>
</div>
<% end %>
<div class="flex flex-col items-center space-y-2 absolute z-50 right-0 top-0 -mr-10" data-element="actions">
<button class="text-gray-500 hover:text-current" data-element="enable-insert-mode-button">
<%= Icons.svg(:pencil, class: "h-6") %>
</button>
<button class="text-gray-500 hover:text-current"
phx-click="delete_cell"
phx-value-cell_id="<%= @cell.id %>">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
<button class="text-gray-500 hover:text-current"
phx-click="move_cell"
phx-value-cell_id="<%= @cell.id %>"
phx-value-offset="-1">
<%= Icons.svg(:chevron_up, class: "h-6") %>
</button>
<button class="text-gray-500 hover:text-current"
phx-click="move_cell"
phx-value-cell_id="<%= @cell.id %>"
phx-value-offset="1">
<%= Icons.svg(:chevron_down, class: "h-6") %>
</button>
</div>
<div class="<%= if @focused and @insert_mode, do: "mb-4", else: "hidden" %>">
<div class="pb-4" data-element="editor-box">
<%= render_editor(@cell, @cell_info) %>
</div>
<div class="markdown" data-markdown-container id="markdown-container-<%= @cell.id %>" phx-update="ignore">
<div class="markdown" data-element="markdown-container" id="markdown-container-<%= @cell.id %>" phx-update="ignore">
<%= render_markdown_content_placeholder(@cell.source) %>
</div>
"""
@ -52,35 +51,41 @@ defmodule LivebookWeb.CellComponent do
def render_cell_content(%{cell: %{type: :elixir}} = assigns) do
~L"""
<%= if @focused do %>
<div class="flex flex-col items-center space-y-2 absolute z-50 right-0 top-0 -mr-10" data-cell-actions>
<%= if @cell_info.evaluation_status == :ready do %>
<button phx-click="queue_focused_cell_evaluation" class="text-gray-500 hover:text-current">
<%= Icons.svg(:play, class: "h-6") %>
</button>
<% else %>
<button phx-click="cancel_focused_cell_evaluation" class="text-gray-500 hover:text-current">
<%= Icons.svg(:stop, class: "h-6") %>
</button>
<% end %>
<button phx-click="delete_focused_cell" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
<%= live_patch to: Routes.session_path(@socket, :cell_settings, @session_id, @cell.id), class: "text-gray-500 hover:text-current" do %>
<%= Icons.svg(:adjustments, class: "h-6") %>
<% end %>
<div class="flex flex-col items-center space-y-2 absolute z-50 right-0 top-0 -mr-10" data-element="actions">
<%= if @cell_info.evaluation_status == :ready do %>
<button class="text-gray-500 hover:text-current"
phx-click="move_focused_cell"
phx-value-offset="-1">
<%= Icons.svg(:chevron_up, class: "h-6") %>
phx-click="queue_cell_evaluation"
phx-value-cell_id="<%= @cell.id %>">
<%= Icons.svg(:play, class: "h-6") %>
</button>
<% else %>
<button class="text-gray-500 hover:text-current"
phx-click="move_focused_cell"
phx-value-offset="1">
<%= Icons.svg(:chevron_down, class: "h-6") %>
phx-click="cancel_cell_evaluation"
phx-value-cell_id="<%= @cell.id %>">
<%= Icons.svg(:stop, class: "h-6") %>
</button>
</div>
<% end %>
<% end %>
<button class="text-gray-500 hover:text-current"
phx-click="delete_cell"
phx-value-cell_id="<%= @cell.id %>">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
<%= live_patch to: Routes.session_path(@socket, :cell_settings, @session_id, @cell.id), class: "text-gray-500 hover:text-current" do %>
<%= Icons.svg(:adjustments, class: "h-6") %>
<% end %>
<button class="text-gray-500 hover:text-current"
phx-click="move_cell"
phx-value-cell_id="<%= @cell.id %>"
phx-value-offset="-1">
<%= Icons.svg(:chevron_up, class: "h-6") %>
</button>
<button class="text-gray-500 hover:text-current"
phx-click="move_cell"
phx-value-cell_id="<%= @cell.id %>"
phx-value-offset="1">
<%= Icons.svg(:chevron_down, class: "h-6") %>
</button>
</div>
<%= render_editor(@cell, @cell_info, show_status: true) %>
@ -100,7 +105,7 @@ defmodule LivebookWeb.CellComponent do
<div class="py-3 rounded-md overflow-hidden bg-editor relative">
<div
id="editor-container-<%= @cell.id %>"
data-editor-container
data-element="editor-container"
phx-update="ignore">
<%= render_editor_content_placeholder(@cell.source) %>
</div>

View file

@ -21,7 +21,8 @@ defmodule LivebookWeb.PathSelectComponent do
name="path"
placeholder="File"
value="<%= @path %>"
spellcheck="false" />
spellcheck="false"
autocomplete="off" />
</form>
<div class="h-80 -m-1 p-1 overflow-y-auto tiny-scrollbar">
<div class="grid grid-cols-4 gap-2">

View file

@ -3,10 +3,11 @@ defmodule LivebookWeb.SectionComponent do
def render(assigns) do
~L"""
<div class="<%= if not @selected, do: "hidden" %>">
<div data-element="section" data-section-id="<%= @section.id %>">
<div class="flex space-x-4 items-center">
<div class="flex flex-grow space-x-2 items-center text-gray-600">
<h2 class="flex-grow text-gray-900 font-semibold text-3xl py-2 border-b-2 border-transparent hover:border-blue-100 focus:border-blue-300"
data-section-name
id="section-<%= @section.id %>-name"
contenteditable
spellcheck="false"
@ -18,13 +19,13 @@ defmodule LivebookWeb.SectionComponent do
because we want the content to exactly match @section.name. %>
</div>
<div class="flex space-x-2 items-center">
<button phx-click="delete_section" phx-value-section_id="<%= @section.id %>" class="text-gray-600 hover:text-current">
<button phx-click="delete_section" phx-value-section_id="<%= @section.id %>" class="text-gray-600 hover:text-current" tabindex="-1">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
</div>
<div class="container py-2">
<div class="flex flex-col space-y-2 pb-80">
<div class="flex flex-col space-y-2">
<%= live_component @socket, LivebookWeb.InsertCellComponent,
id: "#{@section.id}:0",
section_id: @section.id,
@ -35,9 +36,7 @@ defmodule LivebookWeb.SectionComponent do
id: cell.id,
session_id: @session_id,
cell: cell,
cell_info: @cell_infos[cell.id],
focused: @selected and cell.id == @focused_cell_id,
insert_mode: @insert_mode %>
cell_info: @cell_infos[cell.id] %>
<%= live_component @socket, LivebookWeb.InsertCellComponent,
id: "#{@section.id}:#{index + 1}",
section_id: @section.id,

View file

@ -34,20 +34,10 @@ defmodule LivebookWeb.SessionLive do
end
defp initial_assigns(session_id, data, platform) do
first_section_id =
case data.notebook.sections do
[section | _] -> section.id
[] -> nil
end
%{
platform: platform,
session_id: session_id,
data: data,
selected_section_id: first_section_id,
focused_cell_id: nil,
focused_cell_type: nil,
insert_mode: false
data: data
}
end
@ -87,28 +77,17 @@ defmodule LivebookWeb.SessionLive do
<div class="flex flex-grow h-full"
id="session"
phx-hook="Session"
data-insert-mode="<%= @insert_mode %>"
data-focused-cell-id="<%= @focused_cell_id %>"
data-focused-cell-type="<%= @focused_cell_type %>">
data-element="session"
phx-hook="Session">
<div class="flex flex-col w-1/5 bg-gray-100 border-r border-gray-200">
<h1 class="m-6 py-1 text-2xl border-b-2 border-transparent hover:border-blue-100 focus:border-blue-300"
id="notebook-name"
contenteditable
spellcheck="false"
phx-blur="set_notebook_name"
phx-hook="ContentEditable"
data-update-attribute="phx-value-name"><%= @data.notebook.name %></h1>
<div class="flex-grow flex flex-col space-y-2 pl-4">
<div class="flex-grow flex flex-col space-y-2 pl-4 pt-4"
data-element="section-list">
<%= for section <- @data.notebook.sections do %>
<div phx-click="select_section"
phx-value-section_id="<%= section.id %>"
class="py-2 px-4 rounded-l-md cursor-pointer hover:text-current border border-r-0 <%= if(section.id == @selected_section_id, do: "bg-white border-gray-200", else: "text-gray-500 border-transparent") %>">
<span>
<%= section.name %>
</span>
</div>
<button class="py-2 px-4 rounded-l-md text-left hover:text-current text-gray-500"
data-element="section-list-item"
data-section-id="<%= section.id %>">
<%= section.name %>
</button>
<% end %>
<button phx-click="add_section" class="py-2 px-4 rounded-l-md cursor-pointer text-gray-300 hover:text-gray-400">
<div class="flex items-center space-x-2">
@ -153,26 +132,31 @@ defmodule LivebookWeb.SessionLive do
</div>
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
<div class="max-w-screen-lg w-full mx-auto">
<%= for section <- @data.notebook.sections do %>
<%= live_component @socket, LivebookWeb.SectionComponent,
id: section.id,
session_id: @session_id,
section: section,
selected: section.id == @selected_section_id,
cell_infos: @data.cell_infos,
focused_cell_id: @focused_cell_id,
insert_mode: @insert_mode %>
<% end %>
<div class="mb-8">
<h1 class="text-gray-900 font-semibold text-4xl pb-2 border-b-2 border-transparent hover:border-blue-100 focus:border-blue-300"
id="notebook-name"
contenteditable
spellcheck="false"
phx-blur="set_notebook_name"
phx-hook="ContentEditable"
data-update-attribute="phx-value-name"><%= @data.notebook.name %></h1>
</div>
<div class="flex flex-col space-y-16 pb-80">
<%= for section <- @data.notebook.sections do %>
<%= live_component @socket, LivebookWeb.SectionComponent,
id: section.id,
session_id: @session_id,
section: section,
cell_infos: @data.cell_infos %>
<% end %>
</div>
</div>
</div>
</div>
<%= if @insert_mode do %>
<%# Show a tiny insert indicator for clarity %>
<div class="fixed right-5 bottom-1 text-gray-500 text-semibold text-sm">
<div class="fixed right-5 bottom-1 text-gray-500 text-semibold text-sm" data-element="insert-indicator">
insert
</div>
<% end %>
</div>
"""
end
@ -217,40 +201,34 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("select_section", %{"section_id" => section_id}, socket) do
{:noreply, assign(socket, selected_section_id: section_id)}
end
def handle_event(
"insert_cell",
%{"section_id" => section_id, "index" => index, "type" => type},
socket
) do
index = String.to_integer(index) |> max(0)
index = ensure_integer(index) |> max(0)
type = String.to_atom(type)
Session.insert_cell(socket.assigns.session_id, section_id, index, type)
{:noreply, socket}
end
def handle_event("insert_cell_below_focused", %{"type" => type}, socket) do
def handle_event("insert_cell_below", %{"cell_id" => cell_id, "type" => type}, socket) do
type = String.to_atom(type)
insert_cell_next_to_focused(socket.assigns, type, idx_offset: 1)
insert_cell_next_to(socket.assigns, cell_id, type, idx_offset: 1)
{:noreply, socket}
end
def handle_event("insert_cell_above_focused", %{"type" => type}, socket) do
def handle_event("insert_cell_above", %{"cell_id" => cell_id, "type" => type}, socket) do
type = String.to_atom(type)
insert_cell_next_to_focused(socket.assigns, type, idx_offset: 0)
insert_cell_next_to(socket.assigns, cell_id, type, idx_offset: 0)
{:noreply, socket}
end
def handle_event("delete_focused_cell", %{}, socket) do
if socket.assigns.focused_cell_id do
Session.delete_cell(socket.assigns.session_id, socket.assigns.focused_cell_id)
end
def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do
Session.delete_cell(socket.assigns.session_id, cell_id)
{:noreply, socket}
end
@ -290,74 +268,20 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("focus_cell", %{"cell_id" => nil}, socket) do
{:noreply, focus_cell(socket, nil)}
end
def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do
case Notebook.fetch_cell_and_section(socket.assigns.data.notebook, cell_id) do
{:ok, cell, _section} ->
{:noreply, focus_cell(socket, cell)}
:error ->
{:noreply, socket}
end
end
def handle_event("move_cell_focus", %{"offset" => offset}, socket) do
def handle_event("move_cell", %{"cell_id" => cell_id, "offset" => offset}, socket) do
offset = ensure_integer(offset)
case new_focused_cell_from_offset(socket.assigns, offset) do
{:ok, cell} ->
{:noreply, focus_cell(socket, cell)}
:error ->
{:noreply, socket}
end
end
def handle_event("move_focused_cell", %{"offset" => offset}, socket) do
offset = ensure_integer(offset)
if socket.assigns.focused_cell_id do
Session.move_cell(socket.assigns.session_id, socket.assigns.focused_cell_id, offset)
end
Session.move_cell(socket.assigns.session_id, cell_id, offset)
{:noreply, socket}
end
def handle_event("set_insert_mode", %{"enabled" => enabled}, socket) do
if socket.assigns.focused_cell_id do
{:noreply, assign(socket, insert_mode: enabled)}
else
{:noreply, socket}
end
end
def handle_event("enable_insert_mode", %{}, socket) do
if socket.assigns.focused_cell_id do
{:noreply, assign(socket, insert_mode: true)}
else
{:noreply, socket}
end
end
def handle_event("queue_focused_cell_evaluation", %{}, socket) do
if socket.assigns.focused_cell_id do
Session.queue_cell_evaluation(socket.assigns.session_id, socket.assigns.focused_cell_id)
end
def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id}, socket) do
Session.queue_cell_evaluation(socket.assigns.session_id, cell_id)
{:noreply, socket}
end
def handle_event("queue_section_cells_evaluation", %{}, socket) do
if socket.assigns.selected_section_id do
{:ok, section} =
Notebook.fetch_section(
socket.assigns.data.notebook,
socket.assigns.selected_section_id
)
def handle_event("queue_section_cells_evaluation", %{"section_id" => section_id}, socket) do
with {:ok, section} <- Notebook.fetch_section(socket.assigns.data.notebook, section_id) do
for cell <- section.cells, cell.type == :elixir do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
end
@ -366,14 +290,20 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("queue_child_cells_evaluation", %{}, socket) do
if socket.assigns.focused_cell_id do
{:ok, cell, _section} =
Notebook.fetch_cell_and_section(
socket.assigns.data.notebook,
socket.assigns.focused_cell_id
)
def handle_event("queue_all_cells_evaluation", _params, socket) do
data = socket.assigns.data
for {cell, _} <- Notebook.elixir_cells_with_section(data.notebook),
data.cell_infos[cell.id].validity_status != :evaluated do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
end
{:noreply, socket}
end
def handle_event("queue_child_cells_evaluation", %{"cell_id" => cell_id}, socket) do
with {:ok, cell, _section} <-
Notebook.fetch_cell_and_section(socket.assigns.data.notebook, cell_id) do
for {cell, _} <- Notebook.child_cells_with_section(socket.assigns.data.notebook, cell.id) do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
end
@ -382,10 +312,8 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("cancel_focused_cell_evaluation", %{}, socket) do
if socket.assigns.focused_cell_id do
Session.cancel_cell_evaluation(socket.assigns.session_id, socket.assigns.focused_cell_id)
end
def handle_event("cancel_cell_evaluation", %{"cell_id" => cell_id}, socket) do
Session.cancel_cell_evaluation(socket.assigns.session_id, cell_id)
{:noreply, socket}
end
@ -435,46 +363,44 @@ defmodule LivebookWeb.SessionLive do
defp after_operation(socket, _prev_socket, {:insert_section, client_pid, _index, section_id}) do
if client_pid == self() do
assign(socket, selected_section_id: section_id)
push_event(socket, "section_inserted", %{section_id: section_id})
else
socket
end
end
defp after_operation(socket, _prev_socket, {:delete_section, _client_pid, section_id}) do
if section_id == socket.assigns.selected_section_id do
assign(socket, selected_section_id: nil)
else
socket
end
push_event(socket, "section_deleted", %{section_id: section_id})
end
defp after_operation(socket, _prev_socket, {:insert_cell, client_pid, _, _, _, cell_id}) do
if client_pid == self() do
{:ok, cell, _section} =
Notebook.fetch_cell_and_section(socket.assigns.data.notebook, cell_id)
focus_cell(socket, cell, insert_mode: true)
push_event(socket, "cell_inserted", %{cell_id: cell_id})
else
socket
end
end
defp after_operation(socket, prev_socket, {:delete_cell, _client_pid, cell_id}) do
if cell_id == socket.assigns.focused_cell_id do
# Find a sibling cell that the client would focus if the deleted cell has focus.
sibling_cell_id =
case Notebook.fetch_cell_sibling(prev_socket.assigns.data.notebook, cell_id, 1) do
{:ok, next_cell} ->
focus_cell(socket, next_cell)
next_cell.id
:error ->
case Notebook.fetch_cell_sibling(prev_socket.assigns.data.notebook, cell_id, -1) do
{:ok, previous_cell} ->
focus_cell(socket, previous_cell)
:error ->
focus_cell(socket, nil)
{:ok, previous_cell} -> previous_cell.id
:error -> nil
end
end
push_event(socket, "cell_deleted", %{cell_id: cell_id, sibling_cell_id: sibling_cell_id})
end
defp after_operation(socket, _prev_socket, {:move_cell, client_pid, cell_id, _offset}) do
if client_pid == self() do
push_event(socket, "cell_moved", %{cell_id: cell_id})
else
socket
end
@ -506,60 +432,10 @@ defmodule LivebookWeb.SessionLive do
end
end
defp focus_cell(socket, cell, opts \\ [])
defp focus_cell(socket, nil = _cell, _opts) do
assign(socket, focused_cell_id: nil, focused_cell_type: nil, insert_mode: false)
end
defp focus_cell(socket, cell, opts) do
insert_mode? = Keyword.get(opts, :insert_mode, false)
assign(socket,
focused_cell_id: cell.id,
focused_cell_type: cell.type,
insert_mode: insert_mode?
)
end
defp insert_cell_next_to_focused(assigns, type, idx_offset: idx_offset) do
if assigns.focused_cell_id do
{:ok, cell, section} =
Notebook.fetch_cell_and_section(assigns.data.notebook, assigns.focused_cell_id)
index = Enum.find_index(section.cells, &(&1 == cell))
Session.insert_cell(assigns.session_id, section.id, index + idx_offset, type)
else
append_cell_to_section(assigns, type)
end
end
defp append_cell_to_section(assigns, type) do
if assigns.selected_section_id do
{:ok, section} = Notebook.fetch_section(assigns.data.notebook, assigns.selected_section_id)
end_index = length(section.cells)
Session.insert_cell(assigns.session_id, section.id, end_index, type)
end
end
defp new_focused_cell_from_offset(assigns, offset) do
cond do
assigns.focused_cell_id ->
# If a cell is focused, look up the appropriate sibling
Notebook.fetch_cell_sibling(assigns.data.notebook, assigns.focused_cell_id, offset)
assigns.selected_section_id ->
# If no cell is focused, focus the first one for easier keyboard navigation.
{:ok, section} =
Notebook.fetch_section(assigns.data.notebook, assigns.selected_section_id)
Enum.fetch(section.cells, 0)
true ->
:error
end
defp insert_cell_next_to(assigns, cell_id, type, idx_offset: idx_offset) do
{:ok, cell, section} = Notebook.fetch_cell_and_section(assigns.data.notebook, cell_id)
index = Enum.find_index(section.cells, &(&1 == cell))
Session.insert_cell(assigns.session_id, section.id, index + idx_offset, type)
end
defp runtime_description(nil), do: "No runtime"

View file

@ -20,6 +20,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
%{seq: "dd", desc: "Delete cell"},
%{seq: "ee", desc: "Evaluate cell"},
%{seq: "es", desc: "Evaluate section"},
%{seq: "ea", desc: "Evaluate all stale/new cells"},
%{seq: "ej", desc: "Evaluate cells below"},
%{seq: "ex", desc: "Cancel cell evaluation"}
]

View file

@ -309,6 +309,31 @@ defmodule Livebook.Session.DataTest do
}, []} = Data.apply_operation(data, operation)
end
test "allows moving cells between sections" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_section, self(), 1, "s2"},
# Add cells
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:insert_cell, self(), "s2", 0, :elixir, "c3"},
{:insert_cell, self(), "s2", 1, :elixir, "c4"}
])
operation = {:move_cell, self(), "c2", 1}
assert {:ok,
%{
notebook: %{
sections: [
%{cells: [%{id: "c1"}]},
%{cells: [%{id: "c2"}, %{id: "c3"}, %{id: "c4"}]}
]
}
}, []} = Data.apply_operation(data, operation)
end
test "marks relevant cells in further sections as stale" do
data =
data_after_operations!([

View file

@ -92,85 +92,75 @@ defmodule LivebookWeb.SessionLiveTest do
Session.get_data(session_id)
end
test "queueing focused cell evaluation", %{conn: conn, session_id: session_id} do
test "queueing cell evaluation", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :elixir, "Process.sleep(10)")
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
focus_cell(view, cell_id)
view
|> element("#session")
|> render_hook("queue_focused_cell_evaluation", %{})
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :evaluating}}} =
Session.get_data(session_id)
end
test "cancelling focused cell evaluation", %{conn: conn, session_id: session_id} do
test "cancelling cell evaluation", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :elixir, "Process.sleep(2000)")
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
focus_cell(view, cell_id)
view
|> element("#session")
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
view
|> element("#session")
|> render_hook("queue_focused_cell_evaluation", %{})
view
|> element("#session")
|> render_hook("cancel_focused_cell_evaluation", %{})
|> render_hook("cancel_cell_evaluation", %{"cell_id" => cell_id})
assert %{cell_infos: %{^cell_id => %{evaluation_status: :ready}}} =
Session.get_data(session_id)
end
test "inserting a cell below the focused cell", %{conn: conn, session_id: session_id} do
test "inserting a cell below the given cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :elixir)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
focus_cell(view, cell_id)
view
|> element("#session")
|> render_hook("insert_cell_below_focused", %{"type" => "markdown"})
|> render_hook("insert_cell_below", %{"cell_id" => cell_id, "type" => "markdown"})
assert %{notebook: %{sections: [%{cells: [_first_cell, %{type: :markdown}]}]}} =
Session.get_data(session_id)
end
test "inserting a cell above the focused cell", %{conn: conn, session_id: session_id} do
test "inserting a cell above the given cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :elixir)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
focus_cell(view, cell_id)
view
|> element("#session")
|> render_hook("insert_cell_above_focused", %{"type" => "markdown"})
|> render_hook("insert_cell_above", %{"cell_id" => cell_id, "type" => "markdown"})
assert %{notebook: %{sections: [%{cells: [%{type: :markdown}, _first_cell]}]}} =
Session.get_data(session_id)
end
test "deleting the focused cell", %{conn: conn, session_id: session_id} do
test "deleting the given cell", %{conn: conn, session_id: session_id} do
section_id = insert_section(session_id)
cell_id = insert_cell(session_id, section_id, :elixir)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
focus_cell(view, cell_id)
view
|> element("#session")
|> render_hook("delete_focused_cell", %{})
|> render_hook("delete_cell", %{"cell_id" => cell_id})
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session_id)
end
@ -201,10 +191,4 @@ defmodule LivebookWeb.SessionLiveTest do
cell.id
end
defp focus_cell(view, cell_id) do
view
|> element("#session")
|> render_hook("focus_cell", %{"cell_id" => cell_id})
end
end